1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
// =============================================================================
// Error Types
// =============================================================================
//
// All error types for the Bnto WASM engine. When something goes wrong during
// node execution (bad input, unsupported format, etc.), we return one of
// these errors instead of crashing.
use thiserror::Error;
use crate::processor::NodeOutput;
/// All possible errors from Bnto WASM node operations.
///
/// Each variant represents a different category of failure.
/// The `#[error("...")]` attribute defines the display message.
#[derive(Debug, Error)]
pub enum BntoError {
// --- Input Validation Errors ---
/// The input data is invalid (wrong type, missing required field, etc.).
/// The string contains a human-readable explanation of what's wrong.
#[error("Invalid input: {0}")]
InvalidInput(String),
/// The file format isn't supported by this node.
/// For example, trying to compress a .bmp when we only support JPEG/PNG/WebP.
#[error("Unsupported format: {0}")]
UnsupportedFormat(String),
// --- Processing Errors ---
/// Something went wrong while processing the file.
/// This is a catch-all for errors during the actual work (compression,
/// CSV parsing, etc.). The string explains what happened.
#[error("Processing failed: {0}")]
ProcessingFailed(String),
/// The user cancelled the operation (or the Web Worker was terminated).
/// Not a "real" error — the user chose to stop.
#[error("Operation cancelled")]
Cancelled,
/// The run passed its time limit (`settings.timeout`, or the consumer's
/// override), so nothing more of it started.
#[error("Run timed out after {secs}s")]
TimedOut { secs: u64 },
// --- Resource Errors ---
/// A file is too large for browser processing.
/// Browser memory is limited (~2GB), so we check file sizes before
/// trying to process them.
#[error("File too large: {0} bytes")]
FileTooLarge(u64),
/// The browser ran out of memory while processing.
/// WASM has a limited memory heap. If we try to load a huge image,
/// we might exhaust it.
#[error("Out of memory: {0}")]
OutOfMemory(String),
// --- Failures That Carry More ---
/// An external command ran and exited non-zero. `stdout` is what it
/// printed, so a caller can still use it. Displays like `ProcessingFailed`.
#[error("Processing failed: {message}")]
CommandFailed { message: String, stdout: Vec<u8> },
/// A step failed but still handed over output (e.g. a failed command's
/// stdout), which the run's closing steps receive. Displays as the
/// underlying error. Build with [`BntoError::with_output`].
#[error("{}", .0.error)]
FailedWithOutput(Box<FailedOutput>),
/// A closing step (a node marked `always`) failed after an earlier step
/// had already failed the run. `first` stays the run's verdict.
#[error("{first}; closing step '{node_id}' also failed: {error}")]
ClosingStepFailed {
first: Box<BntoError>,
node_id: String,
error: Box<BntoError>,
},
}
/// The error and output of a step that failed after making files.
pub struct FailedOutput {
pub error: BntoError,
pub output: NodeOutput,
}
// File bytes would flood a debug print, so show the file names only.
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 {
/// `error`, handing over `output` along with the failure. Replaces any
/// output `error` already carried.
pub fn with_output(error: BntoError, output: NodeOutput) -> Self {
let (error, _) = error.into_parts();
Self::FailedWithOutput(Box::new(FailedOutput { error, output }))
}
/// Whether this failure is, or began with, the run passing its time
/// limit. A timeout ends the run: no closing step or skipped loop item
/// runs past it.
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,
}
}
/// The error itself and the output it handed over, if any.
pub fn into_parts(self) -> (BntoError, Option<NodeOutput>) {
match self {
Self::FailedWithOutput(failed) => (failed.error, Some(failed.output)),
error => (error, None),
}
}
}
// =============================================================================
// Converting BntoError to JavaScript (MOVED TO bnto-wasm)
// =============================================================================
//
// NOTE: The `impl From<BntoError> for JsValue` conversion used to live here,
// but it coupled bnto-core to WASM dependencies (wasm-bindgen, js-sys).
// That forced every node crate to carry WASM deps even for pure Rust logic.
//
// The conversion now lives in `bnto-wasm/src/lib.rs` — the only crate that
// should know about JavaScript types. This keeps bnto-core target-agnostic:
// it works in WASM, native CLI, desktop (Tauri), or anywhere else.
// =============================================================================
// Tests
// =============================================================================
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_invalid_input_error_message() {
// Create an error and check its message.
let error = BntoError::InvalidInput("missing file data".to_string());
// `.to_string()` triggers the `#[error("...")]` formatting.
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() {
// 5 GB in bytes
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"
);
}
}