bnto_core/processor.rs
1// =============================================================================
2// NodeProcessor Trait — The Contract Every Node Type Must Implement
3// =============================================================================
4
5use crate::context::ProcessContext;
6use crate::errors::BntoError;
7use crate::metadata::{NodeCategory, NodeMetadata, ParameterDef};
8use crate::progress::ProgressReporter;
9
10// Re-export FileData so `processor::FileData` still works for existing imports.
11pub use crate::file_data::FileData;
12
13// =============================================================================
14// Input and Output Types
15// =============================================================================
16
17/// The input data that a node receives for processing.
18pub struct NodeInput {
19 /// File content — in-memory bytes or a path on disk.
20 ///
21 /// Most processors call `input.data.into_bytes()` at the top of `process()`.
22 /// Processors that move/copy files can use `write_to()` / `copy_to()` directly
23 /// for zero-copy operations on `FileData::Path` variants.
24 pub data: FileData,
25
26 /// The original filename (e.g., "photo.jpg", "data.csv").
27 /// Used to determine the file format and to generate output filenames.
28 pub filename: String,
29
30 /// The MIME type of the input (e.g., "image/jpeg", "text/csv").
31 /// `None` when the MIME type wasn't provided by the caller.
32 pub mime_type: Option<String>,
33
34 /// Configuration parameters for the node (e.g., quality level, target
35 /// format, dimensions). A JSON-compatible map where keys are parameter
36 /// names from @bnto/nodes schemas.
37 pub params: serde_json::Map<String, serde_json::Value>,
38}
39
40/// The output from a node after processing.
41///
42/// A node can produce one or more output files. For example, the
43/// compress-images node takes one image in and produces one compressed
44/// image out. A future "split PDF" node might produce many pages.
45pub struct NodeOutput {
46 /// The processed file data. Each entry is one output file.
47 pub files: Vec<OutputFile>,
48
49 /// Optional metadata about the processing (timing, compression ratio,
50 /// rows removed, etc.). Displayed in the UI's results panel.
51 pub metadata: serde_json::Map<String, serde_json::Value>,
52}
53
54/// A single output file produced by a node.
55pub struct OutputFile {
56 /// The file content — in-memory bytes or a path on disk.
57 pub data: FileData,
58
59 /// The filename for this output (e.g., "photo-compressed.jpg").
60 pub filename: String,
61
62 /// The MIME type of the output (e.g., "image/jpeg").
63 pub mime_type: String,
64
65 /// Per-file metadata (e.g., CSV row column values for loop iteration).
66 /// When non-empty, takes priority over `NodeOutput.metadata` in collect_output.
67 pub metadata: serde_json::Map<String, serde_json::Value>,
68}
69
70/// Input for batch processors that need all files at once (e.g., merge, zip).
71///
72/// Unlike `NodeInput` (one file), this carries the full set of pipeline files
73/// plus the shared configuration parameters.
74pub struct BatchInput {
75 /// All files to process as a group.
76 pub files: Vec<BatchFile>,
77
78 /// Configuration parameters for the node (same as `NodeInput.params`).
79 pub params: serde_json::Map<String, serde_json::Value>,
80}
81
82/// A single file within a batch input.
83pub struct BatchFile {
84 /// The file content as it arrived: in-memory bytes or a path on disk.
85 /// A reference stays a reference, so a batch node never loads files it
86 /// only lists; call `to_bytes()` / `into_bytes()` when the bytes matter.
87 pub data: FileData,
88
89 /// The original filename.
90 pub filename: String,
91
92 /// The MIME type, if known.
93 pub mime_type: Option<String>,
94
95 /// Per-file metadata the file carries from earlier nodes (lookup rows,
96 /// flags, the upload's size). A batch node that passes files through
97 /// copies it onto its outputs.
98 pub metadata: serde_json::Map<String, serde_json::Value>,
99}
100
101// =============================================================================
102// The NodeProcessor Trait
103// =============================================================================
104
105/// The contract that every node type must implement.
106///
107/// Currently synchronous -- async is handled at the Web Worker level.
108/// wasm-bindgen doesn't support async trait methods across the WASM boundary.
109pub trait NodeProcessor {
110 /// The processor's type key (e.g., "image-compress").
111 /// Must match the key used in `registry.register()`.
112 /// Convention: category-operation, kebab-case.
113 fn name(&self) -> &str;
114
115 /// Process a single input file and produce output.
116 ///
117 /// Arguments:
118 /// - `&self` — reference to the node processor instance
119 /// - `input` — the file data, filename, MIME type, and config params
120 /// - `progress` — callback to report progress to the UI (0-100%)
121 /// - `ctx` — system access boundary (commands, temp files, env vars)
122 ///
123 /// Returns:
124 /// - `Ok(NodeOutput)` — processing succeeded, here are the results
125 /// - `Err(BntoError)` — processing failed, here's what went wrong
126 fn process(
127 &self,
128 input: NodeInput,
129 progress: &ProgressReporter,
130 ctx: &dyn ProcessContext,
131 ) -> Result<NodeOutput, BntoError>;
132
133 /// Validate the input parameters before processing.
134 ///
135 /// This is called BEFORE `process()` to catch configuration errors
136 /// early (missing required params, invalid values, etc.) without
137 /// doing any expensive file processing.
138 ///
139 /// Returns a list of validation errors (empty = valid).
140 ///
141 /// Default implementation passes validation. Override in specific
142 /// node types to add parameter validation.
143 fn validate(&self, _params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
144 Vec::new()
145 }
146
147 /// Tokens this processor fills in its params itself, after the executor
148 /// (shell-run's `{{output_dir}}`, `{{url}}`, each groups' `{{path}}`).
149 /// A value the executor fills in may not carry one: the processor
150 /// couldn't tell it from the recipe's own. An entry ending in `.`
151 /// (`{{item.`) covers every token it starts.
152 ///
153 /// Default: none.
154 fn reserved_tokens(&self) -> &'static [&'static str] {
155 &[]
156 }
157
158 /// Problems in the params as the recipe wrote them, before any template
159 /// is filled — what `validate` can't see once data is in, such as a data
160 /// placeholder in a param that must be literal. The executor refuses the
161 /// run on any, before a step starts; `bnto check` reports them.
162 ///
163 /// Default: none.
164 fn validate_raw(&self, _raw: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
165 Vec::new()
166 }
167
168 /// Keys of the node-level `NodeOutput.metadata` that count something per
169 /// call (e.g. files copied). When the node runs more than once — a per-file
170 /// node over several files, a loop's iterations — the executor adds these
171 /// up in the node's record instead of keeping the last call's value.
172 ///
173 /// Default: none, so every key keeps the last call's value.
174 fn count_keys(&self) -> &'static [&'static str] {
175 &[]
176 }
177
178 /// True for a batch node that fills `{{item.*}}` in its params itself,
179 /// once per incoming file (assert, report). Outside a loop there is no
180 /// item to resolve them against once, so the executor hands them to the
181 /// node as written instead of refusing them.
182 ///
183 /// Default: false, so an unfilled `{{item.*}}` fails the node.
184 fn resolves_items(&self) -> bool {
185 false
186 }
187
188 /// Process a batch of files together, producing combined output.
189 ///
190 /// Override this for processors with `InputCardinality::Batch` (merge, zip,
191 /// concat). The default falls back to calling `process()` per file and
192 /// concatenating results — suitable for `PerFile` processors.
193 fn process_batch(
194 &self,
195 input: BatchInput,
196 progress: &ProgressReporter,
197 ctx: &dyn ProcessContext,
198 ) -> Result<NodeOutput, BntoError> {
199 let total = input.files.len();
200 let mut all_files = Vec::new();
201 let mut combined_metadata = serde_json::Map::new();
202
203 for (i, file) in input.files.into_iter().enumerate() {
204 let pct = ((i as u32) * 100) / (total as u32).max(1);
205 progress.report(pct, &format!("Processing file {} of {total}...", i + 1));
206
207 let single_input = NodeInput {
208 data: file.data,
209 filename: file.filename,
210 mime_type: file.mime_type,
211 params: input.params.clone(),
212 };
213 let output = self.process(single_input, progress, ctx)?;
214 all_files.extend(output.files);
215 // Merge metadata from the last file processed.
216 combined_metadata = output.metadata;
217 }
218
219 Ok(NodeOutput {
220 files: all_files,
221 metadata: combined_metadata,
222 })
223 }
224
225 /// Return the processor's self-describing metadata.
226 ///
227 /// This tells consumers everything about this processor: what it's called,
228 /// what category it belongs to, what parameters it accepts, what files it
229 /// handles, and whether it runs in the browser.
230 ///
231 /// Every concrete processor SHOULD override this with its real metadata.
232 /// The default returns a placeholder "unknown" metadata — useful for tests
233 /// and mocks that don't need real metadata.
234 fn metadata(&self) -> NodeMetadata {
235 NodeMetadata {
236 node_type: "unknown".to_string(),
237 name: self.name().to_string(),
238 description: String::new(),
239 category: NodeCategory::Data,
240 accepts: vec![],
241 platforms: vec![],
242 parameters: vec![],
243 input_cardinality: Default::default(),
244 requires: vec![],
245 }
246 }
247
248 /// The params a run accepts for this node: its metadata's, or `None`
249 /// when the processor keeps the placeholder `metadata()` above (test
250 /// mocks), which declares nothing. A run refuses any other param.
251 fn declared_params(&self) -> Option<Vec<ParameterDef>> {
252 let meta = self.metadata();
253 (meta.node_type == self.name()).then_some(meta.parameters)
254 }
255}
256
257// =============================================================================
258// Tests
259// =============================================================================
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::context::NoopContext;
265
266 // --- Test helpers ---
267 // We create a simple mock processor to test the trait contract.
268
269 /// A mock node processor for testing. Does nothing — just echoes
270 /// the input back as output.
271 struct EchoProcessor;
272
273 impl NodeProcessor for EchoProcessor {
274 fn name(&self) -> &str {
275 "echo"
276 }
277
278 fn process(
279 &self,
280 input: NodeInput,
281 _progress: &ProgressReporter,
282 _ctx: &dyn ProcessContext,
283 ) -> Result<NodeOutput, BntoError> {
284 // Just echo the input data back as output.
285 Ok(NodeOutput {
286 files: vec![OutputFile {
287 data: input.data,
288 filename: input.filename,
289 mime_type: input
290 .mime_type
291 .unwrap_or_else(|| "application/octet-stream".to_string()),
292 metadata: serde_json::Map::new(),
293 }],
294 metadata: serde_json::Map::new(),
295 })
296 }
297 }
298
299 /// A mock processor that always fails — for testing error handling.
300 struct FailProcessor;
301
302 impl NodeProcessor for FailProcessor {
303 fn name(&self) -> &str {
304 "fail"
305 }
306
307 fn process(
308 &self,
309 _input: NodeInput,
310 _progress: &ProgressReporter,
311 _ctx: &dyn ProcessContext,
312 ) -> Result<NodeOutput, BntoError> {
313 Err(BntoError::ProcessingFailed(
314 "intentional test failure".to_string(),
315 ))
316 }
317 }
318
319 /// Helper to create a simple test input.
320 fn make_test_input(data: &[u8], filename: &str) -> NodeInput {
321 NodeInput {
322 data: FileData::Bytes(data.to_vec()),
323 filename: filename.to_string(),
324 mime_type: None,
325 params: serde_json::Map::new(),
326 }
327 }
328
329 // --- Tests ---
330
331 #[test]
332 fn test_echo_processor_name() {
333 let processor = EchoProcessor;
334 assert_eq!(processor.name(), "echo");
335 }
336
337 #[test]
338 fn test_echo_processor_echoes_data() {
339 let processor = EchoProcessor;
340 let progress = ProgressReporter::new_noop();
341 let input = make_test_input(b"hello world", "test.txt");
342
343 let output = processor.process(input, &progress, &NoopContext).unwrap();
344
345 assert_eq!(output.files.len(), 1);
346 assert!(matches!(&output.files[0].data, FileData::Bytes(d) if d == b"hello world"));
347 assert_eq!(output.files[0].filename, "test.txt");
348 }
349
350 #[test]
351 fn test_fail_processor_returns_error() {
352 let processor = FailProcessor;
353 let progress = ProgressReporter::new_noop();
354 let input = make_test_input(b"data", "test.txt");
355
356 let result = processor.process(input, &progress, &NoopContext);
357 assert!(result.is_err());
358
359 if let Err(e) = result {
360 assert!(e.to_string().contains("intentional test failure"));
361 }
362 }
363
364 #[test]
365 fn test_default_validate_returns_empty() {
366 let processor = EchoProcessor;
367 let params = serde_json::Map::new();
368
369 // The default validate() should return no errors.
370 let errors = processor.validate(¶ms);
371 assert!(errors.is_empty());
372 }
373
374 #[test]
375 fn test_default_count_keys_is_empty() {
376 assert!(EchoProcessor.count_keys().is_empty());
377 }
378
379 #[test]
380 fn test_by_default_a_node_does_not_resolve_items() {
381 assert!(!EchoProcessor.resolves_items());
382 }
383
384 // --- Batch Processing Tests ---
385
386 #[test]
387 fn test_default_process_batch_falls_back_to_per_file() {
388 let processor = EchoProcessor;
389 let progress = ProgressReporter::new_noop();
390 let input = BatchInput {
391 files: vec![
392 BatchFile {
393 data: FileData::Bytes(b"file1".to_vec()),
394 filename: "a.txt".to_string(),
395 mime_type: None,
396 metadata: serde_json::Map::new(),
397 },
398 BatchFile {
399 data: FileData::Bytes(b"file2".to_vec()),
400 filename: "b.txt".to_string(),
401 mime_type: None,
402 metadata: serde_json::Map::new(),
403 },
404 ],
405 params: serde_json::Map::new(),
406 };
407
408 let output = processor
409 .process_batch(input, &progress, &NoopContext)
410 .unwrap();
411
412 // Default batch falls back to per-file: 2 inputs → 2 outputs.
413 assert_eq!(output.files.len(), 2);
414 assert_eq!(output.files[0].filename, "a.txt");
415 assert!(matches!(&output.files[0].data, FileData::Bytes(d) if d == b"file1"));
416 assert_eq!(output.files[1].filename, "b.txt");
417 assert!(matches!(&output.files[1].data, FileData::Bytes(d) if d == b"file2"));
418 }
419
420 #[test]
421 fn test_default_process_batch_empty_input() {
422 let processor = EchoProcessor;
423 let progress = ProgressReporter::new_noop();
424 let input = BatchInput {
425 files: vec![],
426 params: serde_json::Map::new(),
427 };
428
429 let output = processor
430 .process_batch(input, &progress, &NoopContext)
431 .unwrap();
432 assert_eq!(output.files.len(), 0);
433 }
434
435 #[test]
436 fn test_default_process_batch_propagates_errors() {
437 let processor = FailProcessor;
438 let progress = ProgressReporter::new_noop();
439 let input = BatchInput {
440 files: vec![BatchFile {
441 data: FileData::Bytes(b"data".to_vec()),
442 filename: "test.txt".to_string(),
443 mime_type: None,
444 metadata: serde_json::Map::new(),
445 }],
446 params: serde_json::Map::new(),
447 };
448
449 let result = processor.process_batch(input, &progress, &NoopContext);
450 assert!(result.is_err());
451 }
452}