Skip to main content

bnto_core/
errors.rs

1// =============================================================================
2// Error Types
3// =============================================================================
4//
5// All error types for the Bnto WASM engine. When something goes wrong during
6// node execution (bad input, unsupported format, etc.), we return one of
7// these errors instead of crashing.
8
9use thiserror::Error;
10
11use crate::processor::NodeOutput;
12
13/// All possible errors from Bnto WASM node operations.
14///
15/// Each variant represents a different category of failure.
16/// The `#[error("...")]` attribute defines the display message.
17#[derive(Debug, Error)]
18pub enum BntoError {
19    // --- Input Validation Errors ---
20    /// The input data is invalid (wrong type, missing required field, etc.).
21    /// The string contains a human-readable explanation of what's wrong.
22    #[error("Invalid input: {0}")]
23    InvalidInput(String),
24
25    /// The file format isn't supported by this node.
26    /// For example, trying to compress a .bmp when we only support JPEG/PNG/WebP.
27    #[error("Unsupported format: {0}")]
28    UnsupportedFormat(String),
29
30    // --- Processing Errors ---
31    /// Something went wrong while processing the file.
32    /// This is a catch-all for errors during the actual work (compression,
33    /// CSV parsing, etc.). The string explains what happened.
34    #[error("Processing failed: {0}")]
35    ProcessingFailed(String),
36
37    /// The user cancelled the operation (or the Web Worker was terminated).
38    /// Not a "real" error — the user chose to stop.
39    #[error("Operation cancelled")]
40    Cancelled,
41
42    /// The run passed its time limit (`settings.timeout`, or the consumer's
43    /// override), so nothing more of it started.
44    #[error("Run timed out after {secs}s")]
45    TimedOut { secs: u64 },
46
47    // --- Resource Errors ---
48    /// A file is too large for browser processing.
49    /// Browser memory is limited (~2GB), so we check file sizes before
50    /// trying to process them.
51    #[error("File too large: {0} bytes")]
52    FileTooLarge(u64),
53
54    /// The browser ran out of memory while processing.
55    /// WASM has a limited memory heap. If we try to load a huge image,
56    /// we might exhaust it.
57    #[error("Out of memory: {0}")]
58    OutOfMemory(String),
59
60    // --- Failures That Carry More ---
61    /// An external command ran and exited non-zero. `stdout` is what it
62    /// printed, so a caller can still use it. Displays like `ProcessingFailed`.
63    #[error("Processing failed: {message}")]
64    CommandFailed { message: String, stdout: Vec<u8> },
65
66    /// A step failed but still handed over output (e.g. a failed command's
67    /// stdout), which the run's closing steps receive. Displays as the
68    /// underlying error. Build with [`BntoError::with_output`].
69    #[error("{}", .0.error)]
70    FailedWithOutput(Box<FailedOutput>),
71
72    /// A closing step (a node marked `always`) failed after an earlier step
73    /// had already failed the run. `first` stays the run's verdict.
74    #[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
82/// The error and output of a step that failed after making files.
83pub struct FailedOutput {
84    pub error: BntoError,
85    pub output: NodeOutput,
86}
87
88// File bytes would flood a debug print, so show the file names only.
89impl 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    /// `error`, handing over `output` along with the failure. Replaces any
106    /// output `error` already carried.
107    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    /// Whether this failure is, or began with, the run passing its time
113    /// limit. A timeout ends the run: no closing step or skipped loop item
114    /// runs past it.
115    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    /// The error itself and the output it handed over, if any.
127    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// =============================================================================
136// Converting BntoError to JavaScript (MOVED TO bnto-wasm)
137// =============================================================================
138//
139// NOTE: The `impl From<BntoError> for JsValue` conversion used to live here,
140// but it coupled bnto-core to WASM dependencies (wasm-bindgen, js-sys).
141// That forced every node crate to carry WASM deps even for pure Rust logic.
142//
143// The conversion now lives in `bnto-wasm/src/lib.rs` — the only crate that
144// should know about JavaScript types. This keeps bnto-core target-agnostic:
145// it works in WASM, native CLI, desktop (Tauri), or anywhere else.
146
147// =============================================================================
148// Tests
149// =============================================================================
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_invalid_input_error_message() {
157        // Create an error and check its message.
158        let error = BntoError::InvalidInput("missing file data".to_string());
159
160        // `.to_string()` triggers the `#[error("...")]` formatting.
161        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        // 5 GB in bytes
213        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}