Skip to main content

bnto_engine/
lib.rs

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