use thiserror::Error;
use crate::processor::NodeOutput;
#[derive(Debug, Error)]
pub enum BntoError {
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Unsupported format: {0}")]
UnsupportedFormat(String),
#[error("Processing failed: {0}")]
ProcessingFailed(String),
#[error("Operation cancelled")]
Cancelled,
#[error("Run timed out after {secs}s")]
TimedOut { secs: u64 },
#[error("File too large: {0} bytes")]
FileTooLarge(u64),
#[error("Out of memory: {0}")]
OutOfMemory(String),
#[error("Processing failed: {message}")]
CommandFailed { message: String, stdout: Vec<u8> },
#[error("{}", .0.error)]
FailedWithOutput(Box<FailedOutput>),
#[error("{first}; closing step '{node_id}' also failed: {error}")]
ClosingStepFailed {
first: Box<BntoError>,
node_id: String,
error: Box<BntoError>,
},
}
pub struct FailedOutput {
pub error: BntoError,
pub output: NodeOutput,
}
impl std::fmt::Debug for FailedOutput {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let names: Vec<&str> = self
.output
.files
.iter()
.map(|o| o.filename.as_str())
.collect();
f.debug_struct("FailedOutput")
.field("error", &self.error)
.field("files", &names)
.finish()
}
}
impl BntoError {
pub fn with_output(error: BntoError, output: NodeOutput) -> Self {
let (error, _) = error.into_parts();
Self::FailedWithOutput(Box::new(FailedOutput { error, output }))
}
pub fn is_timeout(&self) -> bool {
match self {
Self::TimedOut { .. } => true,
Self::FailedWithOutput(failed) => failed.error.is_timeout(),
Self::ClosingStepFailed { first, error, .. } => {
first.is_timeout() || error.is_timeout()
}
_ => false,
}
}
pub fn into_parts(self) -> (BntoError, Option<NodeOutput>) {
match self {
Self::FailedWithOutput(failed) => (failed.error, Some(failed.output)),
error => (error, None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_invalid_input_error_message() {
let error = BntoError::InvalidInput("missing file data".to_string());
let msg = error.to_string();
assert_eq!(msg, "Invalid input: missing file data");
}
#[test]
fn test_timed_out_error_names_the_limit() {
let error = BntoError::TimedOut { secs: 30 };
assert_eq!(error.to_string(), "Run timed out after 30s");
}
#[test]
fn test_a_timeout_is_seen_through_what_wraps_it() {
let output = NodeOutput {
files: vec![],
metadata: serde_json::Map::new(),
};
let wrapped = BntoError::with_output(BntoError::TimedOut { secs: 1 }, output);
assert!(wrapped.is_timeout());
let closing = BntoError::ClosingStepFailed {
first: Box::new(BntoError::ProcessingFailed("killed".into())),
node_id: "cleanup".into(),
error: Box::new(BntoError::TimedOut { secs: 1 }),
};
assert!(closing.is_timeout());
assert!(!BntoError::Cancelled.is_timeout());
}
#[test]
fn test_unsupported_format_error_message() {
let error = BntoError::UnsupportedFormat("BMP".to_string());
assert_eq!(error.to_string(), "Unsupported format: BMP");
}
#[test]
fn test_processing_failed_error_message() {
let error = BntoError::ProcessingFailed("decoder returned invalid data".to_string());
assert_eq!(
error.to_string(),
"Processing failed: decoder returned invalid data"
);
}
#[test]
fn test_cancelled_error_message() {
let error = BntoError::Cancelled;
assert_eq!(error.to_string(), "Operation cancelled");
}
#[test]
fn test_file_too_large_error_message() {
let error = BntoError::FileTooLarge(5_000_000_000);
assert_eq!(error.to_string(), "File too large: 5000000000 bytes");
}
fn log_output() -> NodeOutput {
NodeOutput {
files: vec![crate::processor::OutputFile {
data: crate::FileData::Bytes(b"log".to_vec()),
filename: "tool.log".to_string(),
mime_type: "text/plain".to_string(),
metadata: serde_json::Map::new(),
}],
metadata: serde_json::Map::new(),
}
}
#[test]
fn test_command_failed_displays_like_processing_failed() {
let error = BntoError::CommandFailed {
message: "Command 'tool' failed (exit 2): boom".to_string(),
stdout: b"partial".to_vec(),
};
assert_eq!(
error.to_string(),
"Processing failed: Command 'tool' failed (exit 2): boom"
);
}
#[test]
fn test_failed_with_output_displays_as_its_error_and_splits_back() {
let inner = BntoError::ProcessingFailed("tool exited 2".to_string());
let error = BntoError::with_output(inner, log_output());
assert_eq!(error.to_string(), "Processing failed: tool exited 2");
assert!(format!("{error:?}").contains("tool.log"), "{error:?}");
let (error, output) = error.into_parts();
assert!(matches!(error, BntoError::ProcessingFailed(_)));
assert_eq!(output.unwrap().files[0].filename, "tool.log");
}
#[test]
fn test_into_parts_of_a_plain_error_has_no_output() {
let (error, output) = BntoError::Cancelled.into_parts();
assert!(matches!(error, BntoError::Cancelled));
assert!(output.is_none());
}
#[test]
fn test_closing_step_failed_names_both_first_failure_first() {
let error = BntoError::ClosingStepFailed {
first: Box::new(BntoError::ProcessingFailed("build broke".to_string())),
node_id: "cleanup".to_string(),
error: Box::new(BntoError::InvalidInput("no file".to_string())),
};
assert_eq!(
error.to_string(),
"Processing failed: build broke; closing step 'cleanup' also failed: \
Invalid input: no file"
);
}
#[test]
fn test_out_of_memory_error_message() {
let error = BntoError::OutOfMemory("failed to allocate 2GB buffer".to_string());
assert_eq!(
error.to_string(),
"Out of memory: failed to allocate 2GB buffer"
);
}
}