From 7aeb8b21d4cafcc1163df041ceb094703d3a011e Mon Sep 17 00:00:00 2001 From: zakstucke <44890343+zakstucke@users.noreply.github.com> Date: Thu, 22 Feb 2024 15:06:20 +0200 Subject: [PATCH] dev (#27) * Cargo hack for checking all features properly in qa * StdCapture context manager python, panic_on_err_async macro, bash runner CmdOut contains attempted commands and a pretty formatting method of those commands to see what caused a failure, plus CmdOut is attached to BashErr errors coming out too --- .github/workflows/release.yml | 3 +- opencollector.yaml | 2 +- py/bitbazaar/misc/__init__.py | 3 +- py/bitbazaar/misc/_std_capture.py | 53 ++++++++++++++ py/tests/misc/test_std_capture.py | 33 +++++++++ rust/Cargo.lock | 1 + rust/Cargo.toml | 1 + rust/bitbazaar/cli/bash.rs | 55 ++++++++------- rust/bitbazaar/cli/builtins/cd.rs | 6 +- rust/bitbazaar/cli/builtins/echo.rs | 1 + rust/bitbazaar/cli/builtins/mod.rs | 2 + rust/bitbazaar/cli/builtins/pwd.rs | 1 + rust/bitbazaar/cli/builtins/set.rs | 12 +--- rust/bitbazaar/cli/cmd_out.rs | 37 ++++++++++ rust/bitbazaar/cli/errs.rs | 44 +++++++++--- rust/bitbazaar/cli/mod.rs | 38 ++++++++++ rust/bitbazaar/cli/shell.rs | 103 ++++++++++++++++++---------- rust/bitbazaar/errors/macros.rs | 74 ++++++++++++++++++-- rust/bitbazaar/prelude.rs | 2 +- 19 files changed, 374 insertions(+), 97 deletions(-) create mode 100644 py/bitbazaar/misc/_std_capture.py create mode 100644 py/tests/misc/test_std_capture.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f875bfad..c5f61d75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,7 +92,8 @@ jobs: if: ${{ inputs.py_rust_release }} || ${{ inputs.rust_release }} with: components: rustfmt, clippy - - name: Install cargo-hack, used for feature checking in pre-commit. + + - name: "Install cargo-hack, used for feature checking in pre-commit." if: ${{ inputs.py_rust_release }} || ${{ inputs.rust_release }} uses: taiki-e/install-action@cargo-hack diff --git a/opencollector.yaml b/opencollector.yaml index 2024bff1..7ee2e638 100644 --- a/opencollector.yaml +++ b/opencollector.yaml @@ -32,7 +32,7 @@ exporters: stream-name: default # Writes all opentelemetry logs, traces, metrics to a file, useful for testing: file/debug_file_writing: - path: /home/runner/work/bitbazaar/bitbazaar/logs/otlp_telemetry_out.log + path: /Users/zak/z/code/bitbazaar/logs/otlp_telemetry_out.log rotation: max_megabytes: 10 max_days: 3 diff --git a/py/bitbazaar/misc/__init__.py b/py/bitbazaar/misc/__init__.py index 7d4e3c56..113eb632 100644 --- a/py/bitbazaar/misc/__init__.py +++ b/py/bitbazaar/misc/__init__.py @@ -1,7 +1,8 @@ """Miscellaneous utility functions for BitBazaar.""" +from ._std_capture import StdCapture -__all__ = ["copy_sig"] +__all__ = ["copy_sig", "StdCapture"] import os import socket diff --git a/py/bitbazaar/misc/_std_capture.py b/py/bitbazaar/misc/_std_capture.py new file mode 100644 index 00000000..24f43812 --- /dev/null +++ b/py/bitbazaar/misc/_std_capture.py @@ -0,0 +1,53 @@ +import io +import sys +import typing as tp + + +class StdCapture(list): + r"""Capture stdout/stderr for the duration of a with block. + + (e.g. print statements) + + Example: + ```python + with StdCapture(stderr=True) as out: # By default only captures stdout + print('hello') + sys.stderr.write('world') + print(out) # ['hello', 'world'] + ``` + """ + + _stderr_capture: bool + _stdout: "tp.TextIO" + _stderr: "tp.TextIO" + _buf: "io.StringIO" + _out: "list[str]" + + def __init__(self, stderr: bool = False): + """Creation of new capturer. + + By default only stdout is captured, stderr=True enables capturing stderr too. + """ + # Prep all instance vars: + self.stderr_capture = stderr + self._out = [] + self._stdout = sys.stdout + self._stderr = sys.stderr + self._buf = io.StringIO() + + def __enter__(self) -> list[str]: + """Entering the capturing context.""" + # Overwrite sinks which are being captured with the buffer: + sys.stdout = self._buf + if self.stderr_capture: + sys.stderr = self._buf + + return self._out + + def __exit__(self, *args): # type: ignore + """On context exit.""" + # First reset the global streams: + sys.stdout = self._stdout + sys.stderr = self._stderr + + self._out.extend(self._buf.getvalue().splitlines()) diff --git a/py/tests/misc/test_std_capture.py b/py/tests/misc/test_std_capture.py new file mode 100644 index 00000000..3c6e3d17 --- /dev/null +++ b/py/tests/misc/test_std_capture.py @@ -0,0 +1,33 @@ +import sys + +from bitbazaar.misc import StdCapture + + +def test_std_capture(): + orig_stdout = sys.stdout + orig_stderr = sys.stderr + + # Confirm the example works: + with StdCapture(stderr=True) as out: # By default only captures stdout + print("hello") + sys.stderr.write("world") + assert out == ["hello", "world"] + + # Confirm no stderr captured if not requested: + with StdCapture() as out: + sys.stderr.write("world") + assert out == [] + + # Confirm returned to originals: + assert sys.stdout is orig_stdout + assert sys.stderr is orig_stderr + + # Confirm would also return if error occurred inside block: + try: + with StdCapture(stderr=True): + raise ValueError("error") + except ValueError: + pass + + assert sys.stdout is orig_stdout + assert sys.stderr is orig_stderr diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9222d06b..91e3ecd2 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -204,6 +204,7 @@ dependencies = [ "conch-parser", "deadpool-redis", "error-stack", + "futures", "homedir", "hostname", "http 1.0.0", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 94e90a2f..ab4559f1 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -117,6 +117,7 @@ tokio = { version = '1.35', features = ["rt-multi-thread", "macros"] } uuid = { version = "1.6", features = ["v4"] } regex = "1" serde_json = "1" +futures = "0.3" [profile.profiler] inherits = "release" # Adds on top of the default release profile diff --git a/rust/bitbazaar/cli/bash.rs b/rust/bitbazaar/cli/bash.rs index 946325f5..c0c67b2a 100644 --- a/rust/bitbazaar/cli/bash.rs +++ b/rust/bitbazaar/cli/bash.rs @@ -3,8 +3,6 @@ use std::{ path::{Path, PathBuf}, }; -use conch_parser::{lexer::Lexer, parse::DefaultParser}; - use super::{errs::ShellErr, shell::Shell, BashErr, CmdOut}; use crate::prelude::*; @@ -61,9 +59,9 @@ impl Bash { } } - /// Add a new command to the bash script. + /// Add a new piece of logic to the bash script. E.g. a line of bash. /// - /// Multiple added commands will be treated as e.g. lines in a bash script. + /// Multiple commands added to a [`Bash`] instance will be treated as newline separated. pub fn cmd(self, cmd: impl Into) -> Self { let mut cmds = self.cmds; cmds.push(cmd.into()); @@ -99,33 +97,34 @@ impl Bash { /// Execute the current contents of the bash script. pub fn run(self) -> Result { if self.cmds.is_empty() { - return Ok(CmdOut { - stdout: "".to_string(), - stderr: "".to_string(), - code: 0, - }); + return Ok(CmdOut::empty()); } - let cmd_str = self.cmds.join("\n"); - let lex = Lexer::new(cmd_str.chars()); - let parser = DefaultParser::new(lex); - - let top_cmds = parser - .into_iter() - .collect::, _>>() - .change_context(BashErr::BashSyntaxError)?; + let mut shell = Shell::new(self.env_vars, self.root_dir) + .map_err(|e| shell_to_bash_err(CmdOut::empty(), e))?; - match Shell::exec(self.root_dir.as_deref(), self.env_vars, top_cmds) { - Ok(cmd_out) => Ok(cmd_out), - Err(e) => match e.current_context() { - ShellErr::Exit => Err(e.change_context(BashErr::InternalError).attach_printable( - "Exit's should be handled and transformed internally in Shell::exec.", - )), - ShellErr::InternalError => Err(e.change_context(BashErr::InternalError)), - ShellErr::BashFeatureUnsupported => { - Err(e.change_context(BashErr::BashFeatureUnsupported)) - } - }, + if let Err(e) = shell.execute_command_strings(self.cmds) { + return Err(shell_to_bash_err(shell.into(), e)); } + + Ok(shell.into()) + } +} + +fn shell_to_bash_err( + mut cmd_out: CmdOut, + e: error_stack::Report, +) -> error_stack::Report { + // Doesn't really make sense, but set the exit code to 1 if 0, as technically the command errored even though it was the runner itself that errored and the command might not have been attempted. + if cmd_out.code == 0 { + cmd_out.code = 1; + } + match e.current_context() { + ShellErr::Exit => e.change_context(BashErr::InternalError(cmd_out)).attach_printable( + "Shouldn't occur, shell exit errors should have been managed internally, not an external error.", + ), + ShellErr::InternalError => e.change_context(BashErr::InternalError(cmd_out)), + ShellErr::BashFeatureUnsupported => e.change_context(BashErr::BashFeatureUnsupported(cmd_out)), + ShellErr::BashSyntaxError => e.change_context(BashErr::BashSyntaxError(cmd_out)), } } diff --git a/rust/bitbazaar/cli/builtins/cd.rs b/rust/bitbazaar/cli/builtins/cd.rs index 56f5707a..70410ffd 100644 --- a/rust/bitbazaar/cli/builtins/cd.rs +++ b/rust/bitbazaar/cli/builtins/cd.rs @@ -82,11 +82,7 @@ pub fn cd(shell: &mut Shell, args: &[String]) -> Result { .chdir(target_path) .change_context(BuiltinErr::InternalError)?; - Ok(CmdOut { - stdout: "".to_string(), - stderr: "".to_string(), - code: 0, - }) + Ok(CmdOut::empty()) } // Should be tested quite well in cli/mod.rs and other builtin tests. diff --git a/rust/bitbazaar/cli/builtins/echo.rs b/rust/bitbazaar/cli/builtins/echo.rs index f1e3e158..db951128 100644 --- a/rust/bitbazaar/cli/builtins/echo.rs +++ b/rust/bitbazaar/cli/builtins/echo.rs @@ -38,6 +38,7 @@ pub fn echo(_shell: &mut Shell, args: &[String]) -> Result { stdout, stderr: "".to_string(), code: 0, + attempted_commands: vec![], // This is a top level attribute, in theory should have a different struct for internal. }) } diff --git a/rust/bitbazaar/cli/builtins/mod.rs b/rust/bitbazaar/cli/builtins/mod.rs index e967ba9b..1b182ba5 100644 --- a/rust/bitbazaar/cli/builtins/mod.rs +++ b/rust/bitbazaar/cli/builtins/mod.rs @@ -24,6 +24,7 @@ macro_rules! bad_call { stdout: "".to_string(), stderr: format!($($arg)*), code: 1, + attempted_commands: vec![], // This is a top level attribute, in theory should have a different struct for internal. }) }; } @@ -50,5 +51,6 @@ fn std_err_echo(_shell: &mut Shell, args: &[String]) -> Result Result { stdout: format!("{}\n", pwd), stderr: "".to_string(), code: 0, + attempted_commands: vec![], // This is a top level attribute, in theory should have a different struct for internal. }) } diff --git a/rust/bitbazaar/cli/builtins/set.rs b/rust/bitbazaar/cli/builtins/set.rs index caa564e6..5c4cc912 100644 --- a/rust/bitbazaar/cli/builtins/set.rs +++ b/rust/bitbazaar/cli/builtins/set.rs @@ -9,19 +9,11 @@ pub fn set(shell: &mut Shell, args: &[String]) -> Result { match arg.as_str() { "+e" => { shell.set_e = false; - return Ok(CmdOut { - stdout: "".to_string(), - stderr: "".to_string(), - code: 0, - }); + return Ok(CmdOut::empty()); } "-e" => { shell.set_e = true; - return Ok(CmdOut { - stdout: "".to_string(), - stderr: "".to_string(), - code: 0, - }); + return Ok(CmdOut::empty()); } _ => {} } diff --git a/rust/bitbazaar/cli/cmd_out.rs b/rust/bitbazaar/cli/cmd_out.rs index 4c4042da..1b681191 100644 --- a/rust/bitbazaar/cli/cmd_out.rs +++ b/rust/bitbazaar/cli/cmd_out.rs @@ -7,9 +7,23 @@ pub struct CmdOut { pub stderr: String, /// The exit code of the command: pub code: i32, + + /// The commands that were run, will include all commands that were attempted. + /// I.e. if a command fails, it will be the last command in this vec, the remaining were not attempted. + pub attempted_commands: Vec, } impl CmdOut { + /// Create a new CmdOut with empty stdout, stderr, and a zero exit code. + pub(crate) fn empty() -> Self { + Self { + stdout: "".to_string(), + stderr: "".to_string(), + code: 0, + attempted_commands: Vec::new(), + } + } + /// Returns true when the command exited with a zero exit code. pub fn success(&self) -> bool { self.code == 0 @@ -29,4 +43,27 @@ impl CmdOut { self.stderr.clone() } } + + /// Pretty format the attempted commands, with the exit code included on the final line. + pub fn fmt_attempted_commands(&self) -> String { + if !self.attempted_commands.is_empty() { + let mut out = "Attempted commands:\n".to_string(); + for (index, cmd) in self.attempted_commands.iter().enumerate() { + // Indent the commands by a bit of whitespace: + out.push_str(" "); + // Add cmd number: + out.push_str(format!("{}. ", index).as_str()); + out.push_str(cmd.trim()); + // Newline if not last: + if index < self.attempted_commands.len() - 1 { + out.push('\n'); + } + } + // On the last line, add <-- exited with code: X + out.push_str(&format!(" <-- exited with code: {}", self.code)); + out + } else { + "No commands!".to_string() + } + } } diff --git a/rust/bitbazaar/cli/errs.rs b/rust/bitbazaar/cli/errs.rs index 1879bb7b..1d6f3a2c 100644 --- a/rust/bitbazaar/cli/errs.rs +++ b/rust/bitbazaar/cli/errs.rs @@ -1,21 +1,42 @@ +use super::CmdOut; + /// User facing error type for Bash functionality. -#[derive(Debug, strum::Display)] +#[derive(Debug)] pub enum BashErr { /// BashSyntaxError - #[strum(serialize = "BashSyntaxError: couldn't parse bash script.")] - BashSyntaxError, + BashSyntaxError(CmdOut), /// BashFeatureUnsupported - #[strum( - serialize = "BashFeatureUnsupported: feature in script is valid bash, but unsupported." - )] - BashFeatureUnsupported, + BashFeatureUnsupported(CmdOut), /// InternalError - #[strum( - serialize = "InternalError: this shouldn't occur, open an issue at https://github.com/zakstucke/bitbazaar/issues" - )] - InternalError, + InternalError(CmdOut), +} + +impl BashErr { + /// Get the CmdOut from the error. + pub fn cmd_out(&self) -> &CmdOut { + match self { + BashErr::BashSyntaxError(cmd_out) => cmd_out, + BashErr::BashFeatureUnsupported(cmd_out) => cmd_out, + BashErr::InternalError(cmd_out) => cmd_out, + } + } +} + +impl std::fmt::Display for BashErr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BashErr::BashSyntaxError(cmd_out) => write!(f, "BashSyntaxError: couldn't parse bash script.\n{}", cmd_out.fmt_attempted_commands()), + BashErr::BashFeatureUnsupported(cmd_out) => { + write!(f, "BashFeatureUnsupported: feature in script is valid bash, but unsupported.\n{}", cmd_out.fmt_attempted_commands()) + } + BashErr::InternalError(cmd_out) => write!( + f, + "InternalError: this shouldn't occur, open an issue at https://github.com/zakstucke/bitbazaar/issues\n{}", cmd_out.fmt_attempted_commands() + ), + } + } } impl error_stack::Context for BashErr {} @@ -24,6 +45,7 @@ impl error_stack::Context for BashErr {} #[derive(Debug, strum::Display)] pub enum ShellErr { BashFeatureUnsupported, + BashSyntaxError, Exit, InternalError, } diff --git a/rust/bitbazaar/cli/mod.rs b/rust/bitbazaar/cli/mod.rs index 531ba47f..0d8bf7ad 100644 --- a/rust/bitbazaar/cli/mod.rs +++ b/rust/bitbazaar/cli/mod.rs @@ -298,4 +298,42 @@ mod tests { assert_eq!(res.stdout.trim(), format!("bar qux")); Ok(()) } + + // Confirm when both when doesn't error but not all commands run AND when Bash errors the final command that was attempted is accessible and printable. + #[rstest] + fn test_error_source_attached(#[allow(unused_variables)] logging: ()) -> Result<(), AnyErr> { + let err_cmd = "ab||][/?cd"; + + // Confirm that when bash itself fails (i.e. invalid syntax), the source is attached to the error: + let res = Bash::new() + .cmd("echo foo") + .cmd(err_cmd) + .cmd("echo bar") + .run(); + assert!(res.is_err()); + let fmted = format!("{:?}", res.as_ref().unwrap_err()); + assert!( + fmted.contains(format!("{} <-- exited with code:", err_cmd).as_str()), + "{}", + fmted + ); + + // Confirm cmd out is attached and the source could be inferred from there: + let e = res.unwrap_err(); + let cmd_out = e.current_context().cmd_out(); + assert_eq!(cmd_out.attempted_commands.len(), 2); + assert_eq!(cmd_out.attempted_commands[1], err_cmd); + + // Now confirm when it's a valid set of commands, but one just fails, also accessible: + let cmd_out = Bash::new() + .cmd("echo foo") + .cmd("echo bar && false") + .cmd("echo bar") + .run() + .unwrap(); + assert_eq!(cmd_out.attempted_commands.len(), 2); + assert_eq!(cmd_out.attempted_commands[1], "echo bar && false"); + + Ok(()) + } } diff --git a/rust/bitbazaar/cli/shell.rs b/rust/bitbazaar/cli/shell.rs index 290726c2..52a35afc 100644 --- a/rust/bitbazaar/cli/shell.rs +++ b/rust/bitbazaar/cli/shell.rs @@ -1,11 +1,6 @@ -use std::{ - collections::HashMap, - mem, - path::{Path, PathBuf}, - str, -}; - -use conch_parser::ast; +use std::{collections::HashMap, mem, path::PathBuf, str}; + +use conch_parser::{ast, lexer::Lexer, parse::DefaultParser}; use normpath::PathExt; use super::{errs::ShellErr, runner::PipeRunner, CmdOut}; @@ -26,6 +21,9 @@ pub struct Shell { pub stdout: String, pub stderr: String, pub set_e: bool, + // Each executed command string supplied will be added here. Will be here even if the command fails. + // Only commands that weren't tried due to previous problems will be missing. + pub attempted_command_strings: Vec, } impl From for CmdOut { @@ -36,23 +34,21 @@ impl From for CmdOut { stdout: val.stdout.replace("\r\n", "\n"), stderr: val.stderr.replace("\r\n", "\n"), code: val.code, + attempted_commands: val.attempted_command_strings, } } else { CmdOut { stdout: val.stdout, stderr: val.stderr, code: val.code, + attempted_commands: val.attempted_command_strings, } } } } impl Shell { - pub fn exec( - root_dir: Option<&Path>, - env: HashMap, - cmds: Vec>, - ) -> Result { + pub fn new(env: HashMap, root_dir: Option) -> Result { let mut shell = Self { root_dir: None, vars: env, @@ -61,24 +57,15 @@ impl Shell { stderr: String::new(), // By default have set -e enabled to break if a line errors: set_e: true, + attempted_command_strings: Vec::new(), }; - if let Some(parent_root_dir) = root_dir { - // Set root dir: - shell.chdir(parent_root_dir.to_path_buf())?; - } - - if let Err(e) = shell.run_top_cmds(cmds) { - match e.current_context() { - // Exits shouldn't propagate outside a shell, the handler will have already set the proper code to the shell. - ShellErr::Exit => {} - _ => { - return Err(e); - } - } + // Chdir() does some normalisation logic, so using that rather than just setting to shell above directly: + if let Some(root_dir) = root_dir { + shell.chdir(root_dir)?; } - Ok(shell.into()) + Ok(shell) } pub fn active_dir(&self) -> Result { @@ -101,6 +88,53 @@ impl Shell { Ok(()) } + pub fn execute_command_strings(&mut self, commands: Vec) -> Result<(), ShellErr> { + // Whilst all commands could be given to the parser together (newline separated), + // and run internally by the shell in a single function call, + // that mean's the source command string that causes an issues would be lost + // (e.g. an actual ShellErr OR a non zero exit code stopping further commands from running) + for cmd_source in commands { + // Add the command before hitting anything that could fail: + self.attempted_command_strings.push(cmd_source.clone()); + + let lex = Lexer::new(cmd_source.chars()); + let parser = DefaultParser::new(lex); + + let parsed_top_cmds = parser + .into_iter() + .collect::, _>>() + .change_context(ShellErr::BashSyntaxError)?; + + // Run the command: + if let Err(e) = self.run_top_cmds(parsed_top_cmds) { + match e.current_context() { + // Exits shouldn't propagate outside a shell, the handler will have already set the proper code to the shell. + ShellErr::Exit => {} + _ => { + return Err(e); + } + } + } + + if !self.should_continue() { + break; + } + } + + Ok(()) + } + + /// Returns false when the code isn't 0 and set -e is enabled. + fn should_continue(&self) -> bool { + // Don't continue if set -e is enabled and the last command failed: + #[allow(clippy::needless_bool)] + if self.code != 0 && self.set_e { + false + } else { + true + } + } + fn run_top_cmds(&mut self, cmds: Vec>) -> Result<(), ShellErr> { // Each res equates to a line in a multi line bash script. E.g. a single line command will only have one res. for cmd in cmds { @@ -136,8 +170,7 @@ impl Shell { } } - // Don't continue if set -e is enabled and the last command failed: - if self.code != 0 && self.set_e { + if !self.should_continue() { break; } } @@ -179,11 +212,9 @@ impl Shell { // E.g. (echo foo && echo bar) match &compound.kind { ast::CompoundCommandKind::Subshell(sub_cmds) => { - let out = Shell::exec( - self.root_dir.as_deref(), - self.vars.clone(), - sub_cmds.clone(), - )?; + let mut shell = Shell::new(self.vars.clone(), self.root_dir.clone())?; + shell.run_top_cmds(sub_cmds.clone())?; + let out: CmdOut = shell.into(); // Add the stderr to the current shell: self.stderr.push_str(&out.stderr); @@ -422,7 +453,9 @@ impl Shell { // - stderr prints to console so in our case it should be added to the root stderr // - It runs in its own shell, so shell vars aren't shared debug!("Running nested command: {:?}", cmds); - let out = Shell::exec(self.root_dir.as_deref(), self.vars.clone(), cmds.clone())?; + let mut shell = Shell::new(self.vars.clone(), self.root_dir.clone())?; + shell.run_top_cmds(cmds.clone())?; + let out: CmdOut = shell.into(); // Add the stderr to the outer stderr, the stdout return to the caller: self.stderr.push_str(&out.stderr); diff --git a/rust/bitbazaar/errors/macros.rs b/rust/bitbazaar/errors/macros.rs index 1fad0705..6b8ba605 100644 --- a/rust/bitbazaar/errors/macros.rs +++ b/rust/bitbazaar/errors/macros.rs @@ -62,11 +62,24 @@ macro_rules! err { /// Allows use of e.g. `?` in the block. #[macro_export] macro_rules! panic_on_err { - ($closure:block) => {{ - use error_stack::{Result, ResultExt}; - use $crate::errors::AnyErr; + ($content:block) => {{ + #[allow(clippy::redundant_closure_call)] + match ((|| $content)()) { + Ok(s) => s, + Err(e) => { + panic!("{:?}", e); + } + } + }}; +} - match (|| -> Result<_, AnyErr> { $closure })() { +/// When working in a function that cannot return a result, use this to auto panic with the formatted error if something goes wrong. +/// +/// Allows use of e.g. `?` in the block. +#[macro_export] +macro_rules! panic_on_err_async { + ($content:block) => {{ + match (async { $content }).await { Ok(s) => s, Err(e) => { panic!("{:?}", e); @@ -74,3 +87,56 @@ macro_rules! panic_on_err { } }}; } + +#[cfg(test)] +mod tests { + use futures::FutureExt; + use rstest::*; + + use crate::prelude::*; + + #[rstest] + fn panic_on_err() { + // Should work fine: + let result = panic_on_err!({ + // ? syntax should work, test with something fallible: + let _ = Ok::<_, AnyErr>(1)?; + Ok::<_, AnyErr>(1) + }); + assert_eq!(result, 1); + + let should_err = std::panic::catch_unwind(|| { + panic_on_err!({ + // ? syntax should work, test with something fallible: + let _ = Ok::<_, AnyErr>(1)?; + Err(anyerr!("foo")) + }); + }); + assert!(should_err.is_err()); + } + + #[rstest] + #[tokio::test] + async fn panic_on_err_async() { + let async_result = panic_on_err_async!({ + // ? syntax should work, test with something fallible: + let _ = Ok::<_, AnyErr>(1)?; + + tokio::time::sleep(std::time::Duration::from_nanos(1)).await; + Ok::<_, AnyErr>(1) + }); + assert_eq!(async_result, 1); + + let should_err = async { + panic_on_err_async!({ + // ? syntax should work, test with something fallible: + let _ = Ok::<_, AnyErr>(1)?; + + futures::future::ready(1).await; + Err(anyerr!("foo")) + }); + }; + + assert!(should_err.catch_unwind().await.is_err()); + } +} diff --git a/rust/bitbazaar/prelude.rs b/rust/bitbazaar/prelude.rs index fa960328..cdd9d362 100644 --- a/rust/bitbazaar/prelude.rs +++ b/rust/bitbazaar/prelude.rs @@ -4,4 +4,4 @@ pub use error_stack::{Result, ResultExt}; pub use tracing::{debug, error, info, warn}; #[allow(unused_imports)] -pub use crate::{anyerr, err, errors::prelude::*, panic_on_err}; +pub use crate::{anyerr, err, errors::prelude::*, panic_on_err, panic_on_err_async};