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(¶ms);
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}