Skip to main content

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    /// Problems the params cause on some of the platforms the step can run
169    /// on: the values `{{ctx.platform}}` may read there (`macos`, `linux`,
170    /// `windows`, `unknown`), narrowed by the platform guards on its path.
171    /// `bnto check` reports them (shell-run's Windows-only `powershell`).
172    ///
173    /// Default: none.
174    fn platform_problems(
175        &self,
176        _params: &serde_json::Map<String, serde_json::Value>,
177        _platforms: &[&str],
178    ) -> Vec<String> {
179        Vec::new()
180    }
181
182    /// Keys of the node-level `NodeOutput.metadata` that count something per
183    /// call (e.g. files copied). When the node runs more than once — a per-file
184    /// node over several files, a loop's iterations — the executor adds these
185    /// up in the node's record instead of keeping the last call's value.
186    ///
187    /// Default: none, so every key keeps the last call's value.
188    fn count_keys(&self) -> &'static [&'static str] {
189        &[]
190    }
191
192    /// True for a batch node that fills `{{item.*}}` in its params itself,
193    /// once per incoming file (assert, report). Outside a loop there is no
194    /// item to resolve them against once, so the executor hands them to the
195    /// node as written instead of refusing them.
196    ///
197    /// Default: false, so an unfilled `{{item.*}}` fails the node.
198    fn resolves_items(&self) -> bool {
199        false
200    }
201
202    /// Process a batch of files together, producing combined output.
203    ///
204    /// Override this for processors with `InputCardinality::Batch` (merge, zip,
205    /// concat). The default falls back to calling `process()` per file and
206    /// concatenating results — suitable for `PerFile` processors.
207    fn process_batch(
208        &self,
209        input: BatchInput,
210        progress: &ProgressReporter,
211        ctx: &dyn ProcessContext,
212    ) -> Result<NodeOutput, BntoError> {
213        let total = input.files.len();
214        let mut all_files = Vec::new();
215        let mut combined_metadata = serde_json::Map::new();
216
217        for (i, file) in input.files.into_iter().enumerate() {
218            let pct = ((i as u32) * 100) / (total as u32).max(1);
219            progress.report(pct, &format!("Processing file {} of {total}...", i + 1));
220
221            let single_input = NodeInput {
222                data: file.data,
223                filename: file.filename,
224                mime_type: file.mime_type,
225                params: input.params.clone(),
226            };
227            let output = self.process(single_input, progress, ctx)?;
228            all_files.extend(output.files);
229            // Merge metadata from the last file processed.
230            combined_metadata = output.metadata;
231        }
232
233        Ok(NodeOutput {
234            files: all_files,
235            metadata: combined_metadata,
236        })
237    }
238
239    /// Return the processor's self-describing metadata.
240    ///
241    /// This tells consumers everything about this processor: what it's called,
242    /// what category it belongs to, what parameters it accepts, what files it
243    /// handles, and whether it runs in the browser.
244    ///
245    /// Every concrete processor SHOULD override this with its real metadata.
246    /// The default returns a placeholder "unknown" metadata — useful for tests
247    /// and mocks that don't need real metadata.
248    fn metadata(&self) -> NodeMetadata {
249        NodeMetadata {
250            node_type: "unknown".to_string(),
251            name: self.name().to_string(),
252            description: String::new(),
253            category: NodeCategory::Data,
254            accepts: vec![],
255            platforms: vec![],
256            parameters: vec![],
257            input_cardinality: Default::default(),
258            requires: vec![],
259        }
260    }
261
262    /// The params a run accepts for this node: its metadata's, or `None`
263    /// when the processor keeps the placeholder `metadata()` above (test
264    /// mocks), which declares nothing. A run refuses any other param.
265    fn declared_params(&self) -> Option<Vec<ParameterDef>> {
266        let meta = self.metadata();
267        (meta.node_type == self.name()).then_some(meta.parameters)
268    }
269}
270
271// =============================================================================
272// Tests
273// =============================================================================
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use crate::context::NoopContext;
279
280    // --- Test helpers ---
281    // We create a simple mock processor to test the trait contract.
282
283    /// A mock node processor for testing. Does nothing — just echoes
284    /// the input back as output.
285    struct EchoProcessor;
286
287    impl NodeProcessor for EchoProcessor {
288        fn name(&self) -> &str {
289            "echo"
290        }
291
292        fn process(
293            &self,
294            input: NodeInput,
295            _progress: &ProgressReporter,
296            _ctx: &dyn ProcessContext,
297        ) -> Result<NodeOutput, BntoError> {
298            // Just echo the input data back as output.
299            Ok(NodeOutput {
300                files: vec![OutputFile {
301                    data: input.data,
302                    filename: input.filename,
303                    mime_type: input
304                        .mime_type
305                        .unwrap_or_else(|| "application/octet-stream".to_string()),
306                    metadata: serde_json::Map::new(),
307                }],
308                metadata: serde_json::Map::new(),
309            })
310        }
311    }
312
313    /// A mock processor that always fails — for testing error handling.
314    struct FailProcessor;
315
316    impl NodeProcessor for FailProcessor {
317        fn name(&self) -> &str {
318            "fail"
319        }
320
321        fn process(
322            &self,
323            _input: NodeInput,
324            _progress: &ProgressReporter,
325            _ctx: &dyn ProcessContext,
326        ) -> Result<NodeOutput, BntoError> {
327            Err(BntoError::ProcessingFailed(
328                "intentional test failure".to_string(),
329            ))
330        }
331    }
332
333    /// Helper to create a simple test input.
334    fn make_test_input(data: &[u8], filename: &str) -> NodeInput {
335        NodeInput {
336            data: FileData::Bytes(data.to_vec()),
337            filename: filename.to_string(),
338            mime_type: None,
339            params: serde_json::Map::new(),
340        }
341    }
342
343    // --- Tests ---
344
345    #[test]
346    fn test_echo_processor_name() {
347        let processor = EchoProcessor;
348        assert_eq!(processor.name(), "echo");
349    }
350
351    #[test]
352    fn test_echo_processor_echoes_data() {
353        let processor = EchoProcessor;
354        let progress = ProgressReporter::new_noop();
355        let input = make_test_input(b"hello world", "test.txt");
356
357        let output = processor.process(input, &progress, &NoopContext).unwrap();
358
359        assert_eq!(output.files.len(), 1);
360        assert!(matches!(&output.files[0].data, FileData::Bytes(d) if d == b"hello world"));
361        assert_eq!(output.files[0].filename, "test.txt");
362    }
363
364    #[test]
365    fn test_fail_processor_returns_error() {
366        let processor = FailProcessor;
367        let progress = ProgressReporter::new_noop();
368        let input = make_test_input(b"data", "test.txt");
369
370        let result = processor.process(input, &progress, &NoopContext);
371        assert!(result.is_err());
372
373        if let Err(e) = result {
374            assert!(e.to_string().contains("intentional test failure"));
375        }
376    }
377
378    #[test]
379    fn test_default_validate_returns_empty() {
380        let processor = EchoProcessor;
381        let params = serde_json::Map::new();
382
383        // The default validate() should return no errors.
384        let errors = processor.validate(&params);
385        assert!(errors.is_empty());
386    }
387
388    #[test]
389    fn test_default_count_keys_is_empty() {
390        assert!(EchoProcessor.count_keys().is_empty());
391    }
392
393    #[test]
394    fn test_by_default_a_node_does_not_resolve_items() {
395        assert!(!EchoProcessor.resolves_items());
396    }
397
398    // --- Batch Processing Tests ---
399
400    #[test]
401    fn test_default_process_batch_falls_back_to_per_file() {
402        let processor = EchoProcessor;
403        let progress = ProgressReporter::new_noop();
404        let input = BatchInput {
405            files: vec![
406                BatchFile {
407                    data: FileData::Bytes(b"file1".to_vec()),
408                    filename: "a.txt".to_string(),
409                    mime_type: None,
410                    metadata: serde_json::Map::new(),
411                },
412                BatchFile {
413                    data: FileData::Bytes(b"file2".to_vec()),
414                    filename: "b.txt".to_string(),
415                    mime_type: None,
416                    metadata: serde_json::Map::new(),
417                },
418            ],
419            params: serde_json::Map::new(),
420        };
421
422        let output = processor
423            .process_batch(input, &progress, &NoopContext)
424            .unwrap();
425
426        // Default batch falls back to per-file: 2 inputs → 2 outputs.
427        assert_eq!(output.files.len(), 2);
428        assert_eq!(output.files[0].filename, "a.txt");
429        assert!(matches!(&output.files[0].data, FileData::Bytes(d) if d == b"file1"));
430        assert_eq!(output.files[1].filename, "b.txt");
431        assert!(matches!(&output.files[1].data, FileData::Bytes(d) if d == b"file2"));
432    }
433
434    #[test]
435    fn test_default_process_batch_empty_input() {
436        let processor = EchoProcessor;
437        let progress = ProgressReporter::new_noop();
438        let input = BatchInput {
439            files: vec![],
440            params: serde_json::Map::new(),
441        };
442
443        let output = processor
444            .process_batch(input, &progress, &NoopContext)
445            .unwrap();
446        assert_eq!(output.files.len(), 0);
447    }
448
449    #[test]
450    fn test_default_process_batch_propagates_errors() {
451        let processor = FailProcessor;
452        let progress = ProgressReporter::new_noop();
453        let input = BatchInput {
454            files: vec![BatchFile {
455                data: FileData::Bytes(b"data".to_vec()),
456                filename: "test.txt".to_string(),
457                mime_type: None,
458                metadata: serde_json::Map::new(),
459            }],
460            params: serde_json::Map::new(),
461        };
462
463        let result = processor.process_batch(input, &progress, &NoopContext);
464        assert!(result.is_err());
465    }
466}