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    /// 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(&params);
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}