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
203/// How the recipe expects to receive its input data.
204/// Read from the input node's `mode` parameter.
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
206pub enum InputMode {
207    /// User uploads files (default). CLI reads file paths from disk.
208    #[default]
209    FileUpload,
210    /// User provides a URL. CLI accepts a URL string.
211    Url,
212    /// User provides text content. CLI accepts a text string.
213    Text,
214}
215
216/// Walk the definition to find the input node and read its `mode` param.
217/// Returns `FileUpload` if no input node or no mode param is found.
218pub 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        // Recurse into container children
232        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            // Check if we found an input node with default mode
238            if children.iter().any(|c| c.node_type == "input") {
239                return InputMode::FileUpload;
240            }
241        }
242    }
243    InputMode::FileUpload
244}
245
246/// Walk the definition to find the first processing node (not I/O, not container).
247/// Used by the CLI to know where to inject params like URL.
248pub 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            // Look inside container children for a processing node
259            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        // Found a processing node
267        return Some(node.id.clone());
268    }
269    None
270}
271
272// =============================================================================
273// Helper: Resolve output directory from recipe definition
274// =============================================================================
275
276/// Read the output node's `directory` parameter from a pipeline definition.
277///
278/// Returns `Some(value)` if a non-empty directory string is found,
279/// `None` otherwise. The caller is responsible for resolving any
280/// `{{ctx.*}}` templates in the returned value.
281pub 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        // Recurse into container children
300        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
309// =============================================================================
310// Helper: Resolve output mode from recipe definition
311// =============================================================================
312
313/// Read the output node's `mode` parameter from a pipeline definition.
314///
315/// Returns the mode string ("write", "overwrite", "message", "none").
316/// Defaults to "write" if no output node or no mode param is found.
317pub 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        // Recurse into container children
336        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
345// =============================================================================
346// Helper: Check if a node type is an I/O marker
347// =============================================================================
348
349/// Returns true if the node type is an I/O structural marker
350/// (input or output) that the executor should skip.
351pub fn is_io_node(node_type: &str) -> bool {
352    node_type == "input" || node_type == "output"
353}
354
355/// Returns true if the node type is a container that holds child nodes
356/// (loop, group, or parallel).
357pub fn is_container_node(node_type: &str) -> bool {
358    node_type == "loop" || node_type == "group" || node_type == "parallel"
359}
360
361// =============================================================================
362// Tests
363// =============================================================================
364
365#[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    // --- Deserialization Tests ---
374    // Verify we can parse the same JSON shape that the TypeScript side produces.
375
376    #[test]
377    fn test_simple_definition_deserializes() {
378        // A minimal pipeline: input → compress → output.
379        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        // I/O nodes often don't have params.
423        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        // A loop node containing a compress child.
436        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        // Group containing a loop containing a processing node.
472        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    // --- Serde Alias Tests ---
501    // Verify that the TypeScript field names ("nodes", "parameters") work
502    // alongside the Rust field names ("children", "params").
503
504    #[test]
505    fn test_nodes_alias_deserializes_as_children() {
506        // TypeScript recipes use "nodes" for child definitions.
507        // The Rust struct uses "children". The alias bridges this gap.
508        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        // TypeScript recipes use "parameters" for node config.
532        // The Rust struct uses "params". The alias bridges this gap.
533        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        // Both TS field names used simultaneously in one definition.
552        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        // "parameters" → params
573        assert_eq!(loop_node.params["mode"], "forEach");
574
575        // "nodes" → children
576        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        // Backward compatibility: "children" and "params" still work.
584        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        // Real recipe JSON includes fields the Rust struct doesn't have:
607        // version, name, position, metadata, inputPorts, outputPorts, edges.
608        // Serde should ignore them without error.
609        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    // --- Full Recipe Deserialization Tests ---
633    // Verify that the EXACT JSON shape from TS recipe definitions
634    // deserializes correctly with all aliases and ignored fields.
635
636    #[test]
637    fn test_compress_images_recipe_deserializes() {
638        // Compositional: Input → Group("Batch Compress") → Loop → [image-compress] → Output
639        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        // Top level: 3 nodes (input, group, output).
693        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        // Group has 1 child (compress-loop).
700        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        // Loop has 1 child (compress-image processor).
705        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        // Compositional: Input → Group("CSV Cleaner") → [spreadsheet-clean] → Output
715        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        // Middle node is now a group, not a flat processor.
765        assert_eq!(def.nodes[1].node_type, "group");
766        assert_eq!(def.nodes[1].id, "csv-cleaner");
767
768        // Group has 1 child (the clean processor).
769        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        // Compositional: Input → Group("Batch Rename") → Loop → [file-rename] → Output
777        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        // Middle node is the batch-rename group.
819        let group_node = &def.nodes[1];
820        assert_eq!(group_node.node_type, "group");
821        assert_eq!(group_node.id, "batch-rename");
822
823        // Group has 1 child (rename-loop).
824        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        // Loop has 1 child (rename-file processor).
829        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        // Group → Group → Loop → processor — 3 levels of nesting.
838        // All using TS field names ("nodes", "parameters").
839        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        // Walk 3 levels deep.
869        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    // --- Recipe-level requires Tests ---
884
885    #[test]
886    fn test_definition_without_requires_still_parses() {
887        // Backward compat: existing recipes have no "requires" field.
888        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        // When requires is empty, it should NOT appear in the serialized JSON.
937        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        // Verify all Dependency fields survive deserialization.
978        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    // --- Secrets Field Tests ---
998
999    #[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    // --- Helper Function Tests ---
1064
1065    // --- PipelineSettings & IterationMode Tests ---
1066
1067    #[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        // Settings object present but iteration field absent — defaults to explicit.
1138        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    // --- Serialization Round-Trip Tests ---
1149    // Verify PipelineDefinition and PipelineNode serialize and deserialize back.
1150
1151    #[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    // --- Helper Function Tests ---
1248
1249    #[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    // --- resolve_output_directory Tests ---
1269
1270    #[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    // --- resolve_output_mode Tests ---
1348
1349    #[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    // --- InputMode Resolution Tests ---
1407
1408    #[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    // --- first_processing_node_id Tests ---
1504
1505    #[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}