Skip to main content

bnto_engine/
lib.rs

1// bnto-engine — shared engine layer for CLI + WASM consumers.
2//
3// Provides the processor registry and a convenience `run_pipeline()`
4// so consumers don't duplicate registration logic. Both bnto-wasm (browser)
5// and bnto (native CLI binary) depend on this crate.
6
7pub mod deps;
8pub mod recipes;
9
10use bnto_core::{
11    BntoError, NodeRegistry, PipelineDefinition, PipelineFile, PipelineReporter, PipelineResult,
12    ProcessContext, execute_pipeline,
13};
14
15/// Create a registry with only browser-capable (WASM-safe) processors.
16///
17/// Used by the `node_catalog()` WASM export and browser execution path.
18/// For the full registry (including CLI-only processors), use `create_registry()`.
19pub fn create_browser_registry() -> NodeRegistry {
20    let mut registry = NodeRegistry::new();
21
22    registry.register(
23        "image-compress",
24        Box::new(bnto_image::CompressImages::new()),
25    );
26    registry.register("image-resize", Box::new(bnto_image::ResizeImages::new()));
27    registry.register(
28        "image-convert",
29        Box::new(bnto_image::ConvertImageFormat::new()),
30    );
31    registry.register(
32        "spreadsheet-clean",
33        Box::new(bnto_spreadsheet::CleanSpreadsheet::new()),
34    );
35    registry.register(
36        "spreadsheet-rename",
37        Box::new(bnto_spreadsheet::RenameColumns::new()),
38    );
39    registry.register("file-filter", Box::new(bnto_file::FileFilter::new()));
40    registry.register("file-metadata", Box::new(bnto_file::FileMetadata::new()));
41    registry.register("file-rename", Box::new(bnto_file::RenameFiles::new()));
42    registry.register("image-strip-exif", Box::new(bnto_image::StripExif::new()));
43    registry.register(
44        "spreadsheet-convert",
45        Box::new(bnto_spreadsheet::ConvertFormat::new()),
46    );
47    registry.register(
48        "spreadsheet-merge",
49        Box::new(bnto_spreadsheet::MergeSpreadsheets::new()),
50    );
51    registry.register("image-overlay", Box::new(bnto_image::OverlayImage::new()));
52    registry.register(
53        "vector-rasterize",
54        Box::new(bnto_vector::VectorRasterize::new()),
55    );
56    registry.register("vector-optimize", Box::new(bnto_vector::OptimizeSvg));
57    registry.register(
58        "spreadsheet-read",
59        Box::new(bnto_spreadsheet::ReadSpreadsheet::new()),
60    );
61
62    registry
63}
64
65/// Create the full processor registry — all node types across all targets.
66///
67/// Starts from the browser-safe registry and adds CLI/server/desktop
68/// processors (like shell-command) when compiled with the `native` feature.
69/// This is the canonical registry — CLI, tests, and codegen use this.
70pub fn create_registry() -> NodeRegistry {
71    #[allow(unused_mut)]
72    let mut registry = create_browser_registry();
73
74    #[cfg(feature = "native")]
75    {
76        registry.register("file-collect", Box::new(bnto_file::FileCollect::new()));
77        registry.register("file-copy", Box::new(bnto_file::FileCopy::new()));
78        registry.register("shell-command", Box::new(bnto_shell::ShellCommand::new()));
79    }
80
81    registry
82}
83
84/// Run a pipeline from a JSON definition string and a list of files.
85///
86/// Convenience wrapper that parses JSON, creates the full registry,
87/// and executes the pipeline. Suited for CLI and integration tests
88/// where the full WASM bridge isn't needed.
89pub fn run_pipeline(
90    definition_json: &str,
91    files: Vec<PipelineFile>,
92    reporter: &PipelineReporter,
93    ctx: &dyn ProcessContext,
94) -> Result<PipelineResult, BntoError> {
95    let definition: PipelineDefinition = serde_json::from_str(definition_json)
96        .map_err(|e| BntoError::InvalidInput(format!("Failed to parse definition: {e}")))?;
97
98    let registry = create_registry();
99
100    // Pre-flight: fail fast if required external tools are missing.
101    deps::check_pipeline_dependencies(&definition, &registry, ctx)?;
102
103    // Pre-flight: fail fast if required secrets (env vars) are missing.
104    deps::check_pipeline_secrets(&definition, ctx)?;
105
106    let now_ms = || {
107        std::time::SystemTime::now()
108            .duration_since(std::time::UNIX_EPOCH)
109            .unwrap_or_default()
110            .as_millis() as u64
111    };
112
113    execute_pipeline(&definition, files, &registry, reporter, ctx, now_ms)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use bnto_core::NoopContext;
120    use bnto_core::processor::FileData;
121
122    #[test]
123    fn test_browser_registry_has_all_processors() {
124        let registry = create_browser_registry();
125        assert_eq!(registry.len(), 15);
126
127        let expected = [
128            "file-filter",
129            "file-metadata",
130            "file-rename",
131            "image-compress",
132            "image-resize",
133            "image-convert",
134            "image-strip-exif",
135            "image-overlay",
136            "spreadsheet-clean",
137            "spreadsheet-read",
138            "spreadsheet-rename",
139            "spreadsheet-convert",
140            "spreadsheet-merge",
141            "vector-rasterize",
142            "vector-optimize",
143        ];
144
145        let params = serde_json::Map::new();
146        for node_type in &expected {
147            assert!(
148                registry.resolve(node_type, &params).is_some(),
149                "Missing processor for {node_type}",
150            );
151        }
152    }
153
154    #[test]
155    #[cfg(feature = "native")]
156    fn test_full_registry_has_native_processors() {
157        let registry = create_registry();
158        // Full registry = browser (15) + file-collect + file-copy + shell-command (3) = 18
159        assert_eq!(registry.len(), 18);
160        let params = serde_json::Map::new();
161        assert!(
162            registry.resolve("shell-command", &params).is_some(),
163            "Native registry should include shell-command",
164        );
165    }
166
167    #[test]
168    fn test_run_pipeline_rejects_invalid_json() {
169        let reporter = PipelineReporter::new_noop();
170        let result = run_pipeline("not valid json", vec![], &reporter, &NoopContext);
171        assert!(result.is_err());
172    }
173
174    #[test]
175    fn test_run_pipeline_with_simple_definition() {
176        let json = r#"{
177            "nodes": [
178                { "id": "input", "type": "input" },
179                { "id": "compress", "type": "image-compress", "parameters": { "quality": 80 } },
180                { "id": "output", "type": "output" }
181            ]
182        }"#;
183
184        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
185        let files = vec![PipelineFile {
186            name: "test.jpg".to_string(),
187            data: FileData::Bytes(test_image.to_vec()),
188            mime_type: "image/jpeg".to_string(),
189            metadata: serde_json::Map::new(),
190        }];
191
192        let reporter = PipelineReporter::new_noop();
193        let result =
194            run_pipeline(json, files, &reporter, &NoopContext).expect("Pipeline should succeed");
195
196        assert_eq!(result.files.len(), 1);
197        assert!(!result.files[0].data.is_empty().expect("should read length"));
198    }
199
200    #[test]
201    fn test_generated_compress_images_recipe() {
202        let json = include_str!("../recipes/compress-images.bnto.json");
203        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
204        let files = vec![PipelineFile {
205            name: "photo.jpg".to_string(),
206            data: FileData::Bytes(test_image.to_vec()),
207            mime_type: "image/jpeg".to_string(),
208            metadata: serde_json::Map::new(),
209        }];
210
211        let reporter = PipelineReporter::new_noop();
212        let result =
213            run_pipeline(json, files, &reporter, &NoopContext).expect("compress-images recipe");
214
215        assert_eq!(result.files.len(), 1);
216        assert!(!result.files[0].data.is_empty().expect("should read length"));
217    }
218
219    #[test]
220    fn test_generated_resize_images_recipe() {
221        let json = include_str!("../recipes/resize-images.bnto.json");
222        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
223        let files = vec![PipelineFile {
224            name: "photo.jpg".to_string(),
225            data: FileData::Bytes(test_image.to_vec()),
226            mime_type: "image/jpeg".to_string(),
227            metadata: serde_json::Map::new(),
228        }];
229
230        let reporter = PipelineReporter::new_noop();
231        let result =
232            run_pipeline(json, files, &reporter, &NoopContext).expect("resize-images recipe");
233
234        assert_eq!(result.files.len(), 1);
235    }
236
237    #[test]
238    fn test_generated_clean_csv_recipe() {
239        let json = include_str!("../recipes/clean-csv.bnto.json");
240        let csv_data = include_bytes!("../../../../test-fixtures/csv/messy.csv");
241        let files = vec![PipelineFile {
242            name: "data.csv".to_string(),
243            data: FileData::Bytes(csv_data.to_vec()),
244            mime_type: "text/csv".to_string(),
245            metadata: serde_json::Map::new(),
246        }];
247
248        let reporter = PipelineReporter::new_noop();
249        let result = run_pipeline(json, files, &reporter, &NoopContext).expect("clean-csv recipe");
250
251        assert_eq!(result.files.len(), 1);
252    }
253
254    #[test]
255    fn test_generated_rename_files_recipe() {
256        let json = include_str!("../recipes/rename-files.bnto.json");
257        let files = vec![PipelineFile {
258            name: "document.txt".to_string(),
259            data: FileData::Bytes(b"hello world".to_vec()),
260            mime_type: "text/plain".to_string(),
261            metadata: serde_json::Map::new(),
262        }];
263
264        let reporter = PipelineReporter::new_noop();
265        let result =
266            run_pipeline(json, files, &reporter, &NoopContext).expect("rename-files recipe");
267
268        assert_eq!(result.files.len(), 1);
269        assert!(
270            result.files[0].name.starts_with("renamed-"),
271            "Expected 'renamed-' prefix, got: {}",
272            result.files[0].name,
273        );
274    }
275
276    #[test]
277    fn test_generated_csv_to_json_recipe() {
278        let json = include_str!("../recipes/csv-to-json.bnto.json");
279        let csv_data = include_bytes!("../../../../test-fixtures/csv/simple.csv");
280        let files = vec![PipelineFile {
281            name: "data.csv".to_string(),
282            data: FileData::Bytes(csv_data.to_vec()),
283            mime_type: "text/csv".to_string(),
284            metadata: serde_json::Map::new(),
285        }];
286
287        let reporter = PipelineReporter::new_noop();
288        let result =
289            run_pipeline(json, files, &reporter, &NoopContext).expect("csv-to-json recipe");
290
291        assert_eq!(result.files.len(), 1);
292        assert!(result.files[0].name.ends_with(".json"));
293    }
294
295    #[test]
296    fn test_generated_merge_csv_recipe() {
297        let json = include_str!("../recipes/merge-csv.bnto.json");
298        let csv_a = b"name,age\nAlice,30\n";
299        let csv_b = b"name,age\nBob,25\n";
300        let files = vec![
301            PipelineFile {
302                name: "a.csv".to_string(),
303                data: FileData::Bytes(csv_a.to_vec()),
304                mime_type: "text/csv".to_string(),
305                metadata: serde_json::Map::new(),
306            },
307            PipelineFile {
308                name: "b.csv".to_string(),
309                data: FileData::Bytes(csv_b.to_vec()),
310                mime_type: "text/csv".to_string(),
311                metadata: serde_json::Map::new(),
312            },
313        ];
314
315        let reporter = PipelineReporter::new_noop();
316        let result = run_pipeline(json, files, &reporter, &NoopContext).expect("merge-csv recipe");
317
318        assert_eq!(result.files.len(), 1);
319        let bytes = result.files[0]
320            .data
321            .clone()
322            .into_bytes()
323            .expect("should have bytes");
324        let output = String::from_utf8_lossy(&bytes);
325        assert!(output.contains("Alice"));
326        assert!(output.contains("Bob"));
327    }
328
329    #[test]
330    fn test_generated_strip_exif_recipe() {
331        let json = include_str!("../recipes/strip-exif.bnto.json");
332        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
333        let files = vec![PipelineFile {
334            name: "photo.jpg".to_string(),
335            data: FileData::Bytes(test_image.to_vec()),
336            mime_type: "image/jpeg".to_string(),
337            metadata: serde_json::Map::new(),
338        }];
339
340        let reporter = PipelineReporter::new_noop();
341        let result = run_pipeline(json, files, &reporter, &NoopContext).expect("strip-exif recipe");
342
343        assert_eq!(result.files.len(), 1);
344        assert!(!result.files[0].data.is_empty().expect("should read length"));
345    }
346
347    #[test]
348    fn test_generated_watermark_images_recipe() {
349        // The generated recipe has an empty overlay param — inject a real one.
350        let mut def: serde_json::Value =
351            serde_json::from_str(include_str!("../recipes/watermark-images.bnto.json")).unwrap();
352
353        // Base64-encode the test overlay image
354        let overlay_bytes =
355            include_bytes!("../../../../test-fixtures/images/overlays/overlay-logo.png");
356        let overlay_b64 = format!(
357            "data:image/png;base64,{}",
358            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, overlay_bytes)
359        );
360
361        // Set the overlay param on the overlay node
362        let nodes = def["nodes"].as_array_mut().unwrap();
363        for node in nodes.iter_mut() {
364            if node["type"] == "image-overlay" {
365                node["parameters"]["overlay"] = serde_json::Value::String(overlay_b64.clone());
366            }
367        }
368
369        let json = serde_json::to_string(&def).unwrap();
370        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
371        let files = vec![PipelineFile {
372            name: "photo.jpg".to_string(),
373            data: FileData::Bytes(test_image.to_vec()),
374            mime_type: "image/jpeg".to_string(),
375            metadata: serde_json::Map::new(),
376        }];
377
378        let reporter = PipelineReporter::new_noop();
379        let result =
380            run_pipeline(&json, files, &reporter, &NoopContext).expect("watermark-images recipe");
381
382        assert_eq!(result.files.len(), 1);
383        assert!(!result.files[0].data.is_empty().expect("should read length"));
384    }
385
386    #[test]
387    fn test_generated_optimize_svg_recipe() {
388        let json = include_str!("../recipes/optimize-svg.bnto.json");
389        let test_svg = include_bytes!("../../../../test-fixtures/vector/verbose.svg");
390        let files = vec![PipelineFile {
391            name: "icon.svg".to_string(),
392            data: FileData::Bytes(test_svg.to_vec()),
393            mime_type: "image/svg+xml".to_string(),
394            metadata: serde_json::Map::new(),
395        }];
396
397        let reporter = PipelineReporter::new_noop();
398        let result =
399            run_pipeline(json, files, &reporter, &NoopContext).expect("optimize-svg recipe");
400
401        assert_eq!(result.files.len(), 1);
402        let bytes = result.files[0]
403            .data
404            .clone()
405            .into_bytes()
406            .expect("should have bytes");
407        // Output should be smaller than input (editor cruft removed)
408        assert!(bytes.len() < test_svg.len());
409        // Output should still be valid SVG (starts with <svg)
410        let output = String::from_utf8_lossy(&bytes);
411        assert!(
412            output.contains("<svg"),
413            "Output should contain <svg element"
414        );
415    }
416
417    #[test]
418    fn test_all_processor_names_match_registry_keys() {
419        // Convention: name() must return the same string used in registry.register().
420        // Pattern: category-operation (e.g., "image-compress", not "compress-images").
421        let registry = create_browser_registry();
422        let expected_keys = [
423            "file-filter",
424            "file-metadata",
425            "file-rename",
426            "image-compress",
427            "image-resize",
428            "image-convert",
429            "image-strip-exif",
430            "image-overlay",
431            "spreadsheet-clean",
432            "spreadsheet-read",
433            "spreadsheet-rename",
434            "spreadsheet-convert",
435            "spreadsheet-merge",
436            "vector-rasterize",
437            "vector-optimize",
438        ];
439        let params = serde_json::Map::new();
440        for key in &expected_keys {
441            let processor = registry
442                .resolve(key, &params)
443                .unwrap_or_else(|| panic!("Missing processor for {key}"));
444            assert_eq!(
445                processor.name(),
446                *key,
447                "processor.name() must match registry key. Got '{}' for key '{}'",
448                processor.name(),
449                key
450            );
451        }
452    }
453
454    #[test]
455    fn test_generated_svg_to_png_recipe() {
456        let json = include_str!("../recipes/svg-to-png.bnto.json");
457        let test_svg = include_bytes!("../../../../test-fixtures/images/small.svg");
458        let files = vec![PipelineFile {
459            name: "icon.svg".to_string(),
460            data: FileData::Bytes(test_svg.to_vec()),
461            mime_type: "image/svg+xml".to_string(),
462            metadata: serde_json::Map::new(),
463        }];
464
465        let reporter = PipelineReporter::new_noop();
466        let result = run_pipeline(json, files, &reporter, &NoopContext).expect("svg-to-png recipe");
467
468        assert_eq!(result.files.len(), 1);
469        assert!(result.files[0].name.ends_with(".png"));
470        // PNG magic bytes
471        let bytes = result.files[0]
472            .data
473            .clone()
474            .into_bytes()
475            .expect("should have bytes");
476        assert!(bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]));
477    }
478
479    #[test]
480    fn test_generated_svg_to_jpeg_recipe() {
481        let json = include_str!("../recipes/svg-to-jpeg.bnto.json");
482        let test_svg = include_bytes!("../../../../test-fixtures/images/small.svg");
483        let files = vec![PipelineFile {
484            name: "icon.svg".to_string(),
485            data: FileData::Bytes(test_svg.to_vec()),
486            mime_type: "image/svg+xml".to_string(),
487            metadata: serde_json::Map::new(),
488        }];
489
490        let reporter = PipelineReporter::new_noop();
491        let result =
492            run_pipeline(json, files, &reporter, &NoopContext).expect("svg-to-jpeg recipe");
493
494        assert_eq!(result.files.len(), 1);
495        assert!(result.files[0].name.ends_with(".jpg"));
496        // JPEG magic bytes
497        let bytes = result.files[0]
498            .data
499            .clone()
500            .into_bytes()
501            .expect("should have bytes");
502        assert!(bytes.starts_with(&[0xFF, 0xD8, 0xFF]));
503    }
504
505    #[test]
506    fn test_all_generated_recipes_parse() {
507        let recipes = [
508            include_str!("../recipes/compress-images.bnto.json"),
509            include_str!("../recipes/resize-images.bnto.json"),
510            include_str!("../recipes/convert-image-format.bnto.json"),
511            include_str!("../recipes/rename-files.bnto.json"),
512            include_str!("../recipes/clean-csv.bnto.json"),
513            include_str!("../recipes/rename-csv-columns.bnto.json"),
514            include_str!("../recipes/optimize-images-for-web.bnto.json"),
515            include_str!("../recipes/generate-thumbnails.bnto.json"),
516            include_str!("../recipes/compress-and-rename.bnto.json"),
517            include_str!("../recipes/standardize-csv.bnto.json"),
518            include_str!("../recipes/csv-to-json.bnto.json"),
519            include_str!("../recipes/strip-exif.bnto.json"),
520            include_str!("../recipes/watermark-images.bnto.json"),
521            include_str!("../recipes/merge-csv.bnto.json"),
522            include_str!("../recipes/download-video.bnto.json"),
523            include_str!("../recipes/svg-to-png.bnto.json"),
524            include_str!("../recipes/svg-to-jpeg.bnto.json"),
525            include_str!("../recipes/optimize-svg.bnto.json"),
526            include_str!("../recipes/number-files.bnto.json"),
527            include_str!("../recipes/sanitize-filenames.bnto.json"),
528            include_str!("../recipes/flatten-folders.bnto.json"),
529        ];
530
531        for (i, json) in recipes.iter().enumerate() {
532            let def: PipelineDefinition = serde_json::from_str(json)
533                .unwrap_or_else(|e| panic!("Recipe {i} failed to parse: {e}"));
534            assert!(
535                !def.nodes.is_empty(),
536                "Recipe {i} should have at least one node",
537            );
538        }
539    }
540}