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("spreadsheet-clean", Box::new(bnto_csv::CleanCsv::new()));
32    registry.register(
33        "spreadsheet-rename",
34        Box::new(bnto_csv::RenameCsvColumns::new()),
35    );
36    registry.register("file-rename", Box::new(bnto_file::RenameFiles::new()));
37    registry.register("image-strip-exif", Box::new(bnto_image::StripExif::new()));
38    registry.register("spreadsheet-convert", Box::new(bnto_csv::CsvToJson::new()));
39    registry.register("spreadsheet-merge", Box::new(bnto_csv::MergeCsv::new()));
40    registry.register("image-overlay", Box::new(bnto_image::OverlayImage::new()));
41
42    registry
43}
44
45/// Create the full processor registry — all node types across all targets.
46///
47/// Starts from the browser-safe registry and adds CLI/server/desktop
48/// processors (like video-download) when compiled with the `native` feature.
49/// This is the canonical registry — CLI, tests, and codegen use this.
50pub fn create_registry() -> NodeRegistry {
51    #[allow(unused_mut)]
52    let mut registry = create_browser_registry();
53
54    #[cfg(feature = "native")]
55    {
56        registry.register(
57            "video-download",
58            Box::new(bnto_video::VideoDownload::with_ytdlp()),
59        );
60    }
61
62    registry
63}
64
65/// Run a pipeline from a JSON definition string and a list of files.
66///
67/// Convenience wrapper that parses JSON, creates the full registry,
68/// and executes the pipeline. Suited for CLI and integration tests
69/// where the full WASM bridge isn't needed.
70pub fn run_pipeline(
71    definition_json: &str,
72    files: Vec<PipelineFile>,
73    reporter: &PipelineReporter,
74    ctx: &dyn ProcessContext,
75) -> Result<PipelineResult, BntoError> {
76    let definition: PipelineDefinition = serde_json::from_str(definition_json)
77        .map_err(|e| BntoError::InvalidInput(format!("Failed to parse definition: {e}")))?;
78
79    let registry = create_registry();
80
81    // Pre-flight: fail fast if required external tools are missing.
82    deps::check_pipeline_dependencies(&definition, &registry, ctx)?;
83
84    let now_ms = || {
85        std::time::SystemTime::now()
86            .duration_since(std::time::UNIX_EPOCH)
87            .unwrap_or_default()
88            .as_millis() as u64
89    };
90
91    execute_pipeline(&definition, files, &registry, reporter, ctx, now_ms)
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97    use bnto_core::NoopContext;
98
99    #[test]
100    fn test_browser_registry_has_all_processors() {
101        let registry = create_browser_registry();
102        assert_eq!(registry.len(), 10);
103
104        let expected = [
105            "image-compress",
106            "image-resize",
107            "image-convert",
108            "image-strip-exif",
109            "image-overlay",
110            "spreadsheet-clean",
111            "spreadsheet-rename",
112            "spreadsheet-convert",
113            "spreadsheet-merge",
114            "file-rename",
115        ];
116
117        let params = serde_json::Map::new();
118        for node_type in &expected {
119            assert!(
120                registry.resolve(node_type, &params).is_some(),
121                "Missing processor for {node_type}",
122            );
123        }
124    }
125
126    #[test]
127    #[cfg(feature = "native")]
128    fn test_full_registry_has_video_download() {
129        let registry = create_registry();
130        // Full registry = browser (10) + video-download (1) = 11
131        assert_eq!(registry.len(), 11);
132        let params = serde_json::Map::new();
133        assert!(
134            registry.resolve("video-download", &params).is_some(),
135            "Native registry should include video-download",
136        );
137    }
138
139    #[test]
140    fn test_run_pipeline_rejects_invalid_json() {
141        let reporter = PipelineReporter::new_noop();
142        let result = run_pipeline("not valid json", vec![], &reporter, &NoopContext);
143        assert!(result.is_err());
144    }
145
146    #[test]
147    fn test_run_pipeline_with_simple_definition() {
148        let json = r#"{
149            "nodes": [
150                { "id": "input", "type": "input" },
151                { "id": "compress", "type": "image-compress", "parameters": { "quality": 80 } },
152                { "id": "output", "type": "output" }
153            ]
154        }"#;
155
156        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
157        let files = vec![PipelineFile {
158            name: "test.jpg".to_string(),
159            data: test_image.to_vec(),
160            mime_type: "image/jpeg".to_string(),
161            metadata: serde_json::Map::new(),
162        }];
163
164        let reporter = PipelineReporter::new_noop();
165        let result =
166            run_pipeline(json, files, &reporter, &NoopContext).expect("Pipeline should succeed");
167
168        assert_eq!(result.files.len(), 1);
169        assert!(!result.files[0].data.is_empty());
170    }
171
172    #[test]
173    fn test_generated_compress_images_recipe() {
174        let json = include_str!("../recipes/compress-images.bnto.json");
175        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
176        let files = vec![PipelineFile {
177            name: "photo.jpg".to_string(),
178            data: test_image.to_vec(),
179            mime_type: "image/jpeg".to_string(),
180            metadata: serde_json::Map::new(),
181        }];
182
183        let reporter = PipelineReporter::new_noop();
184        let result =
185            run_pipeline(json, files, &reporter, &NoopContext).expect("compress-images recipe");
186
187        assert_eq!(result.files.len(), 1);
188        assert!(!result.files[0].data.is_empty());
189    }
190
191    #[test]
192    fn test_generated_resize_images_recipe() {
193        let json = include_str!("../recipes/resize-images.bnto.json");
194        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
195        let files = vec![PipelineFile {
196            name: "photo.jpg".to_string(),
197            data: test_image.to_vec(),
198            mime_type: "image/jpeg".to_string(),
199            metadata: serde_json::Map::new(),
200        }];
201
202        let reporter = PipelineReporter::new_noop();
203        let result =
204            run_pipeline(json, files, &reporter, &NoopContext).expect("resize-images recipe");
205
206        assert_eq!(result.files.len(), 1);
207    }
208
209    #[test]
210    fn test_generated_clean_csv_recipe() {
211        let json = include_str!("../recipes/clean-csv.bnto.json");
212        let csv_data = include_bytes!("../../../../test-fixtures/csv/messy.csv");
213        let files = vec![PipelineFile {
214            name: "data.csv".to_string(),
215            data: csv_data.to_vec(),
216            mime_type: "text/csv".to_string(),
217            metadata: serde_json::Map::new(),
218        }];
219
220        let reporter = PipelineReporter::new_noop();
221        let result = run_pipeline(json, files, &reporter, &NoopContext).expect("clean-csv recipe");
222
223        assert_eq!(result.files.len(), 1);
224    }
225
226    #[test]
227    fn test_generated_rename_files_recipe() {
228        let json = include_str!("../recipes/rename-files.bnto.json");
229        let files = vec![PipelineFile {
230            name: "document.txt".to_string(),
231            data: b"hello world".to_vec(),
232            mime_type: "text/plain".to_string(),
233            metadata: serde_json::Map::new(),
234        }];
235
236        let reporter = PipelineReporter::new_noop();
237        let result =
238            run_pipeline(json, files, &reporter, &NoopContext).expect("rename-files recipe");
239
240        assert_eq!(result.files.len(), 1);
241        assert!(
242            result.files[0].name.starts_with("renamed-"),
243            "Expected 'renamed-' prefix, got: {}",
244            result.files[0].name,
245        );
246    }
247
248    #[test]
249    fn test_generated_csv_to_json_recipe() {
250        let json = include_str!("../recipes/csv-to-json.bnto.json");
251        let csv_data = include_bytes!("../../../../test-fixtures/csv/simple.csv");
252        let files = vec![PipelineFile {
253            name: "data.csv".to_string(),
254            data: csv_data.to_vec(),
255            mime_type: "text/csv".to_string(),
256            metadata: serde_json::Map::new(),
257        }];
258
259        let reporter = PipelineReporter::new_noop();
260        let result =
261            run_pipeline(json, files, &reporter, &NoopContext).expect("csv-to-json recipe");
262
263        assert_eq!(result.files.len(), 1);
264        assert!(result.files[0].name.ends_with(".json"));
265    }
266
267    #[test]
268    fn test_generated_merge_csv_recipe() {
269        let json = include_str!("../recipes/merge-csv.bnto.json");
270        let csv_a = b"name,age\nAlice,30\n";
271        let csv_b = b"name,age\nBob,25\n";
272        let files = vec![
273            PipelineFile {
274                name: "a.csv".to_string(),
275                data: csv_a.to_vec(),
276                mime_type: "text/csv".to_string(),
277                metadata: serde_json::Map::new(),
278            },
279            PipelineFile {
280                name: "b.csv".to_string(),
281                data: csv_b.to_vec(),
282                mime_type: "text/csv".to_string(),
283                metadata: serde_json::Map::new(),
284            },
285        ];
286
287        let reporter = PipelineReporter::new_noop();
288        let result = run_pipeline(json, files, &reporter, &NoopContext).expect("merge-csv recipe");
289
290        assert_eq!(result.files.len(), 1);
291        let output = String::from_utf8_lossy(&result.files[0].data);
292        assert!(output.contains("Alice"));
293        assert!(output.contains("Bob"));
294    }
295
296    #[test]
297    fn test_generated_strip_exif_recipe() {
298        let json = include_str!("../recipes/strip-exif.bnto.json");
299        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
300        let files = vec![PipelineFile {
301            name: "photo.jpg".to_string(),
302            data: test_image.to_vec(),
303            mime_type: "image/jpeg".to_string(),
304            metadata: serde_json::Map::new(),
305        }];
306
307        let reporter = PipelineReporter::new_noop();
308        let result = run_pipeline(json, files, &reporter, &NoopContext).expect("strip-exif recipe");
309
310        assert_eq!(result.files.len(), 1);
311        assert!(!result.files[0].data.is_empty());
312    }
313
314    #[test]
315    fn test_generated_watermark_images_recipe() {
316        // The generated recipe has an empty overlay param — inject a real one.
317        let mut def: serde_json::Value =
318            serde_json::from_str(include_str!("../recipes/watermark-images.bnto.json")).unwrap();
319
320        // Base64-encode the test overlay image
321        let overlay_bytes =
322            include_bytes!("../../../../test-fixtures/images/overlays/overlay-logo.png");
323        let overlay_b64 = format!(
324            "data:image/png;base64,{}",
325            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, overlay_bytes)
326        );
327
328        // Set the overlay param on the overlay node
329        let nodes = def["nodes"].as_array_mut().unwrap();
330        for node in nodes.iter_mut() {
331            if node["type"] == "image-overlay" {
332                node["parameters"]["overlay"] = serde_json::Value::String(overlay_b64.clone());
333            }
334        }
335
336        let json = serde_json::to_string(&def).unwrap();
337        let test_image = include_bytes!("../../../../test-fixtures/images/small.jpg");
338        let files = vec![PipelineFile {
339            name: "photo.jpg".to_string(),
340            data: test_image.to_vec(),
341            mime_type: "image/jpeg".to_string(),
342            metadata: serde_json::Map::new(),
343        }];
344
345        let reporter = PipelineReporter::new_noop();
346        let result =
347            run_pipeline(&json, files, &reporter, &NoopContext).expect("watermark-images recipe");
348
349        assert_eq!(result.files.len(), 1);
350        assert!(!result.files[0].data.is_empty());
351    }
352
353    #[test]
354    fn test_all_generated_recipes_parse() {
355        let recipes = [
356            include_str!("../recipes/compress-images.bnto.json"),
357            include_str!("../recipes/resize-images.bnto.json"),
358            include_str!("../recipes/convert-image-format.bnto.json"),
359            include_str!("../recipes/rename-files.bnto.json"),
360            include_str!("../recipes/clean-csv.bnto.json"),
361            include_str!("../recipes/rename-csv-columns.bnto.json"),
362            include_str!("../recipes/optimize-images-for-web.bnto.json"),
363            include_str!("../recipes/generate-thumbnails.bnto.json"),
364            include_str!("../recipes/compress-and-rename.bnto.json"),
365            include_str!("../recipes/standardize-csv.bnto.json"),
366            include_str!("../recipes/csv-to-json.bnto.json"),
367            include_str!("../recipes/strip-exif.bnto.json"),
368            include_str!("../recipes/watermark-images.bnto.json"),
369            include_str!("../recipes/merge-csv.bnto.json"),
370            include_str!("../recipes/download-video.bnto.json"),
371        ];
372
373        for (i, json) in recipes.iter().enumerate() {
374            let def: PipelineDefinition = serde_json::from_str(json)
375                .unwrap_or_else(|e| panic!("Recipe {i} failed to parse: {e}"));
376            assert!(
377                !def.nodes.is_empty(),
378                "Recipe {i} should have at least one node",
379            );
380        }
381    }
382}