1use thiserror::Error;
10
11use crate::processor::NodeOutput;
12
13#[derive(Debug, Error)]
18pub enum BntoError {
19 #[error("Invalid input: {0}")]
23 InvalidInput(String),
24
25 #[error("Unsupported format: {0}")]
28 UnsupportedFormat(String),
29
30 #[error("Processing failed: {0}")]
35 ProcessingFailed(String),
36
37 #[error("Operation cancelled")]
40 Cancelled,
41
42 #[error("Run timed out after {secs}s")]
45 TimedOut { secs: u64 },
46
47 #[error("File too large: {0} bytes")]
52 FileTooLarge(u64),
53
54 #[error("Out of memory: {0}")]
58 OutOfMemory(String),
59
60 #[error("Processing failed: {message}")]
64 CommandFailed { message: String, stdout: Vec<u8> },
65
66 #[error("{}", .0.error)]
70 FailedWithOutput(Box<FailedOutput>),
71
72 #[error("{first}; closing step '{node_id}' also failed: {error}")]
75 ClosingStepFailed {
76 first: Box<BntoError>,
77 node_id: String,
78 error: Box<BntoError>,
79 },
80}
81
82pub struct FailedOutput {
84 pub error: BntoError,
85 pub output: NodeOutput,
86}
87
88impl std::fmt::Debug for FailedOutput {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 let names: Vec<&str> = self
92 .output
93 .files
94 .iter()
95 .map(|o| o.filename.as_str())
96 .collect();
97 f.debug_struct("FailedOutput")
98 .field("error", &self.error)
99 .field("files", &names)
100 .finish()
101 }
102}
103
104impl BntoError {
105 pub fn with_output(error: BntoError, output: NodeOutput) -> Self {
108 let (error, _) = error.into_parts();
109 Self::FailedWithOutput(Box::new(FailedOutput { error, output }))
110 }
111
112 pub fn is_timeout(&self) -> bool {
116 match self {
117 Self::TimedOut { .. } => true,
118 Self::FailedWithOutput(failed) => failed.error.is_timeout(),
119 Self::ClosingStepFailed { first, error, .. } => {
120 first.is_timeout() || error.is_timeout()
121 }
122 _ => false,
123 }
124 }
125
126 pub fn into_parts(self) -> (BntoError, Option<NodeOutput>) {
128 match self {
129 Self::FailedWithOutput(failed) => (failed.error, Some(failed.output)),
130 error => (error, None),
131 }
132 }
133}
134
135#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn test_invalid_input_error_message() {
157 let error = BntoError::InvalidInput("missing file data".to_string());
159
160 let msg = error.to_string();
162
163 assert_eq!(msg, "Invalid input: missing file data");
164 }
165
166 #[test]
167 fn test_timed_out_error_names_the_limit() {
168 let error = BntoError::TimedOut { secs: 30 };
169 assert_eq!(error.to_string(), "Run timed out after 30s");
170 }
171
172 #[test]
173 fn test_a_timeout_is_seen_through_what_wraps_it() {
174 let output = NodeOutput {
175 files: vec![],
176 metadata: serde_json::Map::new(),
177 };
178 let wrapped = BntoError::with_output(BntoError::TimedOut { secs: 1 }, output);
179 assert!(wrapped.is_timeout());
180 let closing = BntoError::ClosingStepFailed {
181 first: Box::new(BntoError::ProcessingFailed("killed".into())),
182 node_id: "cleanup".into(),
183 error: Box::new(BntoError::TimedOut { secs: 1 }),
184 };
185 assert!(closing.is_timeout());
186 assert!(!BntoError::Cancelled.is_timeout());
187 }
188
189 #[test]
190 fn test_unsupported_format_error_message() {
191 let error = BntoError::UnsupportedFormat("BMP".to_string());
192 assert_eq!(error.to_string(), "Unsupported format: BMP");
193 }
194
195 #[test]
196 fn test_processing_failed_error_message() {
197 let error = BntoError::ProcessingFailed("decoder returned invalid data".to_string());
198 assert_eq!(
199 error.to_string(),
200 "Processing failed: decoder returned invalid data"
201 );
202 }
203
204 #[test]
205 fn test_cancelled_error_message() {
206 let error = BntoError::Cancelled;
207 assert_eq!(error.to_string(), "Operation cancelled");
208 }
209
210 #[test]
211 fn test_file_too_large_error_message() {
212 let error = BntoError::FileTooLarge(5_000_000_000);
214 assert_eq!(error.to_string(), "File too large: 5000000000 bytes");
215 }
216
217 fn log_output() -> NodeOutput {
218 NodeOutput {
219 files: vec![crate::processor::OutputFile {
220 data: crate::FileData::Bytes(b"log".to_vec()),
221 filename: "tool.log".to_string(),
222 mime_type: "text/plain".to_string(),
223 metadata: serde_json::Map::new(),
224 }],
225 metadata: serde_json::Map::new(),
226 }
227 }
228
229 #[test]
230 fn test_command_failed_displays_like_processing_failed() {
231 let error = BntoError::CommandFailed {
232 message: "Command 'tool' failed (exit 2): boom".to_string(),
233 stdout: b"partial".to_vec(),
234 };
235 assert_eq!(
236 error.to_string(),
237 "Processing failed: Command 'tool' failed (exit 2): boom"
238 );
239 }
240
241 #[test]
242 fn test_failed_with_output_displays_as_its_error_and_splits_back() {
243 let inner = BntoError::ProcessingFailed("tool exited 2".to_string());
244 let error = BntoError::with_output(inner, log_output());
245 assert_eq!(error.to_string(), "Processing failed: tool exited 2");
246 assert!(format!("{error:?}").contains("tool.log"), "{error:?}");
247 let (error, output) = error.into_parts();
248 assert!(matches!(error, BntoError::ProcessingFailed(_)));
249 assert_eq!(output.unwrap().files[0].filename, "tool.log");
250 }
251
252 #[test]
253 fn test_into_parts_of_a_plain_error_has_no_output() {
254 let (error, output) = BntoError::Cancelled.into_parts();
255 assert!(matches!(error, BntoError::Cancelled));
256 assert!(output.is_none());
257 }
258
259 #[test]
260 fn test_closing_step_failed_names_both_first_failure_first() {
261 let error = BntoError::ClosingStepFailed {
262 first: Box::new(BntoError::ProcessingFailed("build broke".to_string())),
263 node_id: "cleanup".to_string(),
264 error: Box::new(BntoError::InvalidInput("no file".to_string())),
265 };
266 assert_eq!(
267 error.to_string(),
268 "Processing failed: build broke; closing step 'cleanup' also failed: \
269 Invalid input: no file"
270 );
271 }
272
273 #[test]
274 fn test_out_of_memory_error_message() {
275 let error = BntoError::OutOfMemory("failed to allocate 2GB buffer".to_string());
276 assert_eq!(
277 error.to_string(),
278 "Out of memory: failed to allocate 2GB buffer"
279 );
280 }
281}