1use std::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9#[cfg(feature = "ts")]
10use ts_rs::TS;
11
12use crate::field_def::FieldDef;
13use crate::secrets::SecretDef;
14
15#[cfg_attr(feature = "ts", derive(TS))]
21#[cfg_attr(
22 feature = "ts",
23 ts(
24 export,
25 export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
26 )
27)]
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
29#[serde(rename_all = "camelCase")]
30pub enum IterationMode {
31 #[default]
34 Explicit,
35 Auto,
38}
39
40#[cfg_attr(feature = "ts", derive(TS))]
43#[cfg_attr(
44 feature = "ts",
45 ts(
46 export,
47 export_to = "../../../../packages/@bnto/nodes/src/generated/definitionTypes/"
48 )
49)]
50#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
51#[serde(rename_all = "camelCase")]
52pub struct PipelineSettings {
53 #[serde(default)]
55 pub iteration: IterationMode,
56}
57
58#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct PipelineDefinition {
65 pub nodes: Vec<PipelineNode>,
68
69 #[serde(default)]
72 pub settings: Option<PipelineSettings>,
73
74 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub requires: Vec<crate::Dependency>,
79
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
85 pub secrets: Vec<SecretDef>,
86}
87
88impl PipelineDefinition {
89 pub fn resolved_iteration(&self) -> IterationMode {
92 self.settings
93 .as_ref()
94 .map(|s| s.iteration)
95 .unwrap_or_default()
96 }
97}
98
99#[derive(Debug, Clone, Deserialize, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct PipelineNode {
103 pub id: String,
106
107 #[serde(rename = "type")]
110 pub node_type: String,
111
112 #[serde(default, alias = "parameters")]
118 pub params: serde_json::Map<String, serde_json::Value>,
119
120 #[serde(alias = "nodes")]
129 pub children: Option<Vec<PipelineNode>>,
130
131 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
135 pub fields: BTreeMap<String, FieldDef>,
136}
137
138#[derive(Debug, Clone)]
149pub struct PipelineFile {
150 pub name: String,
152
153 pub data: crate::processor::FileData,
155
156 pub mime_type: String,
158
159 pub metadata: serde_json::Map<String, serde_json::Value>,
164}
165
166#[derive(Debug, Clone)]
171pub struct PipelineFileResult {
172 pub name: String,
174
175 pub data: crate::processor::FileData,
177
178 pub mime_type: String,
180
181 pub metadata: serde_json::Map<String, serde_json::Value>,
184}
185
186#[derive(Debug, Clone)]
188pub struct PipelineResult {
189 pub files: Vec<PipelineFileResult>,
191
192 pub duration_ms: u64,
194
195 pub warnings: Vec<String>,
197}
198
199pub use crate::input_mode::InputMode;
204
205pub fn resolve_input_mode(def: &PipelineDefinition) -> InputMode {
208 find_input_mode_in_nodes(&def.nodes)
209}
210
211fn find_input_mode_in_nodes(nodes: &[PipelineNode]) -> InputMode {
212 for node in nodes {
213 if node.node_type == "input" {
214 return node
215 .params
216 .get("mode")
217 .and_then(|v| v.as_str())
218 .and_then(|s| s.parse().ok())
219 .unwrap_or_default();
220 }
221 if let Some(children) = &node.children {
223 let mode = find_input_mode_in_nodes(children);
224 if mode != InputMode::FileUpload {
225 return mode;
226 }
227 if children.iter().any(|c| c.node_type == "input") {
229 return InputMode::FileUpload;
230 }
231 }
232 }
233 InputMode::FileUpload
234}
235
236pub fn first_processing_node_id(def: &PipelineDefinition) -> Option<String> {
239 find_first_processing_in_nodes(&def.nodes)
240}
241
242fn find_first_processing_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
243 for node in nodes {
244 if is_io_node(&node.node_type) {
245 continue;
246 }
247 if is_container_node(&node.node_type) {
248 if let Some(children) = &node.children
250 && let Some(id) = find_first_processing_in_nodes(children)
251 {
252 return Some(id);
253 }
254 continue;
255 }
256 return Some(node.id.clone());
258 }
259 None
260}
261
262pub fn resolve_output_directory(def: &PipelineDefinition) -> Option<String> {
272 find_output_directory_in_nodes(&def.nodes)
273}
274
275fn find_output_directory_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
276 for node in nodes {
277 if node.node_type == "output" {
278 let dir = node
279 .params
280 .get("directory")
281 .and_then(|v| v.as_str())
282 .unwrap_or("");
283 return if dir.is_empty() {
284 None
285 } else {
286 Some(dir.to_string())
287 };
288 }
289 if let Some(children) = &node.children
291 && let Some(dir) = find_output_directory_in_nodes(children)
292 {
293 return Some(dir);
294 }
295 }
296 None
297}
298
299pub fn resolve_output_mode(def: &PipelineDefinition) -> String {
308 find_output_mode_in_nodes(&def.nodes).unwrap_or_else(|| "write".to_string())
309}
310
311fn find_output_mode_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
312 for node in nodes {
313 if node.node_type == "output" {
314 let mode = node
315 .params
316 .get("mode")
317 .and_then(|v| v.as_str())
318 .unwrap_or("write");
319 return if mode.is_empty() {
320 Some("write".to_string())
321 } else {
322 Some(mode.to_string())
323 };
324 }
325 if let Some(children) = &node.children
327 && let Some(mode) = find_output_mode_in_nodes(children)
328 {
329 return Some(mode);
330 }
331 }
332 None
333}
334
335pub fn is_io_node(node_type: &str) -> bool {
342 node_type == "input" || node_type == "output"
343}
344
345pub fn is_container_node(node_type: &str) -> bool {
348 node_type == "loop" || node_type == "group" || node_type == "parallel"
349}
350
351#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn parse_definition(json: &str) -> PipelineDefinition {
360 serde_json::from_str(json).unwrap()
361 }
362
363 #[test]
367 fn test_simple_definition_deserializes() {
368 let json = r#"{
370 "nodes": [
371 { "id": "n1", "type": "input" },
372 { "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
373 { "id": "n3", "type": "output" }
374 ]
375 }"#;
376
377 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
378
379 assert_eq!(def.nodes.len(), 3);
380 assert_eq!(def.nodes[0].id, "n1");
381 assert_eq!(def.nodes[0].node_type, "input");
382 assert_eq!(def.nodes[1].id, "n2");
383 assert_eq!(def.nodes[1].node_type, "image-compress");
384 assert_eq!(def.nodes[2].id, "n3");
385 assert_eq!(def.nodes[2].node_type, "output");
386 }
387
388 #[test]
389 fn test_params_deserialize_correctly() {
390 let json = r#"{
391 "nodes": [
392 {
393 "id": "n1",
394 "type": "image-compress",
395 "params": {
396 "quality": 80,
397 "preserveExif": true
398 }
399 }
400 ]
401 }"#;
402
403 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
404 let params = &def.nodes[0].params;
405
406 assert_eq!(params["quality"], 80);
407 assert_eq!(params["preserveExif"], true);
408 }
409
410 #[test]
411 fn test_missing_params_defaults_to_empty() {
412 let json = r#"{
414 "nodes": [
415 { "id": "n1", "type": "input" }
416 ]
417 }"#;
418
419 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
420 assert!(def.nodes[0].params.is_empty());
421 }
422
423 #[test]
424 fn test_container_node_with_children() {
425 let json = r#"{
427 "nodes": [
428 {
429 "id": "loop-1",
430 "type": "loop",
431 "children": [
432 { "id": "child-1", "type": "image-compress" }
433 ]
434 }
435 ]
436 }"#;
437
438 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
439 let loop_node = &def.nodes[0];
440
441 assert_eq!(loop_node.node_type, "loop");
442 let children = loop_node.children.as_ref().unwrap();
443 assert_eq!(children.len(), 1);
444 assert_eq!(children[0].node_type, "image-compress");
445 }
446
447 #[test]
448 fn test_no_children_is_none() {
449 let json = r#"{
450 "nodes": [
451 { "id": "n1", "type": "image-compress" }
452 ]
453 }"#;
454
455 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
456 assert!(def.nodes[0].children.is_none());
457 }
458
459 #[test]
460 fn test_nested_containers() {
461 let json = r#"{
463 "nodes": [
464 {
465 "id": "group-1",
466 "type": "group",
467 "children": [
468 {
469 "id": "loop-1",
470 "type": "loop",
471 "children": [
472 { "id": "proc-1", "type": "image-compress" }
473 ]
474 }
475 ]
476 }
477 ]
478 }"#;
479
480 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
481 let group = &def.nodes[0];
482 let loop_node = &group.children.as_ref().unwrap()[0];
483 let proc_node = &loop_node.children.as_ref().unwrap()[0];
484
485 assert_eq!(group.node_type, "group");
486 assert_eq!(loop_node.node_type, "loop");
487 assert_eq!(proc_node.node_type, "image-compress");
488 }
489
490 #[test]
495 fn test_nodes_alias_deserializes_as_children() {
496 let json = r#"{
499 "nodes": [
500 {
501 "id": "loop-1",
502 "type": "loop",
503 "nodes": [
504 { "id": "child-1", "type": "image-compress" }
505 ]
506 }
507 ]
508 }"#;
509
510 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
511 let loop_node = &def.nodes[0];
512 let children = loop_node.children.as_ref().unwrap();
513
514 assert_eq!(children.len(), 1);
515 assert_eq!(children[0].id, "child-1");
516 assert_eq!(children[0].node_type, "image-compress");
517 }
518
519 #[test]
520 fn test_parameters_alias_deserializes_as_params() {
521 let json = r#"{
524 "nodes": [
525 {
526 "id": "n1",
527 "type": "image-compress",
528 "parameters": { "quality": 80 }
529 }
530 ]
531 }"#;
532
533 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
534 let params = &def.nodes[0].params;
535
536 assert_eq!(params["quality"], 80);
537 }
538
539 #[test]
540 fn test_both_aliases_together() {
541 let json = r#"{
543 "nodes": [
544 {
545 "id": "loop-1",
546 "type": "loop",
547 "parameters": { "mode": "forEach" },
548 "nodes": [
549 {
550 "id": "child-1",
551 "type": "image-compress",
552 "parameters": { "quality": 75 }
553 }
554 ]
555 }
556 ]
557 }"#;
558
559 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
560 let loop_node = &def.nodes[0];
561
562 assert_eq!(loop_node.params["mode"], "forEach");
564
565 let children = loop_node.children.as_ref().unwrap();
567 assert_eq!(children.len(), 1);
568 assert_eq!(children[0].params["quality"], 75);
569 }
570
571 #[test]
572 fn test_original_field_names_still_work() {
573 let json = r#"{
575 "nodes": [
576 {
577 "id": "loop-1",
578 "type": "loop",
579 "params": { "mode": "forEach" },
580 "children": [
581 { "id": "child-1", "type": "image-compress" }
582 ]
583 }
584 ]
585 }"#;
586
587 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
588 let loop_node = &def.nodes[0];
589
590 assert_eq!(loop_node.params["mode"], "forEach");
591 assert_eq!(loop_node.children.as_ref().unwrap().len(), 1);
592 }
593
594 #[test]
595 fn test_unknown_fields_silently_ignored() {
596 let json = r#"{
600 "nodes": [
601 {
602 "id": "compress-image",
603 "type": "image-compress",
604 "version": "1.0.0",
605 "name": "Compress Image",
606 "position": { "x": 100, "y": 100 },
607 "metadata": { "description": "Compresses images" },
608 "parameters": { "quality": 80 },
609 "inputPorts": [{ "id": "in-1", "name": "files" }],
610 "outputPorts": [{ "id": "out-1", "name": "files" }]
611 }
612 ],
613 "edges": [{ "id": "e1", "source": "input", "target": "compress-image" }]
614 }"#;
615
616 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
617 assert_eq!(def.nodes.len(), 1);
618 assert_eq!(def.nodes[0].id, "compress-image");
619 assert_eq!(def.nodes[0].params["quality"], 80);
620 }
621
622 #[test]
627 fn test_compress_images_recipe_deserializes() {
628 let json = r#"{
630 "nodes": [
631 {
632 "id": "input", "type": "input", "version": "1.0.0",
633 "name": "Input Files", "position": {"x": 0, "y": 100},
634 "metadata": {},
635 "parameters": { "mode": "file-upload", "accept": ["image/jpeg"] },
636 "inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
637 },
638 {
639 "id": "batch-compress", "type": "group", "version": "1.0.0",
640 "name": "Batch Compress", "position": {"x": 250, "y": 100},
641 "metadata": { "description": "Reusable sub-recipe." },
642 "parameters": {},
643 "inputPorts": [{"id": "in-1", "name": "files"}],
644 "outputPorts": [{"id": "out-1", "name": "files"}],
645 "nodes": [
646 {
647 "id": "compress-loop", "type": "loop", "version": "1.0.0",
648 "name": "Compress Each Image", "position": {"x": 0, "y": 0},
649 "metadata": {},
650 "parameters": { "mode": "forEach" },
651 "inputPorts": [{"id": "in-1", "name": "items"}], "outputPorts": [],
652 "nodes": [
653 {
654 "id": "compress-image", "type": "image-compress", "version": "1.0.0",
655 "name": "Compress Image", "position": {"x": 0, "y": 0},
656 "metadata": {},
657 "parameters": { "quality": 80 },
658 "inputPorts": [], "outputPorts": []
659 }
660 ],
661 "edges": []
662 }
663 ],
664 "edges": []
665 },
666 {
667 "id": "output", "type": "output", "version": "1.0.0",
668 "name": "Compressed Images", "position": {"x": 500, "y": 100},
669 "metadata": {},
670 "parameters": { "mode": "write", "zip": true },
671 "inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
672 }
673 ],
674 "edges": [
675 {"id": "e1", "source": "input", "target": "batch-compress"},
676 {"id": "e2", "source": "batch-compress", "target": "output"}
677 ]
678 }"#;
679
680 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
681
682 assert_eq!(def.nodes.len(), 3);
684 assert_eq!(def.nodes[0].node_type, "input");
685 assert_eq!(def.nodes[1].node_type, "group");
686 assert_eq!(def.nodes[1].id, "batch-compress");
687 assert_eq!(def.nodes[2].node_type, "output");
688
689 let group_children = def.nodes[1].children.as_ref().unwrap();
691 assert_eq!(group_children.len(), 1);
692 assert_eq!(group_children[0].node_type, "loop");
693
694 let loop_children = group_children[0].children.as_ref().unwrap();
696 assert_eq!(loop_children.len(), 1);
697 assert_eq!(loop_children[0].id, "compress-image");
698 assert_eq!(loop_children[0].node_type, "image-compress");
699 assert_eq!(loop_children[0].params["quality"], 80);
700 }
701
702 #[test]
703 fn test_clean_csv_recipe_deserializes() {
704 let json = r#"{
706 "nodes": [
707 {
708 "id": "input", "type": "input", "version": "1.0.0",
709 "name": "Input Files", "position": {"x": 0, "y": 100},
710 "metadata": {},
711 "parameters": { "mode": "file-upload" },
712 "inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
713 },
714 {
715 "id": "csv-cleaner", "type": "group", "version": "1.0.0",
716 "name": "CSV Cleaner", "position": {"x": 250, "y": 100},
717 "metadata": {},
718 "parameters": {},
719 "inputPorts": [{"id": "in-1", "name": "files"}],
720 "outputPorts": [{"id": "out-1", "name": "files"}],
721 "nodes": [
722 {
723 "id": "clean", "type": "spreadsheet-clean", "version": "1.0.0",
724 "name": "Clean CSV", "position": {"x": 0, "y": 0},
725 "metadata": {},
726 "parameters": {
727 "trimWhitespace": true,
728 "removeEmptyRows": true,
729 "removeDuplicates": true
730 },
731 "inputPorts": [{"id": "in-1", "name": "files"}],
732 "outputPorts": [{"id": "out-1", "name": "files"}]
733 }
734 ],
735 "edges": []
736 },
737 {
738 "id": "output", "type": "output", "version": "1.0.0",
739 "name": "Cleaned CSV", "position": {"x": 500, "y": 100},
740 "metadata": {},
741 "parameters": { "mode": "write" },
742 "inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
743 }
744 ],
745 "edges": [
746 {"id": "e1", "source": "input", "target": "csv-cleaner"},
747 {"id": "e2", "source": "csv-cleaner", "target": "output"}
748 ]
749 }"#;
750
751 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
752
753 assert_eq!(def.nodes.len(), 3);
754 assert_eq!(def.nodes[1].node_type, "group");
756 assert_eq!(def.nodes[1].id, "csv-cleaner");
757
758 let group_children = def.nodes[1].children.as_ref().unwrap();
760 assert_eq!(group_children.len(), 1);
761 assert_eq!(group_children[0].node_type, "spreadsheet-clean");
762 }
763
764 #[test]
765 fn test_rename_files_recipe_deserializes() {
766 let json = r#"{
768 "nodes": [
769 { "id": "input", "type": "input", "version": "1.0.0",
770 "name": "Input", "position": {"x": 0, "y": 0}, "metadata": {},
771 "parameters": {}, "inputPorts": [], "outputPorts": [] },
772 {
773 "id": "batch-rename", "type": "group", "version": "1.0.0",
774 "name": "Batch Rename", "position": {"x": 250, "y": 100},
775 "metadata": {},
776 "parameters": {},
777 "inputPorts": [], "outputPorts": [],
778 "nodes": [
779 {
780 "id": "rename-loop", "type": "loop", "version": "1.0.0",
781 "name": "Rename Each File", "position": {"x": 0, "y": 0},
782 "metadata": {},
783 "parameters": { "mode": "forEach" },
784 "inputPorts": [], "outputPorts": [],
785 "nodes": [
786 {
787 "id": "rename-file", "type": "file-rename", "version": "1.0.0",
788 "name": "Rename File", "position": {"x": 0, "y": 0},
789 "metadata": {},
790 "parameters": { "prefix": "renamed-" },
791 "inputPorts": [], "outputPorts": []
792 }
793 ],
794 "edges": []
795 }
796 ],
797 "edges": []
798 },
799 { "id": "output", "type": "output", "version": "1.0.0",
800 "name": "Output", "position": {"x": 0, "y": 0}, "metadata": {},
801 "parameters": {}, "inputPorts": [], "outputPorts": [] }
802 ],
803 "edges": []
804 }"#;
805
806 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
807
808 let group_node = &def.nodes[1];
810 assert_eq!(group_node.node_type, "group");
811 assert_eq!(group_node.id, "batch-rename");
812
813 let group_children = group_node.children.as_ref().unwrap();
815 assert_eq!(group_children.len(), 1);
816 assert_eq!(group_children[0].node_type, "loop");
817
818 let loop_children = group_children[0].children.as_ref().unwrap();
820 assert_eq!(loop_children.len(), 1);
821 assert_eq!(loop_children[0].node_type, "file-rename");
822 assert_eq!(loop_children[0].params["prefix"], "renamed-");
823 }
824
825 #[test]
826 fn test_deeply_nested_three_levels() {
827 let json = r#"{
830 "nodes": [
831 {
832 "id": "outer-group", "type": "group",
833 "parameters": {},
834 "nodes": [
835 {
836 "id": "inner-group", "type": "group",
837 "parameters": {},
838 "nodes": [
839 {
840 "id": "the-loop", "type": "loop",
841 "parameters": { "mode": "forEach" },
842 "nodes": [
843 {
844 "id": "processor", "type": "image-compress",
845 "parameters": { "quality": 50 }
846 }
847 ]
848 }
849 ]
850 }
851 ]
852 }
853 ]
854 }"#;
855
856 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
857
858 let outer = &def.nodes[0];
860 assert_eq!(outer.node_type, "group");
861
862 let inner = &outer.children.as_ref().unwrap()[0];
863 assert_eq!(inner.node_type, "group");
864
865 let loop_node = &inner.children.as_ref().unwrap()[0];
866 assert_eq!(loop_node.node_type, "loop");
867
868 let processor = &loop_node.children.as_ref().unwrap()[0];
869 assert_eq!(processor.node_type, "image-compress");
870 assert_eq!(processor.params["quality"], 50);
871 }
872
873 #[test]
876 fn test_definition_without_requires_still_parses() {
877 let json = r#"{
879 "nodes": [
880 { "id": "n1", "type": "input" },
881 { "id": "n2", "type": "image-compress" }
882 ]
883 }"#;
884 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
885 assert!(def.requires.is_empty());
886 }
887
888 #[test]
889 fn test_definition_with_requires_parses() {
890 let json = r#"{
891 "requires": [
892 {
893 "binary": "yt-dlp",
894 "installHint": "brew install yt-dlp",
895 "homepage": "https://github.com/yt-dlp/yt-dlp"
896 }
897 ],
898 "nodes": [
899 { "id": "n1", "type": "input" }
900 ]
901 }"#;
902 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
903 assert_eq!(def.requires.len(), 1);
904 assert_eq!(def.requires[0].binary, "yt-dlp");
905 assert_eq!(def.requires[0].install_hint, "brew install yt-dlp");
906 }
907
908 #[test]
909 fn test_definition_with_multiple_requires() {
910 let json = r#"{
911 "requires": [
912 { "binary": "yt-dlp", "installHint": "brew install yt-dlp" },
913 { "binary": "ffmpeg", "installHint": "brew install ffmpeg", "version": ">=6.0" }
914 ],
915 "nodes": [{ "id": "n1", "type": "input" }]
916 }"#;
917 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
918 assert_eq!(def.requires.len(), 2);
919 assert_eq!(def.requires[0].binary, "yt-dlp");
920 assert_eq!(def.requires[1].binary, "ffmpeg");
921 assert_eq!(def.requires[1].version, ">=6.0");
922 }
923
924 #[test]
925 fn test_definition_empty_requires_omitted_in_serialization() {
926 let json = r#"{ "nodes": [{ "id": "n1", "type": "input" }] }"#;
928 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
929 let serialized = serde_json::to_string(&def).unwrap();
930 assert!(
931 !serialized.contains("requires"),
932 "Empty requires should be omitted; got: {serialized}"
933 );
934 }
935
936 #[test]
937 fn test_definition_requires_round_trip() {
938 let json = r#"{
939 "requires": [
940 {
941 "binary": "yt-dlp",
942 "version": ">=2024.0.0",
943 "installHint": "brew install yt-dlp",
944 "homepage": "https://github.com/yt-dlp/yt-dlp"
945 }
946 ],
947 "nodes": [{ "id": "n1", "type": "input" }]
948 }"#;
949 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
950 let serialized = serde_json::to_string(&def).unwrap();
951 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
952 assert_eq!(round_tripped.requires.len(), 1);
953 assert_eq!(round_tripped.requires[0].binary, "yt-dlp");
954 assert_eq!(round_tripped.requires[0].version, ">=2024.0.0");
955 assert_eq!(
956 round_tripped.requires[0].install_hint,
957 "brew install yt-dlp"
958 );
959 assert_eq!(
960 round_tripped.requires[0].homepage,
961 "https://github.com/yt-dlp/yt-dlp"
962 );
963 }
964
965 #[test]
966 fn test_definition_requires_preserves_all_dependency_fields() {
967 let json = r#"{
969 "requires": [
970 {
971 "binary": "ffmpeg",
972 "version": ">=6.0",
973 "installHint": "brew install ffmpeg",
974 "homepage": "https://ffmpeg.org"
975 }
976 ],
977 "nodes": []
978 }"#;
979 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
980 let dep = &def.requires[0];
981 assert_eq!(dep.binary, "ffmpeg");
982 assert_eq!(dep.version, ">=6.0");
983 assert_eq!(dep.install_hint, "brew install ffmpeg");
984 assert_eq!(dep.homepage, "https://ffmpeg.org");
985 }
986
987 #[test]
990 fn test_definition_without_secrets_still_parses() {
991 let json = r#"{
992 "nodes": [{ "id": "n1", "type": "input" }]
993 }"#;
994 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
995 assert!(def.secrets.is_empty());
996 }
997
998 #[test]
999 fn test_definition_with_secrets_parses() {
1000 let json = r#"{
1001 "secrets": [
1002 { "key": "OPENAI_API_KEY", "description": "OpenAI API key", "required": true }
1003 ],
1004 "nodes": [{ "id": "n1", "type": "input" }]
1005 }"#;
1006 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1007 assert_eq!(def.secrets.len(), 1);
1008 assert_eq!(def.secrets[0].key, "OPENAI_API_KEY");
1009 assert!(def.secrets[0].required);
1010 }
1011
1012 #[test]
1013 fn test_definition_secrets_defaults_required_true() {
1014 let json = r#"{
1015 "secrets": [{ "key": "API_KEY" }],
1016 "nodes": [{ "id": "n1", "type": "input" }]
1017 }"#;
1018 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1019 assert!(def.secrets[0].required);
1020 assert!(def.secrets[0].description.is_empty());
1021 }
1022
1023 #[test]
1024 fn test_definition_empty_secrets_omitted_in_serialization() {
1025 let json = r#"{ "nodes": [{ "id": "n1", "type": "input" }] }"#;
1026 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1027 let serialized = serde_json::to_string(&def).unwrap();
1028 assert!(
1029 !serialized.contains("secrets"),
1030 "Empty secrets should be omitted; got: {serialized}"
1031 );
1032 }
1033
1034 #[test]
1035 fn test_definition_secrets_round_trip() {
1036 let json = r#"{
1037 "secrets": [
1038 { "key": "API_KEY", "description": "Test key", "required": true },
1039 { "key": "OPTIONAL", "required": false }
1040 ],
1041 "nodes": [{ "id": "n1", "type": "input" }]
1042 }"#;
1043 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1044 let serialized = serde_json::to_string(&def).unwrap();
1045 let rt: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
1046 assert_eq!(rt.secrets.len(), 2);
1047 assert_eq!(rt.secrets[0].key, "API_KEY");
1048 assert!(rt.secrets[0].required);
1049 assert_eq!(rt.secrets[1].key, "OPTIONAL");
1050 assert!(!rt.secrets[1].required);
1051 }
1052
1053 #[test]
1058 fn test_definition_without_settings_deserializes() {
1059 let json = r#"{
1060 "nodes": [
1061 { "id": "n1", "type": "input" },
1062 { "id": "n2", "type": "image-compress" },
1063 { "id": "n3", "type": "output" }
1064 ]
1065 }"#;
1066 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1067 assert!(def.settings.is_none());
1068 }
1069
1070 #[test]
1071 fn test_definition_with_auto_iteration_deserializes() {
1072 let json = r#"{
1073 "settings": { "iteration": "auto" },
1074 "nodes": [
1075 { "id": "n1", "type": "input" },
1076 { "id": "n2", "type": "image-compress" },
1077 { "id": "n3", "type": "output" }
1078 ]
1079 }"#;
1080 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1081 let settings = def.settings.as_ref().unwrap();
1082 assert_eq!(settings.iteration, IterationMode::Auto);
1083 }
1084
1085 #[test]
1086 fn test_definition_with_explicit_iteration_deserializes() {
1087 let json = r#"{
1088 "settings": { "iteration": "explicit" },
1089 "nodes": [
1090 { "id": "n1", "type": "image-compress" }
1091 ]
1092 }"#;
1093 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1094 let settings = def.settings.as_ref().unwrap();
1095 assert_eq!(settings.iteration, IterationMode::Explicit);
1096 }
1097
1098 #[test]
1099 fn test_definition_with_unknown_iteration_fails() {
1100 let json = r#"{
1101 "settings": { "iteration": "garbage" },
1102 "nodes": [{ "id": "n1", "type": "input" }]
1103 }"#;
1104 let result = serde_json::from_str::<PipelineDefinition>(json);
1105 assert!(result.is_err());
1106 }
1107
1108 #[test]
1109 fn test_resolved_iteration_defaults_explicit() {
1110 let json = r#"{ "nodes": [] }"#;
1111 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1112 assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
1113 }
1114
1115 #[test]
1116 fn test_resolved_iteration_returns_auto() {
1117 let json = r#"{
1118 "settings": { "iteration": "auto" },
1119 "nodes": []
1120 }"#;
1121 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1122 assert_eq!(def.resolved_iteration(), IterationMode::Auto);
1123 }
1124
1125 #[test]
1126 fn test_settings_with_default_iteration_field() {
1127 let json = r#"{
1129 "settings": {},
1130 "nodes": []
1131 }"#;
1132 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1133 let settings = def.settings.as_ref().unwrap();
1134 assert_eq!(settings.iteration, IterationMode::Explicit);
1135 assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
1136 }
1137
1138 #[test]
1142 fn test_definition_round_trip_serialization() {
1143 let json = r#"{
1144 "nodes": [
1145 { "id": "n1", "type": "input" },
1146 { "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
1147 { "id": "n3", "type": "output" }
1148 ],
1149 "settings": { "iteration": "auto" }
1150 }"#;
1151
1152 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1153 let serialized = serde_json::to_string(&def).unwrap();
1154 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
1155
1156 assert_eq!(round_tripped.nodes.len(), def.nodes.len());
1157 for (orig, rt) in def.nodes.iter().zip(round_tripped.nodes.iter()) {
1158 assert_eq!(orig.id, rt.id);
1159 assert_eq!(orig.node_type, rt.node_type);
1160 }
1161 assert_eq!(round_tripped.resolved_iteration(), def.resolved_iteration());
1162 }
1163
1164 #[test]
1165 fn test_definition_serialization_preserves_params() {
1166 let json = r#"{
1167 "nodes": [
1168 {
1169 "id": "n1",
1170 "type": "image-compress",
1171 "params": { "quality": 80, "preserveExif": true, "name": "test" }
1172 }
1173 ]
1174 }"#;
1175
1176 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1177 let serialized = serde_json::to_string(&def).unwrap();
1178 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
1179
1180 let params = &round_tripped.nodes[0].params;
1181 assert_eq!(params["quality"], 80);
1182 assert_eq!(params["preserveExif"], true);
1183 assert_eq!(params["name"], "test");
1184 }
1185
1186 #[test]
1187 fn test_definition_serialization_preserves_children() {
1188 let json = r#"{
1189 "nodes": [
1190 {
1191 "id": "group-1",
1192 "type": "group",
1193 "children": [
1194 {
1195 "id": "loop-1",
1196 "type": "loop",
1197 "children": [
1198 { "id": "proc-1", "type": "image-compress", "params": { "quality": 50 } }
1199 ]
1200 }
1201 ]
1202 }
1203 ]
1204 }"#;
1205
1206 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1207 let serialized = serde_json::to_string(&def).unwrap();
1208 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
1209
1210 let group = &round_tripped.nodes[0];
1211 assert_eq!(group.node_type, "group");
1212 let loop_node = &group.children.as_ref().unwrap()[0];
1213 assert_eq!(loop_node.node_type, "loop");
1214 let proc_node = &loop_node.children.as_ref().unwrap()[0];
1215 assert_eq!(proc_node.node_type, "image-compress");
1216 assert_eq!(proc_node.params["quality"], 50);
1217 }
1218
1219 #[test]
1220 fn test_iteration_mode_serializes_camel_case() {
1221 let auto_json = serde_json::to_string(&IterationMode::Auto).unwrap();
1222 assert_eq!(auto_json, r#""auto""#);
1223
1224 let explicit_json = serde_json::to_string(&IterationMode::Explicit).unwrap();
1225 assert_eq!(explicit_json, r#""explicit""#);
1226 }
1227
1228 #[test]
1229 fn test_pipeline_settings_serializes() {
1230 let settings = PipelineSettings {
1231 iteration: IterationMode::Auto,
1232 };
1233 let json = serde_json::to_string(&settings).unwrap();
1234 assert!(json.contains(r#""iteration":"auto""#));
1235 }
1236
1237 #[test]
1240 fn test_is_io_node() {
1241 assert!(is_io_node("input"));
1242 assert!(is_io_node("output"));
1243 assert!(!is_io_node("image-compress"));
1244 assert!(!is_io_node("spreadsheet-clean"));
1245 assert!(!is_io_node("loop"));
1246 }
1247
1248 #[test]
1249 fn test_is_container_node() {
1250 assert!(is_container_node("loop"));
1251 assert!(is_container_node("group"));
1252 assert!(is_container_node("parallel"));
1253 assert!(!is_container_node("image-compress"));
1254 assert!(!is_container_node("input"));
1255 assert!(!is_container_node("output"));
1256 }
1257
1258 #[test]
1261 fn test_resolve_output_directory_found() {
1262 let def = parse_definition(
1263 r#"{
1264 "nodes": [
1265 { "id": "in", "type": "input", "params": {} },
1266 { "id": "proc", "type": "image-compress", "params": {} },
1267 { "id": "out", "type": "output", "params": { "directory": "{{ctx.date}}-output" } }
1268 ]
1269 }"#,
1270 );
1271 assert_eq!(
1272 resolve_output_directory(&def),
1273 Some("{{ctx.date}}-output".to_string())
1274 );
1275 }
1276
1277 #[test]
1278 fn test_resolve_output_directory_none_when_missing() {
1279 let def = parse_definition(
1280 r#"{
1281 "nodes": [
1282 { "id": "in", "type": "input", "params": {} },
1283 { "id": "out", "type": "output", "params": { "mode": "write" } }
1284 ]
1285 }"#,
1286 );
1287 assert_eq!(resolve_output_directory(&def), None);
1288 }
1289
1290 #[test]
1291 fn test_resolve_output_directory_none_when_empty_string() {
1292 let def = parse_definition(
1293 r#"{
1294 "nodes": [
1295 { "id": "out", "type": "output", "params": { "directory": "" } }
1296 ]
1297 }"#,
1298 );
1299 assert_eq!(resolve_output_directory(&def), None);
1300 }
1301
1302 #[test]
1303 fn test_resolve_output_directory_no_output_node() {
1304 let def = parse_definition(
1305 r#"{
1306 "nodes": [
1307 { "id": "in", "type": "input", "params": {} },
1308 { "id": "proc", "type": "image-compress", "params": {} }
1309 ]
1310 }"#,
1311 );
1312 assert_eq!(resolve_output_directory(&def), None);
1313 }
1314
1315 #[test]
1316 fn test_resolve_output_directory_nested() {
1317 let def = parse_definition(
1318 r#"{
1319 "nodes": [
1320 {
1321 "id": "group-1",
1322 "type": "group",
1323 "params": {},
1324 "children": [
1325 { "id": "out", "type": "output", "params": { "directory": "nested-dir" } }
1326 ]
1327 }
1328 ]
1329 }"#,
1330 );
1331 assert_eq!(
1332 resolve_output_directory(&def),
1333 Some("nested-dir".to_string())
1334 );
1335 }
1336
1337 #[test]
1340 fn test_resolve_output_mode_returns_mode_from_output_node() {
1341 let def = parse_definition(
1342 r#"{
1343 "nodes": [
1344 { "id": "in", "type": "input", "params": {} },
1345 { "id": "out", "type": "output", "params": { "mode": "overwrite" } }
1346 ]
1347 }"#,
1348 );
1349 assert_eq!(resolve_output_mode(&def), "overwrite");
1350 }
1351
1352 #[test]
1353 fn test_resolve_output_mode_defaults_to_write() {
1354 let def = parse_definition(
1355 r#"{
1356 "nodes": [
1357 { "id": "in", "type": "input", "params": {} },
1358 { "id": "proc", "type": "image-compress", "params": {} }
1359 ]
1360 }"#,
1361 );
1362 assert_eq!(resolve_output_mode(&def), "write");
1363 }
1364
1365 #[test]
1366 fn test_resolve_output_mode_defaults_when_no_mode_param() {
1367 let def = parse_definition(
1368 r#"{
1369 "nodes": [
1370 { "id": "out", "type": "output", "params": { "directory": "foo" } }
1371 ]
1372 }"#,
1373 );
1374 assert_eq!(resolve_output_mode(&def), "write");
1375 }
1376
1377 #[test]
1378 fn test_resolve_output_mode_nested_in_container() {
1379 let def = parse_definition(
1380 r#"{
1381 "nodes": [
1382 {
1383 "id": "group-1",
1384 "type": "group",
1385 "params": {},
1386 "children": [
1387 { "id": "out", "type": "output", "params": { "mode": "none" } }
1388 ]
1389 }
1390 ]
1391 }"#,
1392 );
1393 assert_eq!(resolve_output_mode(&def), "none");
1394 }
1395
1396 #[test]
1399 fn test_resolve_input_mode_file_upload() {
1400 let def = parse_definition(
1401 r#"{
1402 "formatVersion": "1.0.0",
1403 "nodes": [
1404 { "id": "in", "type": "input", "params": { "mode": "file-upload" } },
1405 { "id": "proc", "type": "image-compress", "params": {} },
1406 { "id": "out", "type": "output", "params": {} }
1407 ]
1408 }"#,
1409 );
1410 assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1411 }
1412
1413 #[test]
1414 fn test_resolve_input_mode_url() {
1415 let def = parse_definition(
1416 r#"{
1417 "formatVersion": "1.0.0",
1418 "nodes": [
1419 { "id": "in", "type": "input", "params": { "mode": "url" } },
1420 { "id": "proc", "type": "video-download", "params": {} },
1421 { "id": "out", "type": "output", "params": {} }
1422 ]
1423 }"#,
1424 );
1425 assert_eq!(resolve_input_mode(&def), InputMode::Url);
1426 }
1427
1428 #[test]
1429 fn test_resolve_input_mode_text() {
1430 let def = parse_definition(
1431 r#"{
1432 "formatVersion": "1.0.0",
1433 "nodes": [
1434 { "id": "in", "type": "input", "params": { "mode": "text" } },
1435 { "id": "proc", "type": "text-transform", "params": {} },
1436 { "id": "out", "type": "output", "params": {} }
1437 ]
1438 }"#,
1439 );
1440 assert_eq!(resolve_input_mode(&def), InputMode::Text);
1441 }
1442
1443 #[test]
1444 fn test_resolve_input_mode_none() {
1445 let def = parse_definition(
1446 r#"{
1447 "formatVersion": "1.0.0",
1448 "nodes": [
1449 { "id": "in", "type": "input", "params": { "mode": "none" } },
1450 { "id": "proc", "type": "shell-command", "params": {} },
1451 { "id": "out", "type": "output", "params": {} }
1452 ]
1453 }"#,
1454 );
1455 assert_eq!(resolve_input_mode(&def), InputMode::None);
1456 }
1457
1458 #[test]
1459 fn test_resolve_input_mode_none_inside_container() {
1460 let def = parse_definition(
1461 r#"{
1462 "formatVersion": "1.0.0",
1463 "nodes": [
1464 {
1465 "id": "grp",
1466 "type": "group",
1467 "params": {},
1468 "children": [
1469 { "id": "in", "type": "input", "params": { "mode": "none" } },
1470 { "id": "proc", "type": "shell-command", "params": {} }
1471 ]
1472 }
1473 ]
1474 }"#,
1475 );
1476 assert_eq!(resolve_input_mode(&def), InputMode::None);
1477 }
1478
1479 #[test]
1480 fn test_resolve_input_mode_missing_defaults() {
1481 let def = parse_definition(
1482 r#"{
1483 "formatVersion": "1.0.0",
1484 "nodes": [
1485 { "id": "in", "type": "input", "params": {} },
1486 { "id": "proc", "type": "image-compress", "params": {} }
1487 ]
1488 }"#,
1489 );
1490 assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1491 }
1492
1493 #[test]
1494 fn test_resolve_input_mode_no_input_node() {
1495 let def = parse_definition(
1496 r#"{
1497 "formatVersion": "1.0.0",
1498 "nodes": [
1499 { "id": "proc", "type": "image-compress", "params": {} },
1500 { "id": "out", "type": "output", "params": {} }
1501 ]
1502 }"#,
1503 );
1504 assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1505 }
1506
1507 #[test]
1508 fn test_resolve_input_mode_nested() {
1509 let def = parse_definition(
1510 r#"{
1511 "formatVersion": "1.0.0",
1512 "nodes": [
1513 {
1514 "id": "group-1",
1515 "type": "group",
1516 "params": {},
1517 "children": [
1518 { "id": "in", "type": "input", "params": { "mode": "url" } },
1519 { "id": "proc", "type": "video-download", "params": {} }
1520 ]
1521 },
1522 { "id": "out", "type": "output", "params": {} }
1523 ]
1524 }"#,
1525 );
1526 assert_eq!(resolve_input_mode(&def), InputMode::Url);
1527 }
1528
1529 #[test]
1532 fn test_first_processing_node_id_simple() {
1533 let def = parse_definition(
1534 r#"{
1535 "formatVersion": "1.0.0",
1536 "nodes": [
1537 { "id": "in", "type": "input", "params": {} },
1538 { "id": "compress", "type": "image-compress", "params": {} },
1539 { "id": "out", "type": "output", "params": {} }
1540 ]
1541 }"#,
1542 );
1543 assert_eq!(first_processing_node_id(&def), Some("compress".to_string()));
1544 }
1545
1546 #[test]
1547 fn test_first_processing_node_id_none() {
1548 let def = parse_definition(
1549 r#"{
1550 "formatVersion": "1.0.0",
1551 "nodes": [
1552 { "id": "in", "type": "input", "params": {} },
1553 { "id": "out", "type": "output", "params": {} }
1554 ]
1555 }"#,
1556 );
1557 assert_eq!(first_processing_node_id(&def), None);
1558 }
1559
1560 #[test]
1561 fn test_first_processing_node_id_nested() {
1562 let def = parse_definition(
1563 r#"{
1564 "formatVersion": "1.0.0",
1565 "nodes": [
1566 { "id": "in", "type": "input", "params": {} },
1567 {
1568 "id": "loop-1",
1569 "type": "loop",
1570 "params": {},
1571 "children": [
1572 { "id": "resize", "type": "image-resize", "params": {} },
1573 { "id": "compress", "type": "image-compress", "params": {} }
1574 ]
1575 },
1576 { "id": "out", "type": "output", "params": {} }
1577 ]
1578 }"#,
1579 );
1580 assert_eq!(first_processing_node_id(&def), Some("resize".to_string()));
1581 }
1582}