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 serde::{Deserialize, Serialize};
7
8// =============================================================================
9// Pipeline Settings — Recipe-Level Configuration
10// =============================================================================
11
12/// How the executor handles iteration over multiple input files.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "camelCase")]
15pub enum IterationMode {
16    /// Execute exactly what's defined — containers control iteration.
17    /// This is the existing behavior and the default for backward compatibility.
18    #[default]
19    Explicit,
20    /// Wrap contiguous per-file processor sequences in implicit per-file loops.
21    /// Flat recipes produce identical output to explicit-loop recipes.
22    Auto,
23}
24
25/// Recipe-level settings on the root Definition. Extensible — new fields
26/// can be added without changing the schema shape.
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28#[serde(rename_all = "camelCase")]
29pub struct PipelineSettings {
30    /// How the executor iterates over multiple input files.
31    #[serde(default)]
32    pub iteration: IterationMode,
33}
34
35// =============================================================================
36// Pipeline Definition
37// =============================================================================
38
39/// The top-level pipeline definition that the executor receives.
40#[derive(Debug, Clone, Deserialize, Serialize)]
41pub struct PipelineDefinition {
42    /// The ordered list of nodes in this pipeline.
43    /// Nodes execute sequentially — output from node N feeds into node N+1.
44    pub nodes: Vec<PipelineNode>,
45
46    /// Recipe-level settings (iteration mode, etc.).
47    /// Optional for backward compatibility — missing defaults to explicit iteration.
48    #[serde(default)]
49    pub settings: Option<PipelineSettings>,
50}
51
52impl PipelineDefinition {
53    /// Returns the resolved iteration mode, defaulting to `Explicit`
54    /// when settings are absent.
55    pub fn resolved_iteration(&self) -> IterationMode {
56        self.settings
57            .as_ref()
58            .map(|s| s.iteration)
59            .unwrap_or_default()
60    }
61}
62
63/// A single node in the pipeline.
64#[derive(Debug, Clone, Deserialize, Serialize)]
65#[serde(rename_all = "camelCase")]
66pub struct PipelineNode {
67    /// The unique identifier for this node (e.g., "node-abc123").
68    /// Used in progress events so the UI knows which node to highlight.
69    pub id: String,
70
71    /// The per-operation type key (e.g., "image-compress", "spreadsheet-clean").
72    /// I/O types ("input", "output") are skipped by the executor.
73    #[serde(rename = "type")]
74    pub node_type: String,
75
76    /// Configuration parameters for this node.
77    /// Operation-specific settings (quality, dimensions, format, etc.).
78    /// Defaults to an empty Map when absent (I/O nodes often have no params).
79    /// Accepts both `params` (Rust convention) and `parameters` (TypeScript
80    /// convention) via serde alias.
81    #[serde(default, alias = "parameters")]
82    pub params: serde_json::Map<String, serde_json::Value>,
83
84    /// Child nodes for container types (loop, group, parallel).
85    /// `None` for primitive (leaf) nodes. `Some(vec![...])` for containers.
86    /// Both `None` and `Some(vec![])` mean "no children."
87    ///
88    /// The TypeScript `Definition` type uses `nodes` for child definitions,
89    /// but the Rust struct uses `children`. The `alias` lets serde accept
90    /// either name — so real recipe JSON (with `"nodes"`) and test JSON
91    /// (with `"children"`) both work.
92    #[serde(alias = "nodes")]
93    pub children: Option<Vec<PipelineNode>>,
94}
95
96// =============================================================================
97// Pipeline File Types
98// =============================================================================
99
100/// A file that enters the pipeline for processing.
101///
102/// This is the engine's internal file representation — it holds raw bytes,
103/// not a browser File object or filesystem path. The adapter layer
104/// (WASM bridge, CLI, Tauri) converts from its native file type to this.
105#[derive(Debug, Clone)]
106pub struct PipelineFile {
107    /// The filename (e.g., "photo.jpg", "data.csv").
108    pub name: String,
109
110    /// The raw file data as bytes.
111    pub data: Vec<u8>,
112
113    /// The MIME type (e.g., "image/jpeg", "text/csv").
114    pub mime_type: String,
115
116    /// Metadata from the processor that created this file.
117    /// Carries through the pipeline so the final result includes
118    /// stats like compression ratio, original size, etc.
119    /// Empty for files that haven't been processed yet (inputs).
120    pub metadata: serde_json::Map<String, serde_json::Value>,
121}
122
123/// A single output file produced by the pipeline.
124///
125/// Includes the processed data plus metadata about the processing
126/// (compression ratio, dimensions, rows affected, etc.).
127#[derive(Debug, Clone)]
128pub struct PipelineFileResult {
129    /// The filename of the output (e.g., "photo-compressed.jpg").
130    pub name: String,
131
132    /// The processed file data as bytes.
133    pub data: Vec<u8>,
134
135    /// The MIME type of the output.
136    pub mime_type: String,
137
138    /// Metadata about the processing (timing, stats, etc.).
139    /// Each node can attach arbitrary key-value metadata to its output.
140    pub metadata: serde_json::Map<String, serde_json::Value>,
141}
142
143/// The result of executing an entire pipeline.
144#[derive(Debug, Clone)]
145pub struct PipelineResult {
146    /// All output files produced by the pipeline's final processing node.
147    pub files: Vec<PipelineFileResult>,
148
149    /// Total wall-clock time for the entire pipeline, in milliseconds.
150    pub duration_ms: u64,
151}
152
153// =============================================================================
154// InputMode — How data enters the recipe
155// =============================================================================
156
157/// How the recipe expects to receive its input data.
158/// Read from the input node's `mode` parameter.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
160pub enum InputMode {
161    /// User uploads files (default). CLI reads file paths from disk.
162    #[default]
163    FileUpload,
164    /// User provides a URL. CLI accepts a URL string.
165    Url,
166    /// User provides text content. CLI accepts a text string.
167    Text,
168}
169
170/// Walk the definition to find the input node and read its `mode` param.
171/// Returns `FileUpload` if no input node or no mode param is found.
172pub fn resolve_input_mode(def: &PipelineDefinition) -> InputMode {
173    find_input_mode_in_nodes(&def.nodes)
174}
175
176fn find_input_mode_in_nodes(nodes: &[PipelineNode]) -> InputMode {
177    for node in nodes {
178        if node.node_type == "input" {
179            return match node.params.get("mode").and_then(|v| v.as_str()) {
180                Some("url") => InputMode::Url,
181                Some("text") => InputMode::Text,
182                _ => InputMode::FileUpload,
183            };
184        }
185        // Recurse into container children
186        if let Some(children) = &node.children {
187            let mode = find_input_mode_in_nodes(children);
188            if mode != InputMode::FileUpload {
189                return mode;
190            }
191            // Check if we found an input node with default mode
192            if children.iter().any(|c| c.node_type == "input") {
193                return InputMode::FileUpload;
194            }
195        }
196    }
197    InputMode::FileUpload
198}
199
200/// Walk the definition to find the first processing node (not I/O, not container).
201/// Used by the CLI to know where to inject params like URL.
202pub fn first_processing_node_id(def: &PipelineDefinition) -> Option<String> {
203    find_first_processing_in_nodes(&def.nodes)
204}
205
206fn find_first_processing_in_nodes(nodes: &[PipelineNode]) -> Option<String> {
207    for node in nodes {
208        if is_io_node(&node.node_type) {
209            continue;
210        }
211        if is_container_node(&node.node_type) {
212            // Look inside container children for a processing node
213            if let Some(children) = &node.children
214                && let Some(id) = find_first_processing_in_nodes(children)
215            {
216                return Some(id);
217            }
218            continue;
219        }
220        // Found a processing node
221        return Some(node.id.clone());
222    }
223    None
224}
225
226// =============================================================================
227// Helper: Check if a node type is an I/O marker
228// =============================================================================
229
230/// Returns true if the node type is an I/O structural marker
231/// (input or output) that the executor should skip.
232pub fn is_io_node(node_type: &str) -> bool {
233    node_type == "input" || node_type == "output"
234}
235
236/// Returns true if the node type is a container that holds child nodes
237/// (loop, group, or parallel).
238pub fn is_container_node(node_type: &str) -> bool {
239    node_type == "loop" || node_type == "group" || node_type == "parallel"
240}
241
242// =============================================================================
243// Tests
244// =============================================================================
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn parse_definition(json: &str) -> PipelineDefinition {
251        serde_json::from_str(json).unwrap()
252    }
253
254    // --- Deserialization Tests ---
255    // Verify we can parse the same JSON shape that the TypeScript side produces.
256
257    #[test]
258    fn test_simple_definition_deserializes() {
259        // A minimal pipeline: input → compress → output.
260        let json = r#"{
261            "nodes": [
262                { "id": "n1", "type": "input" },
263                { "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
264                { "id": "n3", "type": "output" }
265            ]
266        }"#;
267
268        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
269
270        assert_eq!(def.nodes.len(), 3);
271        assert_eq!(def.nodes[0].id, "n1");
272        assert_eq!(def.nodes[0].node_type, "input");
273        assert_eq!(def.nodes[1].id, "n2");
274        assert_eq!(def.nodes[1].node_type, "image-compress");
275        assert_eq!(def.nodes[2].id, "n3");
276        assert_eq!(def.nodes[2].node_type, "output");
277    }
278
279    #[test]
280    fn test_params_deserialize_correctly() {
281        let json = r#"{
282            "nodes": [
283                {
284                    "id": "n1",
285                    "type": "image-compress",
286                    "params": {
287                        "quality": 80,
288                        "preserveExif": true
289                    }
290                }
291            ]
292        }"#;
293
294        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
295        let params = &def.nodes[0].params;
296
297        assert_eq!(params["quality"], 80);
298        assert_eq!(params["preserveExif"], true);
299    }
300
301    #[test]
302    fn test_missing_params_defaults_to_empty() {
303        // I/O nodes often don't have params.
304        let json = r#"{
305            "nodes": [
306                { "id": "n1", "type": "input" }
307            ]
308        }"#;
309
310        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
311        assert!(def.nodes[0].params.is_empty());
312    }
313
314    #[test]
315    fn test_container_node_with_children() {
316        // A loop node containing a compress child.
317        let json = r#"{
318            "nodes": [
319                {
320                    "id": "loop-1",
321                    "type": "loop",
322                    "children": [
323                        { "id": "child-1", "type": "image-compress" }
324                    ]
325                }
326            ]
327        }"#;
328
329        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
330        let loop_node = &def.nodes[0];
331
332        assert_eq!(loop_node.node_type, "loop");
333        let children = loop_node.children.as_ref().unwrap();
334        assert_eq!(children.len(), 1);
335        assert_eq!(children[0].node_type, "image-compress");
336    }
337
338    #[test]
339    fn test_no_children_is_none() {
340        let json = r#"{
341            "nodes": [
342                { "id": "n1", "type": "image-compress" }
343            ]
344        }"#;
345
346        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
347        assert!(def.nodes[0].children.is_none());
348    }
349
350    #[test]
351    fn test_nested_containers() {
352        // Group containing a loop containing a processing node.
353        let json = r#"{
354            "nodes": [
355                {
356                    "id": "group-1",
357                    "type": "group",
358                    "children": [
359                        {
360                            "id": "loop-1",
361                            "type": "loop",
362                            "children": [
363                                { "id": "proc-1", "type": "image-compress" }
364                            ]
365                        }
366                    ]
367                }
368            ]
369        }"#;
370
371        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
372        let group = &def.nodes[0];
373        let loop_node = &group.children.as_ref().unwrap()[0];
374        let proc_node = &loop_node.children.as_ref().unwrap()[0];
375
376        assert_eq!(group.node_type, "group");
377        assert_eq!(loop_node.node_type, "loop");
378        assert_eq!(proc_node.node_type, "image-compress");
379    }
380
381    // --- Serde Alias Tests ---
382    // Verify that the TypeScript field names ("nodes", "parameters") work
383    // alongside the Rust field names ("children", "params").
384
385    #[test]
386    fn test_nodes_alias_deserializes_as_children() {
387        // TypeScript recipes use "nodes" for child definitions.
388        // The Rust struct uses "children". The alias bridges this gap.
389        let json = r#"{
390            "nodes": [
391                {
392                    "id": "loop-1",
393                    "type": "loop",
394                    "nodes": [
395                        { "id": "child-1", "type": "image-compress" }
396                    ]
397                }
398            ]
399        }"#;
400
401        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
402        let loop_node = &def.nodes[0];
403        let children = loop_node.children.as_ref().unwrap();
404
405        assert_eq!(children.len(), 1);
406        assert_eq!(children[0].id, "child-1");
407        assert_eq!(children[0].node_type, "image-compress");
408    }
409
410    #[test]
411    fn test_parameters_alias_deserializes_as_params() {
412        // TypeScript recipes use "parameters" for node config.
413        // The Rust struct uses "params". The alias bridges this gap.
414        let json = r#"{
415            "nodes": [
416                {
417                    "id": "n1",
418                    "type": "image-compress",
419                    "parameters": { "quality": 80 }
420                }
421            ]
422        }"#;
423
424        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
425        let params = &def.nodes[0].params;
426
427        assert_eq!(params["quality"], 80);
428    }
429
430    #[test]
431    fn test_both_aliases_together() {
432        // Both TS field names used simultaneously in one definition.
433        let json = r#"{
434            "nodes": [
435                {
436                    "id": "loop-1",
437                    "type": "loop",
438                    "parameters": { "mode": "forEach" },
439                    "nodes": [
440                        {
441                            "id": "child-1",
442                            "type": "image-compress",
443                            "parameters": { "quality": 75 }
444                        }
445                    ]
446                }
447            ]
448        }"#;
449
450        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
451        let loop_node = &def.nodes[0];
452
453        // "parameters" → params
454        assert_eq!(loop_node.params["mode"], "forEach");
455
456        // "nodes" → children
457        let children = loop_node.children.as_ref().unwrap();
458        assert_eq!(children.len(), 1);
459        assert_eq!(children[0].params["quality"], 75);
460    }
461
462    #[test]
463    fn test_original_field_names_still_work() {
464        // Backward compatibility: "children" and "params" still work.
465        let json = r#"{
466            "nodes": [
467                {
468                    "id": "loop-1",
469                    "type": "loop",
470                    "params": { "mode": "forEach" },
471                    "children": [
472                        { "id": "child-1", "type": "image-compress" }
473                    ]
474                }
475            ]
476        }"#;
477
478        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
479        let loop_node = &def.nodes[0];
480
481        assert_eq!(loop_node.params["mode"], "forEach");
482        assert_eq!(loop_node.children.as_ref().unwrap().len(), 1);
483    }
484
485    #[test]
486    fn test_unknown_fields_silently_ignored() {
487        // Real recipe JSON includes fields the Rust struct doesn't have:
488        // version, name, position, metadata, inputPorts, outputPorts, edges.
489        // Serde should ignore them without error.
490        let json = r#"{
491            "nodes": [
492                {
493                    "id": "compress-image",
494                    "type": "image-compress",
495                    "version": "1.0.0",
496                    "name": "Compress Image",
497                    "position": { "x": 100, "y": 100 },
498                    "metadata": { "description": "Compresses images" },
499                    "parameters": { "quality": 80 },
500                    "inputPorts": [{ "id": "in-1", "name": "files" }],
501                    "outputPorts": [{ "id": "out-1", "name": "files" }]
502                }
503            ],
504            "edges": [{ "id": "e1", "source": "input", "target": "compress-image" }]
505        }"#;
506
507        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
508        assert_eq!(def.nodes.len(), 1);
509        assert_eq!(def.nodes[0].id, "compress-image");
510        assert_eq!(def.nodes[0].params["quality"], 80);
511    }
512
513    // --- Full Recipe Deserialization Tests ---
514    // Verify that the EXACT JSON shape from TS recipe definitions
515    // deserializes correctly with all aliases and ignored fields.
516
517    #[test]
518    fn test_compress_images_recipe_deserializes() {
519        // Compositional: Input → Group("Batch Compress") → Loop → [image-compress] → Output
520        let json = r#"{
521            "nodes": [
522                {
523                    "id": "input", "type": "input", "version": "1.0.0",
524                    "name": "Input Files", "position": {"x": 0, "y": 100},
525                    "metadata": {},
526                    "parameters": { "mode": "file-upload", "accept": ["image/jpeg"] },
527                    "inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
528                },
529                {
530                    "id": "batch-compress", "type": "group", "version": "1.0.0",
531                    "name": "Batch Compress", "position": {"x": 250, "y": 100},
532                    "metadata": { "description": "Reusable sub-recipe." },
533                    "parameters": {},
534                    "inputPorts": [{"id": "in-1", "name": "files"}],
535                    "outputPorts": [{"id": "out-1", "name": "files"}],
536                    "nodes": [
537                        {
538                            "id": "compress-loop", "type": "loop", "version": "1.0.0",
539                            "name": "Compress Each Image", "position": {"x": 0, "y": 0},
540                            "metadata": {},
541                            "parameters": { "mode": "forEach" },
542                            "inputPorts": [{"id": "in-1", "name": "items"}], "outputPorts": [],
543                            "nodes": [
544                                {
545                                    "id": "compress-image", "type": "image-compress", "version": "1.0.0",
546                                    "name": "Compress Image", "position": {"x": 0, "y": 0},
547                                    "metadata": {},
548                                    "parameters": { "quality": 80 },
549                                    "inputPorts": [], "outputPorts": []
550                                }
551                            ],
552                            "edges": []
553                        }
554                    ],
555                    "edges": []
556                },
557                {
558                    "id": "output", "type": "output", "version": "1.0.0",
559                    "name": "Compressed Images", "position": {"x": 500, "y": 100},
560                    "metadata": {},
561                    "parameters": { "mode": "download", "zip": true },
562                    "inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
563                }
564            ],
565            "edges": [
566                {"id": "e1", "source": "input", "target": "batch-compress"},
567                {"id": "e2", "source": "batch-compress", "target": "output"}
568            ]
569        }"#;
570
571        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
572
573        // Top level: 3 nodes (input, group, output).
574        assert_eq!(def.nodes.len(), 3);
575        assert_eq!(def.nodes[0].node_type, "input");
576        assert_eq!(def.nodes[1].node_type, "group");
577        assert_eq!(def.nodes[1].id, "batch-compress");
578        assert_eq!(def.nodes[2].node_type, "output");
579
580        // Group has 1 child (compress-loop).
581        let group_children = def.nodes[1].children.as_ref().unwrap();
582        assert_eq!(group_children.len(), 1);
583        assert_eq!(group_children[0].node_type, "loop");
584
585        // Loop has 1 child (compress-image processor).
586        let loop_children = group_children[0].children.as_ref().unwrap();
587        assert_eq!(loop_children.len(), 1);
588        assert_eq!(loop_children[0].id, "compress-image");
589        assert_eq!(loop_children[0].node_type, "image-compress");
590        assert_eq!(loop_children[0].params["quality"], 80);
591    }
592
593    #[test]
594    fn test_clean_csv_recipe_deserializes() {
595        // Compositional: Input → Group("CSV Cleaner") → [spreadsheet-clean] → Output
596        let json = r#"{
597            "nodes": [
598                {
599                    "id": "input", "type": "input", "version": "1.0.0",
600                    "name": "Input Files", "position": {"x": 0, "y": 100},
601                    "metadata": {},
602                    "parameters": { "mode": "file-upload" },
603                    "inputPorts": [], "outputPorts": [{"id": "out-1", "name": "files"}]
604                },
605                {
606                    "id": "csv-cleaner", "type": "group", "version": "1.0.0",
607                    "name": "CSV Cleaner", "position": {"x": 250, "y": 100},
608                    "metadata": {},
609                    "parameters": {},
610                    "inputPorts": [{"id": "in-1", "name": "files"}],
611                    "outputPorts": [{"id": "out-1", "name": "files"}],
612                    "nodes": [
613                        {
614                            "id": "clean", "type": "spreadsheet-clean", "version": "1.0.0",
615                            "name": "Clean CSV", "position": {"x": 0, "y": 0},
616                            "metadata": {},
617                            "parameters": {
618                                "trimWhitespace": true,
619                                "removeEmptyRows": true,
620                                "removeDuplicates": true
621                            },
622                            "inputPorts": [{"id": "in-1", "name": "files"}],
623                            "outputPorts": [{"id": "out-1", "name": "files"}]
624                        }
625                    ],
626                    "edges": []
627                },
628                {
629                    "id": "output", "type": "output", "version": "1.0.0",
630                    "name": "Cleaned CSV", "position": {"x": 500, "y": 100},
631                    "metadata": {},
632                    "parameters": { "mode": "download" },
633                    "inputPorts": [{"id": "in-1", "name": "files"}], "outputPorts": []
634                }
635            ],
636            "edges": [
637                {"id": "e1", "source": "input", "target": "csv-cleaner"},
638                {"id": "e2", "source": "csv-cleaner", "target": "output"}
639            ]
640        }"#;
641
642        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
643
644        assert_eq!(def.nodes.len(), 3);
645        // Middle node is now a group, not a flat processor.
646        assert_eq!(def.nodes[1].node_type, "group");
647        assert_eq!(def.nodes[1].id, "csv-cleaner");
648
649        // Group has 1 child (the clean processor).
650        let group_children = def.nodes[1].children.as_ref().unwrap();
651        assert_eq!(group_children.len(), 1);
652        assert_eq!(group_children[0].node_type, "spreadsheet-clean");
653    }
654
655    #[test]
656    fn test_rename_files_recipe_deserializes() {
657        // Compositional: Input → Group("Batch Rename") → Loop → [file-rename] → Output
658        let json = r#"{
659            "nodes": [
660                { "id": "input", "type": "input", "version": "1.0.0",
661                  "name": "Input", "position": {"x": 0, "y": 0}, "metadata": {},
662                  "parameters": {}, "inputPorts": [], "outputPorts": [] },
663                {
664                    "id": "batch-rename", "type": "group", "version": "1.0.0",
665                    "name": "Batch Rename", "position": {"x": 250, "y": 100},
666                    "metadata": {},
667                    "parameters": {},
668                    "inputPorts": [], "outputPorts": [],
669                    "nodes": [
670                        {
671                            "id": "rename-loop", "type": "loop", "version": "1.0.0",
672                            "name": "Rename Each File", "position": {"x": 0, "y": 0},
673                            "metadata": {},
674                            "parameters": { "mode": "forEach" },
675                            "inputPorts": [], "outputPorts": [],
676                            "nodes": [
677                                {
678                                    "id": "rename-file", "type": "file-rename", "version": "1.0.0",
679                                    "name": "Rename File", "position": {"x": 0, "y": 0},
680                                    "metadata": {},
681                                    "parameters": { "prefix": "renamed-" },
682                                    "inputPorts": [], "outputPorts": []
683                                }
684                            ],
685                            "edges": []
686                        }
687                    ],
688                    "edges": []
689                },
690                { "id": "output", "type": "output", "version": "1.0.0",
691                  "name": "Output", "position": {"x": 0, "y": 0}, "metadata": {},
692                  "parameters": {}, "inputPorts": [], "outputPorts": [] }
693            ],
694            "edges": []
695        }"#;
696
697        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
698
699        // Middle node is the batch-rename group.
700        let group_node = &def.nodes[1];
701        assert_eq!(group_node.node_type, "group");
702        assert_eq!(group_node.id, "batch-rename");
703
704        // Group has 1 child (rename-loop).
705        let group_children = group_node.children.as_ref().unwrap();
706        assert_eq!(group_children.len(), 1);
707        assert_eq!(group_children[0].node_type, "loop");
708
709        // Loop has 1 child (rename-file processor).
710        let loop_children = group_children[0].children.as_ref().unwrap();
711        assert_eq!(loop_children.len(), 1);
712        assert_eq!(loop_children[0].node_type, "file-rename");
713        assert_eq!(loop_children[0].params["prefix"], "renamed-");
714    }
715
716    #[test]
717    fn test_deeply_nested_three_levels() {
718        // Group → Group → Loop → processor — 3 levels of nesting.
719        // All using TS field names ("nodes", "parameters").
720        let json = r#"{
721            "nodes": [
722                {
723                    "id": "outer-group", "type": "group",
724                    "parameters": {},
725                    "nodes": [
726                        {
727                            "id": "inner-group", "type": "group",
728                            "parameters": {},
729                            "nodes": [
730                                {
731                                    "id": "the-loop", "type": "loop",
732                                    "parameters": { "mode": "forEach" },
733                                    "nodes": [
734                                        {
735                                            "id": "processor", "type": "image-compress",
736                                            "parameters": { "quality": 50 }
737                                        }
738                                    ]
739                                }
740                            ]
741                        }
742                    ]
743                }
744            ]
745        }"#;
746
747        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
748
749        // Walk 3 levels deep.
750        let outer = &def.nodes[0];
751        assert_eq!(outer.node_type, "group");
752
753        let inner = &outer.children.as_ref().unwrap()[0];
754        assert_eq!(inner.node_type, "group");
755
756        let loop_node = &inner.children.as_ref().unwrap()[0];
757        assert_eq!(loop_node.node_type, "loop");
758
759        let processor = &loop_node.children.as_ref().unwrap()[0];
760        assert_eq!(processor.node_type, "image-compress");
761        assert_eq!(processor.params["quality"], 50);
762    }
763
764    // --- Helper Function Tests ---
765
766    // --- PipelineSettings & IterationMode Tests ---
767
768    #[test]
769    fn test_definition_without_settings_deserializes() {
770        let json = r#"{
771            "nodes": [
772                { "id": "n1", "type": "input" },
773                { "id": "n2", "type": "image-compress" },
774                { "id": "n3", "type": "output" }
775            ]
776        }"#;
777        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
778        assert!(def.settings.is_none());
779    }
780
781    #[test]
782    fn test_definition_with_auto_iteration_deserializes() {
783        let json = r#"{
784            "settings": { "iteration": "auto" },
785            "nodes": [
786                { "id": "n1", "type": "input" },
787                { "id": "n2", "type": "image-compress" },
788                { "id": "n3", "type": "output" }
789            ]
790        }"#;
791        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
792        let settings = def.settings.as_ref().unwrap();
793        assert_eq!(settings.iteration, IterationMode::Auto);
794    }
795
796    #[test]
797    fn test_definition_with_explicit_iteration_deserializes() {
798        let json = r#"{
799            "settings": { "iteration": "explicit" },
800            "nodes": [
801                { "id": "n1", "type": "image-compress" }
802            ]
803        }"#;
804        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
805        let settings = def.settings.as_ref().unwrap();
806        assert_eq!(settings.iteration, IterationMode::Explicit);
807    }
808
809    #[test]
810    fn test_definition_with_unknown_iteration_fails() {
811        let json = r#"{
812            "settings": { "iteration": "garbage" },
813            "nodes": [{ "id": "n1", "type": "input" }]
814        }"#;
815        let result = serde_json::from_str::<PipelineDefinition>(json);
816        assert!(result.is_err());
817    }
818
819    #[test]
820    fn test_resolved_iteration_defaults_explicit() {
821        let json = r#"{ "nodes": [] }"#;
822        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
823        assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
824    }
825
826    #[test]
827    fn test_resolved_iteration_returns_auto() {
828        let json = r#"{
829            "settings": { "iteration": "auto" },
830            "nodes": []
831        }"#;
832        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
833        assert_eq!(def.resolved_iteration(), IterationMode::Auto);
834    }
835
836    #[test]
837    fn test_settings_with_default_iteration_field() {
838        // Settings object present but iteration field absent — defaults to explicit.
839        let json = r#"{
840            "settings": {},
841            "nodes": []
842        }"#;
843        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
844        let settings = def.settings.as_ref().unwrap();
845        assert_eq!(settings.iteration, IterationMode::Explicit);
846        assert_eq!(def.resolved_iteration(), IterationMode::Explicit);
847    }
848
849    // --- Serialization Round-Trip Tests ---
850    // Verify PipelineDefinition and PipelineNode serialize and deserialize back.
851
852    #[test]
853    fn test_definition_round_trip_serialization() {
854        let json = r#"{
855            "nodes": [
856                { "id": "n1", "type": "input" },
857                { "id": "n2", "type": "image-compress", "params": { "quality": 80 } },
858                { "id": "n3", "type": "output" }
859            ],
860            "settings": { "iteration": "auto" }
861        }"#;
862
863        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
864        let serialized = serde_json::to_string(&def).unwrap();
865        let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
866
867        assert_eq!(round_tripped.nodes.len(), def.nodes.len());
868        for (orig, rt) in def.nodes.iter().zip(round_tripped.nodes.iter()) {
869            assert_eq!(orig.id, rt.id);
870            assert_eq!(orig.node_type, rt.node_type);
871        }
872        assert_eq!(round_tripped.resolved_iteration(), def.resolved_iteration());
873    }
874
875    #[test]
876    fn test_definition_serialization_preserves_params() {
877        let json = r#"{
878            "nodes": [
879                {
880                    "id": "n1",
881                    "type": "image-compress",
882                    "params": { "quality": 80, "preserveExif": true, "name": "test" }
883                }
884            ]
885        }"#;
886
887        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
888        let serialized = serde_json::to_string(&def).unwrap();
889        let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
890
891        let params = &round_tripped.nodes[0].params;
892        assert_eq!(params["quality"], 80);
893        assert_eq!(params["preserveExif"], true);
894        assert_eq!(params["name"], "test");
895    }
896
897    #[test]
898    fn test_definition_serialization_preserves_children() {
899        let json = r#"{
900            "nodes": [
901                {
902                    "id": "group-1",
903                    "type": "group",
904                    "children": [
905                        {
906                            "id": "loop-1",
907                            "type": "loop",
908                            "children": [
909                                { "id": "proc-1", "type": "image-compress", "params": { "quality": 50 } }
910                            ]
911                        }
912                    ]
913                }
914            ]
915        }"#;
916
917        let def: PipelineDefinition = serde_json::from_str(json).unwrap();
918        let serialized = serde_json::to_string(&def).unwrap();
919        let round_tripped: PipelineDefinition = serde_json::from_str(&serialized).unwrap();
920
921        let group = &round_tripped.nodes[0];
922        assert_eq!(group.node_type, "group");
923        let loop_node = &group.children.as_ref().unwrap()[0];
924        assert_eq!(loop_node.node_type, "loop");
925        let proc_node = &loop_node.children.as_ref().unwrap()[0];
926        assert_eq!(proc_node.node_type, "image-compress");
927        assert_eq!(proc_node.params["quality"], 50);
928    }
929
930    #[test]
931    fn test_iteration_mode_serializes_camel_case() {
932        let auto_json = serde_json::to_string(&IterationMode::Auto).unwrap();
933        assert_eq!(auto_json, r#""auto""#);
934
935        let explicit_json = serde_json::to_string(&IterationMode::Explicit).unwrap();
936        assert_eq!(explicit_json, r#""explicit""#);
937    }
938
939    #[test]
940    fn test_pipeline_settings_serializes() {
941        let settings = PipelineSettings {
942            iteration: IterationMode::Auto,
943        };
944        let json = serde_json::to_string(&settings).unwrap();
945        assert!(json.contains(r#""iteration":"auto""#));
946    }
947
948    // --- Helper Function Tests ---
949
950    #[test]
951    fn test_is_io_node() {
952        assert!(is_io_node("input"));
953        assert!(is_io_node("output"));
954        assert!(!is_io_node("image-compress"));
955        assert!(!is_io_node("spreadsheet-clean"));
956        assert!(!is_io_node("loop"));
957    }
958
959    #[test]
960    fn test_is_container_node() {
961        assert!(is_container_node("loop"));
962        assert!(is_container_node("group"));
963        assert!(is_container_node("parallel"));
964        assert!(!is_container_node("image-compress"));
965        assert!(!is_container_node("input"));
966        assert!(!is_container_node("output"));
967    }
968
969    // --- InputMode Resolution Tests ---
970
971    #[test]
972    fn test_resolve_input_mode_file_upload() {
973        let def = parse_definition(
974            r#"{
975                "formatVersion": "1.0.0",
976                "nodes": [
977                    { "id": "in", "type": "input", "params": { "mode": "file-upload" } },
978                    { "id": "proc", "type": "image-compress", "params": {} },
979                    { "id": "out", "type": "output", "params": {} }
980                ]
981            }"#,
982        );
983        assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
984    }
985
986    #[test]
987    fn test_resolve_input_mode_url() {
988        let def = parse_definition(
989            r#"{
990                "formatVersion": "1.0.0",
991                "nodes": [
992                    { "id": "in", "type": "input", "params": { "mode": "url" } },
993                    { "id": "proc", "type": "video-download", "params": {} },
994                    { "id": "out", "type": "output", "params": {} }
995                ]
996            }"#,
997        );
998        assert_eq!(resolve_input_mode(&def), InputMode::Url);
999    }
1000
1001    #[test]
1002    fn test_resolve_input_mode_text() {
1003        let def = parse_definition(
1004            r#"{
1005                "formatVersion": "1.0.0",
1006                "nodes": [
1007                    { "id": "in", "type": "input", "params": { "mode": "text" } },
1008                    { "id": "proc", "type": "text-transform", "params": {} },
1009                    { "id": "out", "type": "output", "params": {} }
1010                ]
1011            }"#,
1012        );
1013        assert_eq!(resolve_input_mode(&def), InputMode::Text);
1014    }
1015
1016    #[test]
1017    fn test_resolve_input_mode_missing_defaults() {
1018        let def = parse_definition(
1019            r#"{
1020                "formatVersion": "1.0.0",
1021                "nodes": [
1022                    { "id": "in", "type": "input", "params": {} },
1023                    { "id": "proc", "type": "image-compress", "params": {} }
1024                ]
1025            }"#,
1026        );
1027        assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1028    }
1029
1030    #[test]
1031    fn test_resolve_input_mode_no_input_node() {
1032        let def = parse_definition(
1033            r#"{
1034                "formatVersion": "1.0.0",
1035                "nodes": [
1036                    { "id": "proc", "type": "image-compress", "params": {} },
1037                    { "id": "out", "type": "output", "params": {} }
1038                ]
1039            }"#,
1040        );
1041        assert_eq!(resolve_input_mode(&def), InputMode::FileUpload);
1042    }
1043
1044    #[test]
1045    fn test_resolve_input_mode_nested() {
1046        let def = parse_definition(
1047            r#"{
1048                "formatVersion": "1.0.0",
1049                "nodes": [
1050                    {
1051                        "id": "group-1",
1052                        "type": "group",
1053                        "params": {},
1054                        "children": [
1055                            { "id": "in", "type": "input", "params": { "mode": "url" } },
1056                            { "id": "proc", "type": "video-download", "params": {} }
1057                        ]
1058                    },
1059                    { "id": "out", "type": "output", "params": {} }
1060                ]
1061            }"#,
1062        );
1063        assert_eq!(resolve_input_mode(&def), InputMode::Url);
1064    }
1065
1066    // --- first_processing_node_id Tests ---
1067
1068    #[test]
1069    fn test_first_processing_node_id_simple() {
1070        let def = parse_definition(
1071            r#"{
1072                "formatVersion": "1.0.0",
1073                "nodes": [
1074                    { "id": "in", "type": "input", "params": {} },
1075                    { "id": "compress", "type": "image-compress", "params": {} },
1076                    { "id": "out", "type": "output", "params": {} }
1077                ]
1078            }"#,
1079        );
1080        assert_eq!(first_processing_node_id(&def), Some("compress".to_string()));
1081    }
1082
1083    #[test]
1084    fn test_first_processing_node_id_none() {
1085        let def = parse_definition(
1086            r#"{
1087                "formatVersion": "1.0.0",
1088                "nodes": [
1089                    { "id": "in", "type": "input", "params": {} },
1090                    { "id": "out", "type": "output", "params": {} }
1091                ]
1092            }"#,
1093        );
1094        assert_eq!(first_processing_node_id(&def), None);
1095    }
1096
1097    #[test]
1098    fn test_first_processing_node_id_nested() {
1099        let def = parse_definition(
1100            r#"{
1101                "formatVersion": "1.0.0",
1102                "nodes": [
1103                    { "id": "in", "type": "input", "params": {} },
1104                    {
1105                        "id": "loop-1",
1106                        "type": "loop",
1107                        "params": {},
1108                        "children": [
1109                            { "id": "resize", "type": "image-resize", "params": {} },
1110                            { "id": "compress", "type": "image-compress", "params": {} }
1111                        ]
1112                    },
1113                    { "id": "out", "type": "output", "params": {} }
1114                ]
1115            }"#,
1116        );
1117        assert_eq!(first_processing_node_id(&def), Some("resize".to_string()));
1118    }
1119}