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
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub enum InputMode {
207 #[default]
209 FileUpload,
210 Url,
212 Text,
214}
215
216pub fn resolve_input_mode(def: &PipelineDefinition) -> InputMode {
219 find_input_mode_in_nodes(&def.nodes)
220}
221
222fn find_input_mode_in_nodes(nodes: &[PipelineNode]) -> InputMode {
223 for node in nodes {
224 if node.node_type == "input" {
225 return match node.params.get("mode").and_then(|v| v.as_str()) {
226 Some("url") => InputMode::Url,
227 Some("text") => InputMode::Text,
228 _ => InputMode::FileUpload,
229 };
230 }
231 if let Some(children) = &node.children {
233 let mode = find_input_mode_in_nodes(children);
234 if mode != InputMode::FileUpload {
235 return mode;
236 }
237 if children.iter().any(|c| c.node_type == "input") {
239 return InputMode::FileUpload;
240 }
241 }
242 }
243 InputMode::FileUpload
244}
245
246pub fn first_processing_node_id(def: &PipelineDefinition) -> Option<String> {
249 find_first_processing_in_nodes(&def.nodes)
250}
251
252fn find_first_processing_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
253 for node in nodes {
254 if is_io_node(&node.node_type) {
255 continue;
256 }
257 if is_container_node(&node.node_type) {
258 if let Some(children) = &node.children
260 && let Some(id) = find_first_processing_in_nodes(children)
261 {
262 return Some(id);
263 }
264 continue;
265 }
266 return Some(node.id.clone());
268 }
269 None
270}
271
272pub fn resolve_output_directory(def: &PipelineDefinition) -> Option<String> {
282 find_output_directory_in_nodes(&def.nodes)
283}
284
285fn find_output_directory_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
286 for node in nodes {
287 if node.node_type == "output" {
288 let dir = node
289 .params
290 .get("directory")
291 .and_then(|v| v.as_str())
292 .unwrap_or("");
293 return if dir.is_empty() {
294 None
295 } else {
296 Some(dir.to_string())
297 };
298 }
299 if let Some(children) = &node.children
301 && let Some(dir) = find_output_directory_in_nodes(children)
302 {
303 return Some(dir);
304 }
305 }
306 None
307}
308
309pub fn resolve_output_mode(def: &PipelineDefinition) -> String {
318 find_output_mode_in_nodes(&def.nodes).unwrap_or_else(|| "write".to_string())
319}
320
321fn find_output_mode_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
322 for node in nodes {
323 if node.node_type == "output" {
324 let mode = node
325 .params
326 .get("mode")
327 .and_then(|v| v.as_str())
328 .unwrap_or("write");
329 return if mode.is_empty() {
330 Some("write".to_string())
331 } else {
332 Some(mode.to_string())
333 };
334 }
335 if let Some(children) = &node.children
337 && let Some(mode) = find_output_mode_in_nodes(children)
338 {
339 return Some(mode);
340 }
341 }
342 None
343}
344
345pub fn is_io_node(node_type: &str) -> bool {
352 node_type == "input" || node_type == "output"
353}
354
355pub fn is_container_node(node_type: &str) -> bool {
358 node_type == "loop" || node_type == "group" || node_type == "parallel"
359}
360
361#[cfg(test)]
366mod tests {
367 use super::*;
368
369 fn parse_definition(json: &str) -> PipelineDefinition {
370 serde_json::from_str(json).unwrap()
371 }
372
373 #[test]
377 fn test_simple_definition_deserializes() {
378 let json = r#"{
380 "nodes": [
381 { "id": "n1", "type": "input" },
382 { "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
383 { "id": "n3", "type": "output" }
384 ]
385 }"#;
386
387 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
388
389 assert_eq!(def.nodes.len(), 3);
390 assert_eq!(def.nodes[0].id, "n1");
391 assert_eq!(def.nodes[0].node_type, "input");
392 assert_eq!(def.nodes[1].id, "n2");
393 assert_eq!(def.nodes[1].node_type, "image-compress");
394 assert_eq!(def.nodes[2].id, "n3");
395 assert_eq!(def.nodes[2].node_type, "output");
396 }
397
398 #[test]
399 fn test_params_deserialize_correctly() {
400 let json = r#"{
401 "nodes": [
402 {
403 "id": "n1",
404 "type": "image-compress",
405 "params": {
406 "quality": 80,
407 "preserveExif": true
408 }
409 }
410 ]
411 }"#;
412
413 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
414 let params = &def.nodes[0].params;
415
416 assert_eq!(params["quality"], 80);
417 assert_eq!(params["preserveExif"], true);
418 }
419
420 #[test]
421 fn test_missing_params_defaults_to_empty() {
422 let json = r#"{
424 "nodes": [
425 { "id": "n1", "type": "input" }
426 ]
427 }"#;
428
429 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
430 assert!(def.nodes[0].params.is_empty());
431 }
432
433 #[test]
434 fn test_container_node_with_children() {
435 let json = r#"{
437 "nodes": [
438 {
439 "id": "loop-1",
440 "type": "loop",
441 "children": [
442 { "id": "child-1", "type": "image-compress" }
443 ]
444 }
445 ]
446 }"#;
447
448 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
449 let loop_node = &def.nodes[0];
450
451 assert_eq!(loop_node.node_type, "loop");
452 let children = loop_node.children.as_ref().unwrap();
453 assert_eq!(children.len(), 1);
454 assert_eq!(children[0].node_type, "image-compress");
455 }
456
457 #[test]
458 fn test_no_children_is_none() {
459 let json = r#"{
460 "nodes": [
461 { "id": "n1", "type": "image-compress" }
462 ]
463 }"#;
464
465 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
466 assert!(def.nodes[0].children.is_none());
467 }
468
469 #[test]
470 fn test_nested_containers() {
471 let json = r#"{
473 "nodes": [
474 {
475 "id": "group-1",
476 "type": "group",
477 "children": [
478 {
479 "id": "loop-1",
480 "type": "loop",
481 "children": [
482 { "id": "proc-1", "type": "image-compress" }
483 ]
484 }
485 ]
486 }
487 ]
488 }"#;
489
490 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
491 let group = &def.nodes[0];
492 let loop_node = &group.children.as_ref().unwrap()[0];
493 let proc_node = &loop_node.children.as_ref().unwrap()[0];
494
495 assert_eq!(group.node_type, "group");
496 assert_eq!(loop_node.node_type, "loop");
497 assert_eq!(proc_node.node_type, "image-compress");
498 }
499
500 #[test]
505 fn test_nodes_alias_deserializes_as_children() {
506 let json = r#"{
509 "nodes": [
510 {
511 "id": "loop-1",
512 "type": "loop",
513 "nodes": [
514 { "id": "child-1", "type": "image-compress" }
515 ]
516 }
517 ]
518 }"#;
519
520 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
521 let loop_node = &def.nodes[0];
522 let children = loop_node.children.as_ref().unwrap();
523
524 assert_eq!(children.len(), 1);
525 assert_eq!(children[0].id, "child-1");
526 assert_eq!(children[0].node_type, "image-compress");
527 }
528
529 #[test]
530 fn test_parameters_alias_deserializes_as_params() {
531 let json = r#"{
534 "nodes": [
535 {
536 "id": "n1",
537 "type": "image-compress",
538 "parameters": { "quality": 80 }
539 }
540 ]
541 }"#;
542
543 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
544 let params = &def.nodes[0].params;
545
546 assert_eq!(params["quality"], 80);
547 }
548
549 #[test]
550 fn test_both_aliases_together() {
551 let json = r#"{
553 "nodes": [
554 {
555 "id": "loop-1",
556 "type": "loop",
557 "parameters": { "mode": "forEach" },
558 "nodes": [
559 {
560 "id": "child-1",
561 "type": "image-compress",
562 "parameters": { "quality": 75 }
563 }
564 ]
565 }
566 ]
567 }"#;
568
569 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
570 let loop_node = &def.nodes[0];
571
572 assert_eq!(loop_node.params["mode"], "forEach");
574
575 let children = loop_node.children.as_ref().unwrap();
577 assert_eq!(children.len(), 1);
578 assert_eq!(children[0].params["quality"], 75);
579 }
580
581 #[test]
582 fn test_original_field_names_still_work() {
583 let json = r#"{
585 "nodes": [
586 {
587 "id": "loop-1",
588 "type": "loop",
589 "params": { "mode": "forEach" },
590 "children": [
591 { "id": "child-1", "type": "image-compress" }
592 ]
593 }
594 ]
595 }"#;
596
597 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
598 let loop_node = &def.nodes[0];
599
600 assert_eq!(loop_node.params["mode"], "forEach");
601 assert_eq!(loop_node.children.as_ref().unwrap().len(), 1);
602 }
603
604 #[test]
605 fn test_unknown_fields_silently_ignored() {
606 let json = r#"{
610 "nodes": [
611 {
612 "id": "compress-image",
613 "type": "image-compress",
614 "version": "1.0.0",
615 "name": "Compress Image",
616 "position": { "x": 100, "y": 100 },
617 "metadata": { "description": "Compresses images" },
618 "parameters": { "quality": 80 },
619 "inputPorts": [{ "id": "in-1", "name": "files" }],
620 "outputPorts": [{ "id": "out-1", "name": "files" }]
621 }
622 ],
623 "edges": [{ "id": "e1", "source": "input", "target": "compress-image" }]
624 }"#;
625
626 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
627 assert_eq!(def.nodes.len(), 1);
628 assert_eq!(def.nodes[0].id, "compress-image");
629 assert_eq!(def.nodes[0].params["quality"], 80);
630 }
631
632 #[test]
637 fn test_compress_images_recipe_deserializes() {
638 let json = r#"{
640 "nodes": [
641 {
642 "id": "input", "type": "input", "version": "1.0.0",
643 "name": "Input Files", "position": {"x": 0, "y": 100},
644 "metadata": {},
645 "parameters": { "mode": "file-upload", "accept": ["image/jpeg"] },
646 "inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
647 },
648 {
649 "id": "batch-compress", "type": "group", "version": "1.0.0",
650 "name": "Batch Compress", "position": {"x": 250, "y": 100},
651 "metadata": { "description": "Reusable sub-recipe." },
652 "parameters": {},
653 "inputPorts": [{"id": "in-1", "name": "files"}],
654 "outputPorts": [{"id": "out-1", "name": "files"}],
655 "nodes": [
656 {
657 "id": "compress-loop", "type": "loop", "version": "1.0.0",
658 "name": "Compress Each Image", "position": {"x": 0, "y": 0},
659 "metadata": {},
660 "parameters": { "mode": "forEach" },
661 "inputPorts": [{"id": "in-1", "name": "items"}], "outputPorts": [],
662 "nodes": [
663 {
664 "id": "compress-image", "type": "image-compress", "version": "1.0.0",
665 "name": "Compress Image", "position": {"x": 0, "y": 0},
666 "metadata": {},
667 "parameters": { "quality": 80 },
668 "inputPorts": [], "outputPorts": []
669 }
670 ],
671 "edges": []
672 }
673 ],
674 "edges": []
675 },
676 {
677 "id": "output", "type": "output", "version": "1.0.0",
678 "name": "Compressed Images", "position": {"x": 500, "y": 100},
679 "metadata": {},
680 "parameters": { "mode": "write", "zip": true },
681 "inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
682 }
683 ],
684 "edges": [
685 {"id": "e1", "source": "input", "target": "batch-compress"},
686 {"id": "e2", "source": "batch-compress", "target": "output"}
687 ]
688 }"#;
689
690 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
691
692 assert_eq!(def.nodes.len(), 3);
694 assert_eq!(def.nodes[0].node_type, "input");
695 assert_eq!(def.nodes[1].node_type, "group");
696 assert_eq!(def.nodes[1].id, "batch-compress");
697 assert_eq!(def.nodes[2].node_type, "output");
698
699 let group_children = def.nodes[1].children.as_ref().unwrap();
701 assert_eq!(group_children.len(), 1);
702 assert_eq!(group_children[0].node_type, "loop");
703
704 let loop_children = group_children[0].children.as_ref().unwrap();
706 assert_eq!(loop_children.len(), 1);
707 assert_eq!(loop_children[0].id, "compress-image");
708 assert_eq!(loop_children[0].node_type, "image-compress");
709 assert_eq!(loop_children[0].params["quality"], 80);
710 }
711
712 #[test]
713 fn test_clean_csv_recipe_deserializes() {
714 let json = r#"{
716 "nodes": [
717 {
718 "id": "input", "type": "input", "version": "1.0.0",
719 "name": "Input Files", "position": {"x": 0, "y": 100},
720 "metadata": {},
721 "parameters": { "mode": "file-upload" },
722 "inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
723 },
724 {
725 "id": "csv-cleaner", "type": "group", "version": "1.0.0",
726 "name": "CSV Cleaner", "position": {"x": 250, "y": 100},
727 "metadata": {},
728 "parameters": {},
729 "inputPorts": [{"id": "in-1", "name": "files"}],
730 "outputPorts": [{"id": "out-1", "name": "files"}],
731 "nodes": [
732 {
733 "id": "clean", "type": "spreadsheet-clean", "version": "1.0.0",
734 "name": "Clean CSV", "position": {"x": 0, "y": 0},
735 "metadata": {},
736 "parameters": {
737 "trimWhitespace": true,
738 "removeEmptyRows": true,
739 "removeDuplicates": true
740 },
741 "inputPorts": [{"id": "in-1", "name": "files"}],
742 "outputPorts": [{"id": "out-1", "name": "files"}]
743 }
744 ],
745 "edges": []
746 },
747 {
748 "id": "output", "type": "output", "version": "1.0.0",
749 "name": "Cleaned CSV", "position": {"x": 500, "y": 100},
750 "metadata": {},
751 "parameters": { "mode": "write" },
752 "inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
753 }
754 ],
755 "edges": [
756 {"id": "e1", "source": "input", "target": "csv-cleaner"},
757 {"id": "e2", "source": "csv-cleaner", "target": "output"}
758 ]
759 }"#;
760
761 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
762
763 assert_eq!(def.nodes.len(), 3);
764 assert_eq!(def.nodes[1].node_type, "group");
766 assert_eq!(def.nodes[1].id, "csv-cleaner");
767
768 let group_children = def.nodes[1].children.as_ref().unwrap();
770 assert_eq!(group_children.len(), 1);
771 assert_eq!(group_children[0].node_type, "spreadsheet-clean");
772 }
773
774 #[test]
775 fn test_rename_files_recipe_deserializes() {
776 let json = r#"{
778 "nodes": [
779 { "id": "input", "type": "input", "version": "1.0.0",
780 "name": "Input", "position": {"x": 0, "y": 0}, "metadata": {},
781 "parameters": {}, "inputPorts": [], "outputPorts": [] },
782 {
783 "id": "batch-rename", "type": "group", "version": "1.0.0",
784 "name": "Batch Rename", "position": {"x": 250, "y": 100},
785 "metadata": {},
786 "parameters": {},
787 "inputPorts": [], "outputPorts": [],
788 "nodes": [
789 {
790 "id": "rename-loop", "type": "loop", "version": "1.0.0",
791 "name": "Rename Each File", "position": {"x": 0, "y": 0},
792 "metadata": {},
793 "parameters": { "mode": "forEach" },
794 "inputPorts": [], "outputPorts": [],
795 "nodes": [
796 {
797 "id": "rename-file", "type": "file-rename", "version": "1.0.0",
798 "name": "Rename File", "position": {"x": 0, "y": 0},
799 "metadata": {},
800 "parameters": { "prefix": "renamed-" },
801 "inputPorts": [], "outputPorts": []
802 }
803 ],
804 "edges": []
805 }
806 ],
807 "edges": []
808 },
809 { "id": "output", "type": "output", "version": "1.0.0",
810 "name": "Output", "position": {"x": 0, "y": 0}, "metadata": {},
811 "parameters": {}, "inputPorts": [], "outputPorts": [] }
812 ],
813 "edges": []
814 }"#;
815
816 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
817
818 let group_node = &def.nodes[1];
820 assert_eq!(group_node.node_type, "group");
821 assert_eq!(group_node.id, "batch-rename");
822
823 let group_children = group_node.children.as_ref().unwrap();
825 assert_eq!(group_children.len(), 1);
826 assert_eq!(group_children[0].node_type, "loop");
827
828 let loop_children = group_children[0].children.as_ref().unwrap();
830 assert_eq!(loop_children.len(), 1);
831 assert_eq!(loop_children[0].node_type, "file-rename");
832 assert_eq!(loop_children[0].params["prefix"], "renamed-");
833 }
834
835 #[test]
836 fn test_deeply_nested_three_levels() {
837 let json = r#"{
840 "nodes": [
841 {
842 "id": "outer-group", "type": "group",
843 "parameters": {},
844 "nodes": [
845 {
846 "id": "inner-group", "type": "group",
847 "parameters": {},
848 "nodes": [
849 {
850 "id": "the-loop", "type": "loop",
851 "parameters": { "mode": "forEach" },
852 "nodes": [
853 {
854 "id": "processor", "type": "image-compress",
855 "parameters": { "quality": 50 }
856 }
857 ]
858 }
859 ]
860 }
861 ]
862 }
863 ]
864 }"#;
865
866 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
867
868 let outer = &def.nodes[0];
870 assert_eq!(outer.node_type, "group");
871
872 let inner = &outer.children.as_ref().unwrap()[0];
873 assert_eq!(inner.node_type, "group");
874
875 let loop_node = &inner.children.as_ref().unwrap()[0];
876 assert_eq!(loop_node.node_type, "loop");
877
878 let processor = &loop_node.children.as_ref().unwrap()[0];
879 assert_eq!(processor.node_type, "image-compress");
880 assert_eq!(processor.params["quality"], 50);
881 }
882
883 #[test]
886 fn test_definition_without_requires_still_parses() {
887 let json = r#"{
889 "nodes": [
890 { "id": "n1", "type": "input" },
891 { "id": "n2", "type": "image-compress" }
892 ]
893 }"#;
894 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
895 assert!(def.requires.is_empty());
896 }
897
898 #[test]
899 fn test_definition_with_requires_parses() {
900 let json = r#"{
901 "requires": [
902 {
903 "binary": "yt-dlp",
904 "installHint": "brew install yt-dlp",
905 "homepage": "https://github.com/yt-dlp/yt-dlp"
906 }
907 ],
908 "nodes": [
909 { "id": "n1", "type": "input" }
910 ]
911 }"#;
912 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
913 assert_eq!(def.requires.len(), 1);
914 assert_eq!(def.requires[0].binary, "yt-dlp");
915 assert_eq!(def.requires[0].install_hint, "brew install yt-dlp");
916 }
917
918 #[test]
919 fn test_definition_with_multiple_requires() {
920 let json = r#"{
921 "requires": [
922 { "binary": "yt-dlp", "installHint": "brew install yt-dlp" },
923 { "binary": "ffmpeg", "installHint": "brew install ffmpeg", "version": ">=6.0" }
924 ],
925 "nodes": [{ "id": "n1", "type": "input" }]
926 }"#;
927 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
928 assert_eq!(def.requires.len(), 2);
929 assert_eq!(def.requires[0].binary, "yt-dlp");
930 assert_eq!(def.requires[1].binary, "ffmpeg");
931 assert_eq!(def.requires[1].version, ">=6.0");
932 }
933
934 #[test]
935 fn test_definition_empty_requires_omitted_in_serialization() {
936 let json = r#"{ "nodes": [{ "id": "n1", "type": "input" }] }"#;
938 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
939 let serialized = serde_json::to_string(&def).unwrap();
940 assert!(
941 !serialized.contains("requires"),
942 "Empty requires should be omitted; got: {serialized}"
943 );
944 }
945
946 #[test]
947 fn test_definition_requires_round_trip() {
948 let json = r#"{
949 "requires": [
950 {
951 "binary": "yt-dlp",
952 "version": ">=2024.0.0",
953 "installHint": "brew install yt-dlp",
954 "homepage": "https://github.com/yt-dlp/yt-dlp"
955 }
956 ],
957 "nodes": [{ "id": "n1", "type": "input" }]
958 }"#;
959 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
960 let serialized = serde_json::to_string(&def).unwrap();
961 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
962 assert_eq!(round_tripped.requires.len(), 1);
963 assert_eq!(round_tripped.requires[0].binary, "yt-dlp");
964 assert_eq!(round_tripped.requires[0].version, ">=2024.0.0");
965 assert_eq!(
966 round_tripped.requires[0].install_hint,
967 "brew install yt-dlp"
968 );
969 assert_eq!(
970 round_tripped.requires[0].homepage,
971 "https://github.com/yt-dlp/yt-dlp"
972 );
973 }
974
975 #[test]
976 fn test_definition_requires_preserves_all_dependency_fields() {
977 let json = r#"{
979 "requires": [
980 {
981 "binary": "ffmpeg",
982 "version": ">=6.0",
983 "installHint": "brew install ffmpeg",
984 "homepage": "https://ffmpeg.org"
985 }
986 ],
987 "nodes": []
988 }"#;
989 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
990 let dep = &def.requires[0];
991 assert_eq!(dep.binary, "ffmpeg");
992 assert_eq!(dep.version, ">=6.0");
993 assert_eq!(dep.install_hint, "brew install ffmpeg");
994 assert_eq!(dep.homepage, "https://ffmpeg.org");
995 }
996
997 #[test]
1000 fn test_definition_without_secrets_still_parses() {
1001 let json = r#"{
1002 "nodes": [{ "id": "n1", "type": "input" }]
1003 }"#;
1004 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1005 assert!(def.secrets.is_empty());
1006 }
1007
1008 #[test]
1009 fn test_definition_with_secrets_parses() {
1010 let json = r#"{
1011 "secrets": [
1012 { "key": "OPENAI_API_KEY", "description": "OpenAI API key", "required": true }
1013 ],
1014 "nodes": [{ "id": "n1", "type": "input" }]
1015 }"#;
1016 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1017 assert_eq!(def.secrets.len(), 1);
1018 assert_eq!(def.secrets[0].key, "OPENAI_API_KEY");
1019 assert!(def.secrets[0].required);
1020 }
1021
1022 #[test]
1023 fn test_definition_secrets_defaults_required_true() {
1024 let json = r#"{
1025 "secrets": [{ "key": "API_KEY" }],
1026 "nodes": [{ "id": "n1", "type": "input" }]
1027 }"#;
1028 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1029 assert!(def.secrets[0].required);
1030 assert!(def.secrets[0].description.is_empty());
1031 }
1032
1033 #[test]
1034 fn test_definition_empty_secrets_omitted_in_serialization() {
1035 let json = r#"{ "nodes": [{ "id": "n1", "type": "input" }] }"#;
1036 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1037 let serialized = serde_json::to_string(&def).unwrap();
1038 assert!(
1039 !serialized.contains("secrets"),
1040 "Empty secrets should be omitted; got: {serialized}"
1041 );
1042 }
1043
1044 #[test]
1045 fn test_definition_secrets_round_trip() {
1046 let json = r#"{
1047 "secrets": [
1048 { "key": "API_KEY", "description": "Test key", "required": true },
1049 { "key": "OPTIONAL", "required": false }
1050 ],
1051 "nodes": [{ "id": "n1", "type": "input" }]
1052 }"#;
1053 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1054 let serialized = serde_json::to_string(&def).unwrap();
1055 let rt: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
1056 assert_eq!(rt.secrets.len(), 2);
1057 assert_eq!(rt.secrets[0].key, "API_KEY");
1058 assert!(rt.secrets[0].required);
1059 assert_eq!(rt.secrets[1].key, "OPTIONAL");
1060 assert!(!rt.secrets[1].required);
1061 }
1062
1063 #[test]
1068 fn test_definition_without_settings_deserializes() {
1069 let json = r#"{
1070 "nodes": [
1071 { "id": "n1", "type": "input" },
1072 { "id": "n2", "type": "image-compress" },
1073 { "id": "n3", "type": "output" }
1074 ]
1075 }"#;
1076 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1077 assert!(def.settings.is_none());
1078 }
1079
1080 #[test]
1081 fn test_definition_with_auto_iteration_deserializes() {
1082 let json = r#"{
1083 "settings": { "iteration": "auto" },
1084 "nodes": [
1085 { "id": "n1", "type": "input" },
1086 { "id": "n2", "type": "image-compress" },
1087 { "id": "n3", "type": "output" }
1088 ]
1089 }"#;
1090 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1091 let settings = def.settings.as_ref().unwrap();
1092 assert_eq!(settings.iteration, IterationMode::Auto);
1093 }
1094
1095 #[test]
1096 fn test_definition_with_explicit_iteration_deserializes() {
1097 let json = r#"{
1098 "settings": { "iteration": "explicit" },
1099 "nodes": [
1100 { "id": "n1", "type": "image-compress" }
1101 ]
1102 }"#;
1103 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1104 let settings = def.settings.as_ref().unwrap();
1105 assert_eq!(settings.iteration, IterationMode::Explicit);
1106 }
1107
1108 #[test]
1109 fn test_definition_with_unknown_iteration_fails() {
1110 let json = r#"{
1111 "settings": { "iteration": "garbage" },
1112 "nodes": [{ "id": "n1", "type": "input" }]
1113 }"#;
1114 let result = serde_json::from_str::<PipelineDefinition>(json);
1115 assert!(result.is_err());
1116 }
1117
1118 #[test]
1119 fn test_resolved_iteration_defaults_explicit() {
1120 let json = r#"{ "nodes": [] }"#;
1121 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1122 assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
1123 }
1124
1125 #[test]
1126 fn test_resolved_iteration_returns_auto() {
1127 let json = r#"{
1128 "settings": { "iteration": "auto" },
1129 "nodes": []
1130 }"#;
1131 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1132 assert_eq!(def.resolved_iteration(), IterationMode::Auto);
1133 }
1134
1135 #[test]
1136 fn test_settings_with_default_iteration_field() {
1137 let json = r#"{
1139 "settings": {},
1140 "nodes": []
1141 }"#;
1142 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1143 let settings = def.settings.as_ref().unwrap();
1144 assert_eq!(settings.iteration, IterationMode::Explicit);
1145 assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
1146 }
1147
1148 #[test]
1152 fn test_definition_round_trip_serialization() {
1153 let json = r#"{
1154 "nodes": [
1155 { "id": "n1", "type": "input" },
1156 { "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
1157 { "id": "n3", "type": "output" }
1158 ],
1159 "settings": { "iteration": "auto" }
1160 }"#;
1161
1162 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1163 let serialized = serde_json::to_string(&def).unwrap();
1164 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
1165
1166 assert_eq!(round_tripped.nodes.len(), def.nodes.len());
1167 for (orig, rt) in def.nodes.iter().zip(round_tripped.nodes.iter()) {
1168 assert_eq!(orig.id, rt.id);
1169 assert_eq!(orig.node_type, rt.node_type);
1170 }
1171 assert_eq!(round_tripped.resolved_iteration(), def.resolved_iteration());
1172 }
1173
1174 #[test]
1175 fn test_definition_serialization_preserves_params() {
1176 let json = r#"{
1177 "nodes": [
1178 {
1179 "id": "n1",
1180 "type": "image-compress",
1181 "params": { "quality": 80, "preserveExif": true, "name": "test" }
1182 }
1183 ]
1184 }"#;
1185
1186 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1187 let serialized = serde_json::to_string(&def).unwrap();
1188 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
1189
1190 let params = &round_tripped.nodes[0].params;
1191 assert_eq!(params["quality"], 80);
1192 assert_eq!(params["preserveExif"], true);
1193 assert_eq!(params["name"], "test");
1194 }
1195
1196 #[test]
1197 fn test_definition_serialization_preserves_children() {
1198 let json = r#"{
1199 "nodes": [
1200 {
1201 "id": "group-1",
1202 "type": "group",
1203 "children": [
1204 {
1205 "id": "loop-1",
1206 "type": "loop",
1207 "children": [
1208 { "id": "proc-1", "type": "image-compress", "params": { "quality": 50 } }
1209 ]
1210 }
1211 ]
1212 }
1213 ]
1214 }"#;
1215
1216 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
1217 let serialized = serde_json::to_string(&def).unwrap();
1218 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
1219
1220 let group = &round_tripped.nodes[0];
1221 assert_eq!(group.node_type, "group");
1222 let loop_node = &group.children.as_ref().unwrap()[0];
1223 assert_eq!(loop_node.node_type, "loop");
1224 let proc_node = &loop_node.children.as_ref().unwrap()[0];
1225 assert_eq!(proc_node.node_type, "image-compress");
1226 assert_eq!(proc_node.params["quality"], 50);
1227 }
1228
1229 #[test]
1230 fn test_iteration_mode_serializes_camel_case() {
1231 let auto_json = serde_json::to_string(&IterationMode::Auto).unwrap();
1232 assert_eq!(auto_json, r#""auto""#);
1233
1234 let explicit_json = serde_json::to_string(&IterationMode::Explicit).unwrap();
1235 assert_eq!(explicit_json, r#""explicit""#);
1236 }
1237
1238 #[test]
1239 fn test_pipeline_settings_serializes() {
1240 let settings = PipelineSettings {
1241 iteration: IterationMode::Auto,
1242 };
1243 let json = serde_json::to_string(&settings).unwrap();
1244 assert!(json.contains(r#""iteration":"auto""#));
1245 }
1246
1247 #[test]
1250 fn test_is_io_node() {
1251 assert!(is_io_node("input"));
1252 assert!(is_io_node("output"));
1253 assert!(!is_io_node("image-compress"));
1254 assert!(!is_io_node("spreadsheet-clean"));
1255 assert!(!is_io_node("loop"));
1256 }
1257
1258 #[test]
1259 fn test_is_container_node() {
1260 assert!(is_container_node("loop"));
1261 assert!(is_container_node("group"));
1262 assert!(is_container_node("parallel"));
1263 assert!(!is_container_node("image-compress"));
1264 assert!(!is_container_node("input"));
1265 assert!(!is_container_node("output"));
1266 }
1267
1268 #[test]
1271 fn test_resolve_output_directory_found() {
1272 let def = parse_definition(
1273 r#"{
1274 "nodes": [
1275 { "id": "in", "type": "input", "params": {} },
1276 { "id": "proc", "type": "image-compress", "params": {} },
1277 { "id": "out", "type": "output", "params": { "directory": "{{ctx.date}}-output" } }
1278 ]
1279 }"#,
1280 );
1281 assert_eq!(
1282 resolve_output_directory(&def),
1283 Some("{{ctx.date}}-output".to_string())
1284 );
1285 }
1286
1287 #[test]
1288 fn test_resolve_output_directory_none_when_missing() {
1289 let def = parse_definition(
1290 r#"{
1291 "nodes": [
1292 { "id": "in", "type": "input", "params": {} },
1293 { "id": "out", "type": "output", "params": { "mode": "write" } }
1294 ]
1295 }"#,
1296 );
1297 assert_eq!(resolve_output_directory(&def), None);
1298 }
1299
1300 #[test]
1301 fn test_resolve_output_directory_none_when_empty_string() {
1302 let def = parse_definition(
1303 r#"{
1304 "nodes": [
1305 { "id": "out", "type": "output", "params": { "directory": "" } }
1306 ]
1307 }"#,
1308 );
1309 assert_eq!(resolve_output_directory(&def), None);
1310 }
1311
1312 #[test]
1313 fn test_resolve_output_directory_no_output_node() {
1314 let def = parse_definition(
1315 r#"{
1316 "nodes": [
1317 { "id": "in", "type": "input", "params": {} },
1318 { "id": "proc", "type": "image-compress", "params": {} }
1319 ]
1320 }"#,
1321 );
1322 assert_eq!(resolve_output_directory(&def), None);
1323 }
1324
1325 #[test]
1326 fn test_resolve_output_directory_nested() {
1327 let def = parse_definition(
1328 r#"{
1329 "nodes": [
1330 {
1331 "id": "group-1",
1332 "type": "group",
1333 "params": {},
1334 "children": [
1335 { "id": "out", "type": "output", "params": { "directory": "nested-dir" } }
1336 ]
1337 }
1338 ]
1339 }"#,
1340 );
1341 assert_eq!(
1342 resolve_output_directory(&def),
1343 Some("nested-dir".to_string())
1344 );
1345 }
1346
1347 #[test]
1350 fn test_resolve_output_mode_returns_mode_from_output_node() {
1351 let def = parse_definition(
1352 r#"{
1353 "nodes": [
1354 { "id": "in", "type": "input", "params": {} },
1355 { "id": "out", "type": "output", "params": { "mode": "overwrite" } }
1356 ]
1357 }"#,
1358 );
1359 assert_eq!(resolve_output_mode(&def), "overwrite");
1360 }
1361
1362 #[test]
1363 fn test_resolve_output_mode_defaults_to_write() {
1364 let def = parse_definition(
1365 r#"{
1366 "nodes": [
1367 { "id": "in", "type": "input", "params": {} },
1368 { "id": "proc", "type": "image-compress", "params": {} }
1369 ]
1370 }"#,
1371 );
1372 assert_eq!(resolve_output_mode(&def), "write");
1373 }
1374
1375 #[test]
1376 fn test_resolve_output_mode_defaults_when_no_mode_param() {
1377 let def = parse_definition(
1378 r#"{
1379 "nodes": [
1380 { "id": "out", "type": "output", "params": { "directory": "foo" } }
1381 ]
1382 }"#,
1383 );
1384 assert_eq!(resolve_output_mode(&def), "write");
1385 }
1386
1387 #[test]
1388 fn test_resolve_output_mode_nested_in_container() {
1389 let def = parse_definition(
1390 r#"{
1391 "nodes": [
1392 {
1393 "id": "group-1",
1394 "type": "group",
1395 "params": {},
1396 "children": [
1397 { "id": "out", "type": "output", "params": { "mode": "none" } }
1398 ]
1399 }
1400 ]
1401 }"#,
1402 );
1403 assert_eq!(resolve_output_mode(&def), "none");
1404 }
1405
1406 #[test]
1409 fn test_resolve_input_mode_file_upload() {
1410 let def = parse_definition(
1411 r#"{
1412 "formatVersion": "1.0.0",
1413 "nodes": [
1414 { "id": "in", "type": "input", "params": { "mode": "file-upload" } },
1415 { "id": "proc", "type": "image-compress", "params": {} },
1416 { "id": "out", "type": "output", "params": {} }
1417 ]
1418 }"#,
1419 );
1420 assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1421 }
1422
1423 #[test]
1424 fn test_resolve_input_mode_url() {
1425 let def = parse_definition(
1426 r#"{
1427 "formatVersion": "1.0.0",
1428 "nodes": [
1429 { "id": "in", "type": "input", "params": { "mode": "url" } },
1430 { "id": "proc", "type": "video-download", "params": {} },
1431 { "id": "out", "type": "output", "params": {} }
1432 ]
1433 }"#,
1434 );
1435 assert_eq!(resolve_input_mode(&def), InputMode::Url);
1436 }
1437
1438 #[test]
1439 fn test_resolve_input_mode_text() {
1440 let def = parse_definition(
1441 r#"{
1442 "formatVersion": "1.0.0",
1443 "nodes": [
1444 { "id": "in", "type": "input", "params": { "mode": "text" } },
1445 { "id": "proc", "type": "text-transform", "params": {} },
1446 { "id": "out", "type": "output", "params": {} }
1447 ]
1448 }"#,
1449 );
1450 assert_eq!(resolve_input_mode(&def), InputMode::Text);
1451 }
1452
1453 #[test]
1454 fn test_resolve_input_mode_missing_defaults() {
1455 let def = parse_definition(
1456 r#"{
1457 "formatVersion": "1.0.0",
1458 "nodes": [
1459 { "id": "in", "type": "input", "params": {} },
1460 { "id": "proc", "type": "image-compress", "params": {} }
1461 ]
1462 }"#,
1463 );
1464 assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1465 }
1466
1467 #[test]
1468 fn test_resolve_input_mode_no_input_node() {
1469 let def = parse_definition(
1470 r#"{
1471 "formatVersion": "1.0.0",
1472 "nodes": [
1473 { "id": "proc", "type": "image-compress", "params": {} },
1474 { "id": "out", "type": "output", "params": {} }
1475 ]
1476 }"#,
1477 );
1478 assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1479 }
1480
1481 #[test]
1482 fn test_resolve_input_mode_nested() {
1483 let def = parse_definition(
1484 r#"{
1485 "formatVersion": "1.0.0",
1486 "nodes": [
1487 {
1488 "id": "group-1",
1489 "type": "group",
1490 "params": {},
1491 "children": [
1492 { "id": "in", "type": "input", "params": { "mode": "url" } },
1493 { "id": "proc", "type": "video-download", "params": {} }
1494 ]
1495 },
1496 { "id": "out", "type": "output", "params": {} }
1497 ]
1498 }"#,
1499 );
1500 assert_eq!(resolve_input_mode(&def), InputMode::Url);
1501 }
1502
1503 #[test]
1506 fn test_first_processing_node_id_simple() {
1507 let def = parse_definition(
1508 r#"{
1509 "formatVersion": "1.0.0",
1510 "nodes": [
1511 { "id": "in", "type": "input", "params": {} },
1512 { "id": "compress", "type": "image-compress", "params": {} },
1513 { "id": "out", "type": "output", "params": {} }
1514 ]
1515 }"#,
1516 );
1517 assert_eq!(first_processing_node_id(&def), Some("compress".to_string()));
1518 }
1519
1520 #[test]
1521 fn test_first_processing_node_id_none() {
1522 let def = parse_definition(
1523 r#"{
1524 "formatVersion": "1.0.0",
1525 "nodes": [
1526 { "id": "in", "type": "input", "params": {} },
1527 { "id": "out", "type": "output", "params": {} }
1528 ]
1529 }"#,
1530 );
1531 assert_eq!(first_processing_node_id(&def), None);
1532 }
1533
1534 #[test]
1535 fn test_first_processing_node_id_nested() {
1536 let def = parse_definition(
1537 r#"{
1538 "formatVersion": "1.0.0",
1539 "nodes": [
1540 { "id": "in", "type": "input", "params": {} },
1541 {
1542 "id": "loop-1",
1543 "type": "loop",
1544 "params": {},
1545 "children": [
1546 { "id": "resize", "type": "image-resize", "params": {} },
1547 { "id": "compress", "type": "image-compress", "params": {} }
1548 ]
1549 },
1550 { "id": "out", "type": "output", "params": {} }
1551 ]
1552 }"#,
1553 );
1554 assert_eq!(first_processing_node_id(&def), Some("resize".to_string()));
1555 }
1556}