1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "camelCase")]
15pub enum IterationMode {
16 #[default]
19 Explicit,
20 Auto,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28#[serde(rename_all = "camelCase")]
29pub struct PipelineSettings {
30 #[serde(default)]
32 pub iteration: IterationMode,
33}
34
35#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct PipelineDefinition {
42 pub nodes: Vec<PipelineNode>,
45
46 #[serde(default)]
49 pub settings: Option<PipelineSettings>,
50}
51
52impl PipelineDefinition {
53 pub fn resolved_iteration(&self) -> IterationMode {
56 self.settings
57 .as_ref()
58 .map(|s| s.iteration)
59 .unwrap_or_default()
60 }
61}
62
63#[derive(Debug, Clone, Deserialize, Serialize)]
65#[serde(rename_all = "camelCase")]
66pub struct PipelineNode {
67 pub id: String,
70
71 #[serde(rename = "type")]
74 pub node_type: String,
75
76 #[serde(default, alias = "parameters")]
82 pub params: serde_json::Map<String, serde_json::Value>,
83
84 #[serde(alias = "nodes")]
93 pub children: Option<Vec<PipelineNode>>,
94}
95
96#[derive(Debug, Clone)]
106pub struct PipelineFile {
107 pub name: String,
109
110 pub data: Vec<u8>,
112
113 pub mime_type: String,
115
116 pub metadata: serde_json::Map<String, serde_json::Value>,
121}
122
123#[derive(Debug, Clone)]
128pub struct PipelineFileResult {
129 pub name: String,
131
132 pub data: Vec<u8>,
134
135 pub mime_type: String,
137
138 pub metadata: serde_json::Map<String, serde_json::Value>,
141}
142
143#[derive(Debug, Clone)]
145pub struct PipelineResult {
146 pub files: Vec<PipelineFileResult>,
148
149 pub duration_ms: u64,
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
160pub enum InputMode {
161 #[default]
163 FileUpload,
164 Url,
166 Text,
168}
169
170pub fn resolve_input_mode(def: &PipelineDefinition) -> InputMode {
173 find_input_mode_in_nodes(&def.nodes)
174}
175
176fn find_input_mode_in_nodes(nodes: &[PipelineNode]) -> InputMode {
177 for node in nodes {
178 if node.node_type == "input" {
179 return match node.params.get("mode").and_then(|v| v.as_str()) {
180 Some("url") => InputMode::Url,
181 Some("text") => InputMode::Text,
182 _ => InputMode::FileUpload,
183 };
184 }
185 if let Some(children) = &node.children {
187 let mode = find_input_mode_in_nodes(children);
188 if mode != InputMode::FileUpload {
189 return mode;
190 }
191 if children.iter().any(|c| c.node_type == "input") {
193 return InputMode::FileUpload;
194 }
195 }
196 }
197 InputMode::FileUpload
198}
199
200pub fn first_processing_node_id(def: &PipelineDefinition) -> Option<String> {
203 find_first_processing_in_nodes(&def.nodes)
204}
205
206fn find_first_processing_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
207 for node in nodes {
208 if is_io_node(&node.node_type) {
209 continue;
210 }
211 if is_container_node(&node.node_type) {
212 if let Some(children) = &node.children
214 && let Some(id) = find_first_processing_in_nodes(children)
215 {
216 return Some(id);
217 }
218 continue;
219 }
220 return Some(node.id.clone());
222 }
223 None
224}
225
226pub fn is_io_node(node_type: &str) -> bool {
233 node_type == "input" || node_type == "output"
234}
235
236pub fn is_container_node(node_type: &str) -> bool {
239 node_type == "loop" || node_type == "group" || node_type == "parallel"
240}
241
242#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn parse_definition(json: &str) -> PipelineDefinition {
251 serde_json::from_str(json).unwrap()
252 }
253
254 #[test]
258 fn test_simple_definition_deserializes() {
259 let json = r#"{
261 "nodes": [
262 { "id": "n1", "type": "input" },
263 { "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
264 { "id": "n3", "type": "output" }
265 ]
266 }"#;
267
268 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
269
270 assert_eq!(def.nodes.len(), 3);
271 assert_eq!(def.nodes[0].id, "n1");
272 assert_eq!(def.nodes[0].node_type, "input");
273 assert_eq!(def.nodes[1].id, "n2");
274 assert_eq!(def.nodes[1].node_type, "image-compress");
275 assert_eq!(def.nodes[2].id, "n3");
276 assert_eq!(def.nodes[2].node_type, "output");
277 }
278
279 #[test]
280 fn test_params_deserialize_correctly() {
281 let json = r#"{
282 "nodes": [
283 {
284 "id": "n1",
285 "type": "image-compress",
286 "params": {
287 "quality": 80,
288 "preserveExif": true
289 }
290 }
291 ]
292 }"#;
293
294 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
295 let params = &def.nodes[0].params;
296
297 assert_eq!(params["quality"], 80);
298 assert_eq!(params["preserveExif"], true);
299 }
300
301 #[test]
302 fn test_missing_params_defaults_to_empty() {
303 let json = r#"{
305 "nodes": [
306 { "id": "n1", "type": "input" }
307 ]
308 }"#;
309
310 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
311 assert!(def.nodes[0].params.is_empty());
312 }
313
314 #[test]
315 fn test_container_node_with_children() {
316 let json = r#"{
318 "nodes": [
319 {
320 "id": "loop-1",
321 "type": "loop",
322 "children": [
323 { "id": "child-1", "type": "image-compress" }
324 ]
325 }
326 ]
327 }"#;
328
329 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
330 let loop_node = &def.nodes[0];
331
332 assert_eq!(loop_node.node_type, "loop");
333 let children = loop_node.children.as_ref().unwrap();
334 assert_eq!(children.len(), 1);
335 assert_eq!(children[0].node_type, "image-compress");
336 }
337
338 #[test]
339 fn test_no_children_is_none() {
340 let json = r#"{
341 "nodes": [
342 { "id": "n1", "type": "image-compress" }
343 ]
344 }"#;
345
346 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
347 assert!(def.nodes[0].children.is_none());
348 }
349
350 #[test]
351 fn test_nested_containers() {
352 let json = r#"{
354 "nodes": [
355 {
356 "id": "group-1",
357 "type": "group",
358 "children": [
359 {
360 "id": "loop-1",
361 "type": "loop",
362 "children": [
363 { "id": "proc-1", "type": "image-compress" }
364 ]
365 }
366 ]
367 }
368 ]
369 }"#;
370
371 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
372 let group = &def.nodes[0];
373 let loop_node = &group.children.as_ref().unwrap()[0];
374 let proc_node = &loop_node.children.as_ref().unwrap()[0];
375
376 assert_eq!(group.node_type, "group");
377 assert_eq!(loop_node.node_type, "loop");
378 assert_eq!(proc_node.node_type, "image-compress");
379 }
380
381 #[test]
386 fn test_nodes_alias_deserializes_as_children() {
387 let json = r#"{
390 "nodes": [
391 {
392 "id": "loop-1",
393 "type": "loop",
394 "nodes": [
395 { "id": "child-1", "type": "image-compress" }
396 ]
397 }
398 ]
399 }"#;
400
401 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
402 let loop_node = &def.nodes[0];
403 let children = loop_node.children.as_ref().unwrap();
404
405 assert_eq!(children.len(), 1);
406 assert_eq!(children[0].id, "child-1");
407 assert_eq!(children[0].node_type, "image-compress");
408 }
409
410 #[test]
411 fn test_parameters_alias_deserializes_as_params() {
412 let json = r#"{
415 "nodes": [
416 {
417 "id": "n1",
418 "type": "image-compress",
419 "parameters": { "quality": 80 }
420 }
421 ]
422 }"#;
423
424 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
425 let params = &def.nodes[0].params;
426
427 assert_eq!(params["quality"], 80);
428 }
429
430 #[test]
431 fn test_both_aliases_together() {
432 let json = r#"{
434 "nodes": [
435 {
436 "id": "loop-1",
437 "type": "loop",
438 "parameters": { "mode": "forEach" },
439 "nodes": [
440 {
441 "id": "child-1",
442 "type": "image-compress",
443 "parameters": { "quality": 75 }
444 }
445 ]
446 }
447 ]
448 }"#;
449
450 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
451 let loop_node = &def.nodes[0];
452
453 assert_eq!(loop_node.params["mode"], "forEach");
455
456 let children = loop_node.children.as_ref().unwrap();
458 assert_eq!(children.len(), 1);
459 assert_eq!(children[0].params["quality"], 75);
460 }
461
462 #[test]
463 fn test_original_field_names_still_work() {
464 let json = r#"{
466 "nodes": [
467 {
468 "id": "loop-1",
469 "type": "loop",
470 "params": { "mode": "forEach" },
471 "children": [
472 { "id": "child-1", "type": "image-compress" }
473 ]
474 }
475 ]
476 }"#;
477
478 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
479 let loop_node = &def.nodes[0];
480
481 assert_eq!(loop_node.params["mode"], "forEach");
482 assert_eq!(loop_node.children.as_ref().unwrap().len(), 1);
483 }
484
485 #[test]
486 fn test_unknown_fields_silently_ignored() {
487 let json = r#"{
491 "nodes": [
492 {
493 "id": "compress-image",
494 "type": "image-compress",
495 "version": "1.0.0",
496 "name": "Compress Image",
497 "position": { "x": 100, "y": 100 },
498 "metadata": { "description": "Compresses images" },
499 "parameters": { "quality": 80 },
500 "inputPorts": [{ "id": "in-1", "name": "files" }],
501 "outputPorts": [{ "id": "out-1", "name": "files" }]
502 }
503 ],
504 "edges": [{ "id": "e1", "source": "input", "target": "compress-image" }]
505 }"#;
506
507 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
508 assert_eq!(def.nodes.len(), 1);
509 assert_eq!(def.nodes[0].id, "compress-image");
510 assert_eq!(def.nodes[0].params["quality"], 80);
511 }
512
513 #[test]
518 fn test_compress_images_recipe_deserializes() {
519 let json = r#"{
521 "nodes": [
522 {
523 "id": "input", "type": "input", "version": "1.0.0",
524 "name": "Input Files", "position": {"x": 0, "y": 100},
525 "metadata": {},
526 "parameters": { "mode": "file-upload", "accept": ["image/jpeg"] },
527 "inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
528 },
529 {
530 "id": "batch-compress", "type": "group", "version": "1.0.0",
531 "name": "Batch Compress", "position": {"x": 250, "y": 100},
532 "metadata": { "description": "Reusable sub-recipe." },
533 "parameters": {},
534 "inputPorts": [{"id": "in-1", "name": "files"}],
535 "outputPorts": [{"id": "out-1", "name": "files"}],
536 "nodes": [
537 {
538 "id": "compress-loop", "type": "loop", "version": "1.0.0",
539 "name": "Compress Each Image", "position": {"x": 0, "y": 0},
540 "metadata": {},
541 "parameters": { "mode": "forEach" },
542 "inputPorts": [{"id": "in-1", "name": "items"}], "outputPorts": [],
543 "nodes": [
544 {
545 "id": "compress-image", "type": "image-compress", "version": "1.0.0",
546 "name": "Compress Image", "position": {"x": 0, "y": 0},
547 "metadata": {},
548 "parameters": { "quality": 80 },
549 "inputPorts": [], "outputPorts": []
550 }
551 ],
552 "edges": []
553 }
554 ],
555 "edges": []
556 },
557 {
558 "id": "output", "type": "output", "version": "1.0.0",
559 "name": "Compressed Images", "position": {"x": 500, "y": 100},
560 "metadata": {},
561 "parameters": { "mode": "download", "zip": true },
562 "inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
563 }
564 ],
565 "edges": [
566 {"id": "e1", "source": "input", "target": "batch-compress"},
567 {"id": "e2", "source": "batch-compress", "target": "output"}
568 ]
569 }"#;
570
571 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
572
573 assert_eq!(def.nodes.len(), 3);
575 assert_eq!(def.nodes[0].node_type, "input");
576 assert_eq!(def.nodes[1].node_type, "group");
577 assert_eq!(def.nodes[1].id, "batch-compress");
578 assert_eq!(def.nodes[2].node_type, "output");
579
580 let group_children = def.nodes[1].children.as_ref().unwrap();
582 assert_eq!(group_children.len(), 1);
583 assert_eq!(group_children[0].node_type, "loop");
584
585 let loop_children = group_children[0].children.as_ref().unwrap();
587 assert_eq!(loop_children.len(), 1);
588 assert_eq!(loop_children[0].id, "compress-image");
589 assert_eq!(loop_children[0].node_type, "image-compress");
590 assert_eq!(loop_children[0].params["quality"], 80);
591 }
592
593 #[test]
594 fn test_clean_csv_recipe_deserializes() {
595 let json = r#"{
597 "nodes": [
598 {
599 "id": "input", "type": "input", "version": "1.0.0",
600 "name": "Input Files", "position": {"x": 0, "y": 100},
601 "metadata": {},
602 "parameters": { "mode": "file-upload" },
603 "inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
604 },
605 {
606 "id": "csv-cleaner", "type": "group", "version": "1.0.0",
607 "name": "CSV Cleaner", "position": {"x": 250, "y": 100},
608 "metadata": {},
609 "parameters": {},
610 "inputPorts": [{"id": "in-1", "name": "files"}],
611 "outputPorts": [{"id": "out-1", "name": "files"}],
612 "nodes": [
613 {
614 "id": "clean", "type": "spreadsheet-clean", "version": "1.0.0",
615 "name": "Clean CSV", "position": {"x": 0, "y": 0},
616 "metadata": {},
617 "parameters": {
618 "trimWhitespace": true,
619 "removeEmptyRows": true,
620 "removeDuplicates": true
621 },
622 "inputPorts": [{"id": "in-1", "name": "files"}],
623 "outputPorts": [{"id": "out-1", "name": "files"}]
624 }
625 ],
626 "edges": []
627 },
628 {
629 "id": "output", "type": "output", "version": "1.0.0",
630 "name": "Cleaned CSV", "position": {"x": 500, "y": 100},
631 "metadata": {},
632 "parameters": { "mode": "download" },
633 "inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
634 }
635 ],
636 "edges": [
637 {"id": "e1", "source": "input", "target": "csv-cleaner"},
638 {"id": "e2", "source": "csv-cleaner", "target": "output"}
639 ]
640 }"#;
641
642 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
643
644 assert_eq!(def.nodes.len(), 3);
645 assert_eq!(def.nodes[1].node_type, "group");
647 assert_eq!(def.nodes[1].id, "csv-cleaner");
648
649 let group_children = def.nodes[1].children.as_ref().unwrap();
651 assert_eq!(group_children.len(), 1);
652 assert_eq!(group_children[0].node_type, "spreadsheet-clean");
653 }
654
655 #[test]
656 fn test_rename_files_recipe_deserializes() {
657 let json = r#"{
659 "nodes": [
660 { "id": "input", "type": "input", "version": "1.0.0",
661 "name": "Input", "position": {"x": 0, "y": 0}, "metadata": {},
662 "parameters": {}, "inputPorts": [], "outputPorts": [] },
663 {
664 "id": "batch-rename", "type": "group", "version": "1.0.0",
665 "name": "Batch Rename", "position": {"x": 250, "y": 100},
666 "metadata": {},
667 "parameters": {},
668 "inputPorts": [], "outputPorts": [],
669 "nodes": [
670 {
671 "id": "rename-loop", "type": "loop", "version": "1.0.0",
672 "name": "Rename Each File", "position": {"x": 0, "y": 0},
673 "metadata": {},
674 "parameters": { "mode": "forEach" },
675 "inputPorts": [], "outputPorts": [],
676 "nodes": [
677 {
678 "id": "rename-file", "type": "file-rename", "version": "1.0.0",
679 "name": "Rename File", "position": {"x": 0, "y": 0},
680 "metadata": {},
681 "parameters": { "prefix": "renamed-" },
682 "inputPorts": [], "outputPorts": []
683 }
684 ],
685 "edges": []
686 }
687 ],
688 "edges": []
689 },
690 { "id": "output", "type": "output", "version": "1.0.0",
691 "name": "Output", "position": {"x": 0, "y": 0}, "metadata": {},
692 "parameters": {}, "inputPorts": [], "outputPorts": [] }
693 ],
694 "edges": []
695 }"#;
696
697 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
698
699 let group_node = &def.nodes[1];
701 assert_eq!(group_node.node_type, "group");
702 assert_eq!(group_node.id, "batch-rename");
703
704 let group_children = group_node.children.as_ref().unwrap();
706 assert_eq!(group_children.len(), 1);
707 assert_eq!(group_children[0].node_type, "loop");
708
709 let loop_children = group_children[0].children.as_ref().unwrap();
711 assert_eq!(loop_children.len(), 1);
712 assert_eq!(loop_children[0].node_type, "file-rename");
713 assert_eq!(loop_children[0].params["prefix"], "renamed-");
714 }
715
716 #[test]
717 fn test_deeply_nested_three_levels() {
718 let json = r#"{
721 "nodes": [
722 {
723 "id": "outer-group", "type": "group",
724 "parameters": {},
725 "nodes": [
726 {
727 "id": "inner-group", "type": "group",
728 "parameters": {},
729 "nodes": [
730 {
731 "id": "the-loop", "type": "loop",
732 "parameters": { "mode": "forEach" },
733 "nodes": [
734 {
735 "id": "processor", "type": "image-compress",
736 "parameters": { "quality": 50 }
737 }
738 ]
739 }
740 ]
741 }
742 ]
743 }
744 ]
745 }"#;
746
747 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
748
749 let outer = &def.nodes[0];
751 assert_eq!(outer.node_type, "group");
752
753 let inner = &outer.children.as_ref().unwrap()[0];
754 assert_eq!(inner.node_type, "group");
755
756 let loop_node = &inner.children.as_ref().unwrap()[0];
757 assert_eq!(loop_node.node_type, "loop");
758
759 let processor = &loop_node.children.as_ref().unwrap()[0];
760 assert_eq!(processor.node_type, "image-compress");
761 assert_eq!(processor.params["quality"], 50);
762 }
763
764 #[test]
769 fn test_definition_without_settings_deserializes() {
770 let json = r#"{
771 "nodes": [
772 { "id": "n1", "type": "input" },
773 { "id": "n2", "type": "image-compress" },
774 { "id": "n3", "type": "output" }
775 ]
776 }"#;
777 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
778 assert!(def.settings.is_none());
779 }
780
781 #[test]
782 fn test_definition_with_auto_iteration_deserializes() {
783 let json = r#"{
784 "settings": { "iteration": "auto" },
785 "nodes": [
786 { "id": "n1", "type": "input" },
787 { "id": "n2", "type": "image-compress" },
788 { "id": "n3", "type": "output" }
789 ]
790 }"#;
791 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
792 let settings = def.settings.as_ref().unwrap();
793 assert_eq!(settings.iteration, IterationMode::Auto);
794 }
795
796 #[test]
797 fn test_definition_with_explicit_iteration_deserializes() {
798 let json = r#"{
799 "settings": { "iteration": "explicit" },
800 "nodes": [
801 { "id": "n1", "type": "image-compress" }
802 ]
803 }"#;
804 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
805 let settings = def.settings.as_ref().unwrap();
806 assert_eq!(settings.iteration, IterationMode::Explicit);
807 }
808
809 #[test]
810 fn test_definition_with_unknown_iteration_fails() {
811 let json = r#"{
812 "settings": { "iteration": "garbage" },
813 "nodes": [{ "id": "n1", "type": "input" }]
814 }"#;
815 let result = serde_json::from_str::<PipelineDefinition>(json);
816 assert!(result.is_err());
817 }
818
819 #[test]
820 fn test_resolved_iteration_defaults_explicit() {
821 let json = r#"{ "nodes": [] }"#;
822 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
823 assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
824 }
825
826 #[test]
827 fn test_resolved_iteration_returns_auto() {
828 let json = r#"{
829 "settings": { "iteration": "auto" },
830 "nodes": []
831 }"#;
832 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
833 assert_eq!(def.resolved_iteration(), IterationMode::Auto);
834 }
835
836 #[test]
837 fn test_settings_with_default_iteration_field() {
838 let json = r#"{
840 "settings": {},
841 "nodes": []
842 }"#;
843 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
844 let settings = def.settings.as_ref().unwrap();
845 assert_eq!(settings.iteration, IterationMode::Explicit);
846 assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
847 }
848
849 #[test]
853 fn test_definition_round_trip_serialization() {
854 let json = r#"{
855 "nodes": [
856 { "id": "n1", "type": "input" },
857 { "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
858 { "id": "n3", "type": "output" }
859 ],
860 "settings": { "iteration": "auto" }
861 }"#;
862
863 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
864 let serialized = serde_json::to_string(&def).unwrap();
865 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
866
867 assert_eq!(round_tripped.nodes.len(), def.nodes.len());
868 for (orig, rt) in def.nodes.iter().zip(round_tripped.nodes.iter()) {
869 assert_eq!(orig.id, rt.id);
870 assert_eq!(orig.node_type, rt.node_type);
871 }
872 assert_eq!(round_tripped.resolved_iteration(), def.resolved_iteration());
873 }
874
875 #[test]
876 fn test_definition_serialization_preserves_params() {
877 let json = r#"{
878 "nodes": [
879 {
880 "id": "n1",
881 "type": "image-compress",
882 "params": { "quality": 80, "preserveExif": true, "name": "test" }
883 }
884 ]
885 }"#;
886
887 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
888 let serialized = serde_json::to_string(&def).unwrap();
889 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
890
891 let params = &round_tripped.nodes[0].params;
892 assert_eq!(params["quality"], 80);
893 assert_eq!(params["preserveExif"], true);
894 assert_eq!(params["name"], "test");
895 }
896
897 #[test]
898 fn test_definition_serialization_preserves_children() {
899 let json = r#"{
900 "nodes": [
901 {
902 "id": "group-1",
903 "type": "group",
904 "children": [
905 {
906 "id": "loop-1",
907 "type": "loop",
908 "children": [
909 { "id": "proc-1", "type": "image-compress", "params": { "quality": 50 } }
910 ]
911 }
912 ]
913 }
914 ]
915 }"#;
916
917 let def: PipelineDefinition = serde_json::from_str(json).unwrap();
918 let serialized = serde_json::to_string(&def).unwrap();
919 let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
920
921 let group = &round_tripped.nodes[0];
922 assert_eq!(group.node_type, "group");
923 let loop_node = &group.children.as_ref().unwrap()[0];
924 assert_eq!(loop_node.node_type, "loop");
925 let proc_node = &loop_node.children.as_ref().unwrap()[0];
926 assert_eq!(proc_node.node_type, "image-compress");
927 assert_eq!(proc_node.params["quality"], 50);
928 }
929
930 #[test]
931 fn test_iteration_mode_serializes_camel_case() {
932 let auto_json = serde_json::to_string(&IterationMode::Auto).unwrap();
933 assert_eq!(auto_json, r#""auto""#);
934
935 let explicit_json = serde_json::to_string(&IterationMode::Explicit).unwrap();
936 assert_eq!(explicit_json, r#""explicit""#);
937 }
938
939 #[test]
940 fn test_pipeline_settings_serializes() {
941 let settings = PipelineSettings {
942 iteration: IterationMode::Auto,
943 };
944 let json = serde_json::to_string(&settings).unwrap();
945 assert!(json.contains(r#""iteration":"auto""#));
946 }
947
948 #[test]
951 fn test_is_io_node() {
952 assert!(is_io_node("input"));
953 assert!(is_io_node("output"));
954 assert!(!is_io_node("image-compress"));
955 assert!(!is_io_node("spreadsheet-clean"));
956 assert!(!is_io_node("loop"));
957 }
958
959 #[test]
960 fn test_is_container_node() {
961 assert!(is_container_node("loop"));
962 assert!(is_container_node("group"));
963 assert!(is_container_node("parallel"));
964 assert!(!is_container_node("image-compress"));
965 assert!(!is_container_node("input"));
966 assert!(!is_container_node("output"));
967 }
968
969 #[test]
972 fn test_resolve_input_mode_file_upload() {
973 let def = parse_definition(
974 r#"{
975 "formatVersion": "1.0.0",
976 "nodes": [
977 { "id": "in", "type": "input", "params": { "mode": "file-upload" } },
978 { "id": "proc", "type": "image-compress", "params": {} },
979 { "id": "out", "type": "output", "params": {} }
980 ]
981 }"#,
982 );
983 assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
984 }
985
986 #[test]
987 fn test_resolve_input_mode_url() {
988 let def = parse_definition(
989 r#"{
990 "formatVersion": "1.0.0",
991 "nodes": [
992 { "id": "in", "type": "input", "params": { "mode": "url" } },
993 { "id": "proc", "type": "video-download", "params": {} },
994 { "id": "out", "type": "output", "params": {} }
995 ]
996 }"#,
997 );
998 assert_eq!(resolve_input_mode(&def), InputMode::Url);
999 }
1000
1001 #[test]
1002 fn test_resolve_input_mode_text() {
1003 let def = parse_definition(
1004 r#"{
1005 "formatVersion": "1.0.0",
1006 "nodes": [
1007 { "id": "in", "type": "input", "params": { "mode": "text" } },
1008 { "id": "proc", "type": "text-transform", "params": {} },
1009 { "id": "out", "type": "output", "params": {} }
1010 ]
1011 }"#,
1012 );
1013 assert_eq!(resolve_input_mode(&def), InputMode::Text);
1014 }
1015
1016 #[test]
1017 fn test_resolve_input_mode_missing_defaults() {
1018 let def = parse_definition(
1019 r#"{
1020 "formatVersion": "1.0.0",
1021 "nodes": [
1022 { "id": "in", "type": "input", "params": {} },
1023 { "id": "proc", "type": "image-compress", "params": {} }
1024 ]
1025 }"#,
1026 );
1027 assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1028 }
1029
1030 #[test]
1031 fn test_resolve_input_mode_no_input_node() {
1032 let def = parse_definition(
1033 r#"{
1034 "formatVersion": "1.0.0",
1035 "nodes": [
1036 { "id": "proc", "type": "image-compress", "params": {} },
1037 { "id": "out", "type": "output", "params": {} }
1038 ]
1039 }"#,
1040 );
1041 assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1042 }
1043
1044 #[test]
1045 fn test_resolve_input_mode_nested() {
1046 let def = parse_definition(
1047 r#"{
1048 "formatVersion": "1.0.0",
1049 "nodes": [
1050 {
1051 "id": "group-1",
1052 "type": "group",
1053 "params": {},
1054 "children": [
1055 { "id": "in", "type": "input", "params": { "mode": "url" } },
1056 { "id": "proc", "type": "video-download", "params": {} }
1057 ]
1058 },
1059 { "id": "out", "type": "output", "params": {} }
1060 ]
1061 }"#,
1062 );
1063 assert_eq!(resolve_input_mode(&def), InputMode::Url);
1064 }
1065
1066 #[test]
1069 fn test_first_processing_node_id_simple() {
1070 let def = parse_definition(
1071 r#"{
1072 "formatVersion": "1.0.0",
1073 "nodes": [
1074 { "id": "in", "type": "input", "params": {} },
1075 { "id": "compress", "type": "image-compress", "params": {} },
1076 { "id": "out", "type": "output", "params": {} }
1077 ]
1078 }"#,
1079 );
1080 assert_eq!(first_processing_node_id(&def), Some("compress".to_string()));
1081 }
1082
1083 #[test]
1084 fn test_first_processing_node_id_none() {
1085 let def = parse_definition(
1086 r#"{
1087 "formatVersion": "1.0.0",
1088 "nodes": [
1089 { "id": "in", "type": "input", "params": {} },
1090 { "id": "out", "type": "output", "params": {} }
1091 ]
1092 }"#,
1093 );
1094 assert_eq!(first_processing_node_id(&def), None);
1095 }
1096
1097 #[test]
1098 fn test_first_processing_node_id_nested() {
1099 let def = parse_definition(
1100 r#"{
1101 "formatVersion": "1.0.0",
1102 "nodes": [
1103 { "id": "in", "type": "input", "params": {} },
1104 {
1105 "id": "loop-1",
1106 "type": "loop",
1107 "params": {},
1108 "children": [
1109 { "id": "resize", "type": "image-resize", "params": {} },
1110 { "id": "compress", "type": "image-compress", "params": {} }
1111 ]
1112 },
1113 { "id": "out", "type": "output", "params": {} }
1114 ]
1115 }"#,
1116 );
1117 assert_eq!(first_processing_node_id(&def), Some("resize".to_string()));
1118 }
1119}