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("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
45pub 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
65pub 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 deps::check_pipeline_dependencies(&definition, ®istry, 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, ®istry, 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, ¶ms).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 assert_eq!(registry.len(), 11);
132 let params = serde_json::Map::new();
133 assert!(
134 registry.resolve("video-download", ¶ms).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 let mut def: serde_json::Value =
318 serde_json::from_str(include_str!("../recipes/watermark-images.bnto.json")).unwrap();
319
320 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 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}