Skip to main content

bnto_core/
pipeline.rs

1// Pipeline definition types — deserialized from JSON recipe definitions.
2// Mirrors the TypeScript `PipelineDefinition` / `PipelineNode` types exactly.
3// I/O nodes are structural markers (skipped by executor); container nodes
4// (loop, group, parallel) hold child nodes for nested execution.
5
6use 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// =============================================================================
16// Pipeline Settings — Recipe-Level Configuration
17// =============================================================================
18
19/// How the executor handles iteration over multiple input files.
20#[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    /// Execute exactly what's defined — containers control iteration.
32    /// This is the existing behavior and the default for backward compatibility.
33    #[default]
34    Explicit,
35    /// Wrap contiguous per-file processor sequences in implicit per-file loops.
36    /// Flat recipes produce identical output to explicit-loop recipes.
37    Auto,
38}
39
40/// Recipe-level settings on the root Definition. Extensible — new fields
41/// can be added without changing the schema shape.
42#[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    /// How the executor iterates over multiple input files.
54    #[serde(default)]
55    pub iteration: IterationMode,
56}
57
58// =============================================================================
59// Pipeline Definition
60// =============================================================================
61
62/// The top-level pipeline definition that the executor receives.
63#[derive(Debug, Clone, Deserialize, Serialize)]
64pub struct PipelineDefinition {
65    /// The ordered list of nodes in this pipeline.
66    /// Nodes execute sequentially — output from node N feeds into node N+1.
67    pub nodes: Vec<PipelineNode>,
68
69    /// Recipe-level settings (iteration mode, etc.).
70    /// Optional for backward compatibility — missing defaults to explicit iteration.
71    #[serde(default)]
72    pub settings: Option<PipelineSettings>,
73
74    /// Recipe-level dependencies — external tools this recipe needs at runtime.
75    /// Merged with per-node processor dependencies during the pre-flight check.
76    /// Empty by default so existing recipes (without this field) still parse.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub requires: Vec<crate::Dependency>,
79
80    /// Secrets this recipe needs at execution time (API keys, tokens, etc.).
81    /// Each entry maps to a `{{env.KEY}}` placeholder in node params.
82    /// Required secrets are validated before execution; optional ones resolve
83    /// to empty string if absent. See `strategy/recipe-secrets.md`.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub secrets: Vec<SecretDef>,
86}
87
88impl PipelineDefinition {
89    /// Returns the resolved iteration mode, defaulting to `Explicit`
90    /// when settings are absent.
91    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/// A single node in the pipeline.
100#[derive(Debug, Clone, Deserialize, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct PipelineNode {
103    /// The unique identifier for this node (e.g., "node-abc123").
104    /// Used in progress events so the UI knows which node to highlight.
105    pub id: String,
106
107    /// The per-operation type key (e.g., "image-compress", "spreadsheet-clean").
108    /// I/O types ("input", "output") are skipped by the executor.
109    #[serde(rename = "type")]
110    pub node_type: String,
111
112    /// Configuration parameters for this node.
113    /// Operation-specific settings (quality, dimensions, format, etc.).
114    /// Defaults to an empty Map when absent (I/O nodes often have no params).
115    /// Accepts both `params` (Rust convention) and `parameters` (TypeScript
116    /// convention) via serde alias.
117    #[serde(default, alias = "parameters")]
118    pub params: serde_json::Map<String, serde_json::Value>,
119
120    /// Child nodes for container types (loop, group, parallel).
121    /// `None` for primitive (leaf) nodes. `Some(vec![...])` for containers.
122    /// Both `None` and `Some(vec![])` mean "no children."
123    ///
124    /// The TypeScript `Definition` type uses `nodes` for child definitions,
125    /// but the Rust struct uses `children`. The `alias` lets serde accept
126    /// either name — so real recipe JSON (with `"nodes"`) and test JSON
127    /// (with `"children"`) both work.
128    #[serde(alias = "nodes")]
129    pub children: Option<Vec<PipelineNode>>,
130
131    /// Node-level field declarations — user-facing controls that map to
132    /// `{{fields.*}}` templates in this node's parameters. Each node is
133    /// self-contained: its fields resolve into its own params.
134    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
135    pub fields: BTreeMap<String, FieldDef>,
136}
137
138// =============================================================================
139// Pipeline File Types
140// =============================================================================
141
142/// A file that enters the pipeline for processing.
143///
144/// This is the engine's internal file representation. Small files (images,
145/// CSVs) carry in-memory bytes. Large files from shell-command carry a
146/// disk path reference. The adapter layer (WASM bridge, CLI, Tauri)
147/// converts from its native file type to this.
148#[derive(Debug, Clone)]
149pub struct PipelineFile {
150    /// The filename (e.g., "photo.jpg", "data.csv").
151    pub name: String,
152
153    /// The file content — in-memory bytes or a path on disk.
154    pub data: crate::processor::FileData,
155
156    /// The MIME type (e.g., "image/jpeg", "text/csv").
157    pub mime_type: String,
158
159    /// Metadata from the processor that created this file.
160    /// Carries through the pipeline so the final result includes
161    /// stats like compression ratio, original size, etc.
162    /// Empty for files that haven't been processed yet (inputs).
163    pub metadata: serde_json::Map<String, serde_json::Value>,
164}
165
166/// A single output file produced by the pipeline.
167///
168/// Includes the processed data plus metadata about the processing
169/// (compression ratio, dimensions, rows affected, etc.).
170#[derive(Debug, Clone)]
171pub struct PipelineFileResult {
172    /// The filename of the output (e.g., "photo-compressed.jpg").
173    pub name: String,
174
175    /// The file content — in-memory bytes or a path on disk.
176    pub data: crate::processor::FileData,
177
178    /// The MIME type of the output.
179    pub mime_type: String,
180
181    /// Metadata about the processing (timing, stats, etc.).
182    /// Each node can attach arbitrary key-value metadata to its output.
183    pub metadata: serde_json::Map<String, serde_json::Value>,
184}
185
186/// The result of executing an entire pipeline.
187#[derive(Debug, Clone)]
188pub struct PipelineResult {
189    /// All output files produced by the pipeline's final processing node.
190    pub files: Vec<PipelineFileResult>,
191
192    /// Total wall-clock time for the entire pipeline, in milliseconds.
193    pub duration_ms: u64,
194
195    /// Non-fatal warnings collected during execution (e.g. skipped loop iterations).
196    pub warnings: Vec<String>,
197}
198
199// =============================================================================
200// InputMode — How data enters the recipe
201// =============================================================================
202
203pub use crate::input_mode::InputMode;
204
205/// Walk the definition to find the input node and read its `mode` param.
206/// Returns `FileUpload` if no input node or no mode param is found.
207pub 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        // Recurse into container children
222        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            // Check if we found an input node with default mode
228            if children.iter().any(|c| c.node_type == "input") {
229                return InputMode::FileUpload;
230            }
231        }
232    }
233    InputMode::FileUpload
234}
235
236/// Walk the definition to find the first processing node (not I/O, not container).
237/// Used by the CLI to know where to inject params like URL.
238pub 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            // Look inside container children for a processing node
249            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        // Found a processing node
257        return Some(node.id.clone());
258    }
259    None
260}
261
262// =============================================================================
263// Helper: Resolve output directory from recipe definition
264// =============================================================================
265
266/// Read the output node's `directory` parameter from a pipeline definition.
267///
268/// Returns `Some(value)` if a non-empty directory string is found,
269/// `None` otherwise. The caller is responsible for resolving any
270/// `{{ctx.*}}` templates in the returned value.
271pub 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        // Recurse into container children
290        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
299// =============================================================================
300// Helper: Resolve output mode from recipe definition
301// =============================================================================
302
303/// Read the output node's `mode` parameter from a pipeline definition.
304///
305/// Returns the mode string ("write", "overwrite", "message", "none").
306/// Defaults to "write" if no output node or no mode param is found.
307pub 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        // Recurse into container children
326        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
335// =============================================================================
336// Helper: Check if a node type is an I/O marker
337// =============================================================================
338
339/// Returns true if the node type is an I/O structural marker
340/// (input or output) that the executor should skip.
341pub fn is_io_node(node_type: &str) -> bool {
342    node_type == "input" || node_type == "output"
343}
344
345/// Returns true if the node type is a container that holds child nodes
346/// (loop, group, or parallel).
347pub fn is_container_node(node_type: &str) -> bool {
348    node_type == "loop" || node_type == "group" || node_type == "parallel"
349}
350
351// =============================================================================
352// Tests
353// =============================================================================
354
355#[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    // --- Deserialization Tests ---
364    // Verify we can parse the same JSON shape that the TypeScript side produces.
365
366    #[test]
367    fn test_simple_definition_deserializes() {
368        // A minimal pipeline: input → compress → output.
369        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        // I/O nodes often don't have params.
413        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        // A loop node containing a compress child.
426        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        // Group containing a loop containing a processing node.
462        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    // --- Serde Alias Tests ---
491    // Verify that the TypeScript field names ("nodes", "parameters") work
492    // alongside the Rust field names ("children", "params").
493
494    #[test]
495    fn test_nodes_alias_deserializes_as_children() {
496        // TypeScript recipes use "nodes" for child definitions.
497        // The Rust struct uses "children". The alias bridges this gap.
498        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        // TypeScript recipes use "parameters" for node config.
522        // The Rust struct uses "params". The alias bridges this gap.
523        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        // Both TS field names used simultaneously in one definition.
542        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        // "parameters" → params
563        assert_eq!(loop_node.params["mode"], "forEach");
564
565        // "nodes" → children
566        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        // Backward compatibility: "children" and "params" still work.
574        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        // Real recipe JSON includes fields the Rust struct doesn't have:
597        // version, name, position, metadata, inputPorts, outputPorts, edges.
598        // Serde should ignore them without error.
599        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    // --- Full Recipe Deserialization Tests ---
623    // Verify that the EXACT JSON shape from TS recipe definitions
624    // deserializes correctly with all aliases and ignored fields.
625
626    #[test]
627    fn test_compress_images_recipe_deserializes() {
628        // Compositional: Input → Group("Batch Compress") → Loop → [image-compress] → Output
629        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        // Top level: 3 nodes (input, group, output).
683        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        // Group has 1 child (compress-loop).
690        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        // Loop has 1 child (compress-image processor).
695        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        // Compositional: Input → Group("CSV Cleaner") → [spreadsheet-clean] → Output
705        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        // Middle node is now a group, not a flat processor.
755        assert_eq!(def.nodes[1].node_type, "group");
756        assert_eq!(def.nodes[1].id, "csv-cleaner");
757
758        // Group has 1 child (the clean processor).
759        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        // Compositional: Input → Group("Batch Rename") → Loop → [file-rename] → Output
767        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        // Middle node is the batch-rename group.
809        let group_node = &def.nodes[1];
810        assert_eq!(group_node.node_type, "group");
811        assert_eq!(group_node.id, "batch-rename");
812
813        // Group has 1 child (rename-loop).
814        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        // Loop has 1 child (rename-file processor).
819        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        // Group → Group → Loop → processor — 3 levels of nesting.
828        // All using TS field names ("nodes", "parameters").
829        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        // Walk 3 levels deep.
859        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    // --- Recipe-level requires Tests ---
874
875    #[test]
876    fn test_definition_without_requires_still_parses() {
877        // Backward compat: existing recipes have no "requires" field.
878        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        // When requires is empty, it should NOT appear in the serialized JSON.
927        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        // Verify all Dependency fields survive deserialization.
968        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    // --- Secrets Field Tests ---
988
989    #[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    // --- Helper Function Tests ---
1054
1055    // --- PipelineSettings & IterationMode Tests ---
1056
1057    #[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        // Settings object present but iteration field absent — defaults to explicit.
1128        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    // --- Serialization Round-Trip Tests ---
1139    // Verify PipelineDefinition and PipelineNode serialize and deserialize back.
1140
1141    #[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    // --- Helper Function Tests ---
1238
1239    #[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    // --- resolve_output_directory Tests ---
1259
1260    #[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    // --- resolve_output_mode Tests ---
1338
1339    #[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    // --- InputMode Resolution Tests ---
1397
1398    #[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    // --- first_processing_node_id Tests ---
1530
1531    #[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}