1pub mod deps;
8pub mod recipes;
9
10use bnto_core::{
11 BntoError, NodeRegistry, PipelineDefinition, PipelineFile, PipelineReporter, PipelineResult,
12 ProcessContext, execute_pipeline,
13};
14
15pub 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
65pub 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
84pub 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 deps::check_pipeline_dependencies(&definition, ®istry, ctx)?;
102
103 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, ®istry, 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, ¶ms).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 assert_eq!(registry.len(), 18);
160 let params = serde_json::Map::new();
161 assert!(
162 registry.resolve("shell-command", ¶ms).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 let mut def: serde_json::Value =
351 serde_json::from_str(include_str!("../recipes/watermark-images.bnto.json")).unwrap();
352
353 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 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 assert!(bytes.len() < test_svg.len());
409 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 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, ¶ms)
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 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 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}