Skip to main content

dora_core/descriptor/
expand.rs

1use super::classify;
2use dora_message::{
3    config::{Input, InputMapping, UserInputMapping},
4    descriptor::{
5        DYNAMIC_SOURCE, Descriptor, EnvValue, Node, OperatorConfig, OperatorSource, SHELL_SOURCE,
6    },
7    id::{DataId, NodeId},
8};
9use eyre::{Context, bail};
10use serde::Deserialize;
11use std::{
12    collections::{BTreeMap, BTreeSet, HashSet},
13    path::{Path, PathBuf},
14};
15
16use super::normalize_path;
17
18/// Check if a path string is absolute on any platform.
19/// On Windows, `Path::is_absolute()` returns false for Unix-style `/foo` paths,
20/// so we also check for a leading `/` to catch cross-platform absolute paths.
21fn is_absolute_any_platform(path: &str) -> bool {
22    Path::new(path).is_absolute() || path.starts_with('/')
23}
24
25/// Maximum nesting depth for recursive module expansion. Prevents unbounded
26/// recursion from deeply nested or circular module graphs that evade the
27/// path-based cycle check. 8 levels covers realistic robotics pipelines
28/// while keeping memory usage bounded.
29const MAX_MODULE_DEPTH: u8 = 8;
30
31/// Maximum module file size (1 MB). Prevents DoS from huge or infinite files.
32const MAX_MODULE_FILE_SIZE: u64 = 1_048_576;
33
34/// Reserved node-ID prefix used inside module files to reference module inputs.
35/// Usage in module YAML: `_mod/port_name`
36const MODULE_INPUT_SOURCE: &str = "_mod";
37
38/// Where the outputs a module declares end up after expansion: maps each
39/// declared output name to the mapping a consumer must use to read it — the
40/// prefixed inner node ID plus the output name as that node addresses it
41/// (see [`node_output_refs`]).
42type ModuleOutputMap = BTreeMap<String, UserInputMapping>;
43
44/// Header section of a module definition file.
45#[derive(Debug, Clone, Deserialize)]
46#[serde(deny_unknown_fields)]
47struct ModuleHeader {
48    name: String,
49    #[serde(default)]
50    inputs: Vec<DataId>,
51    #[serde(default)]
52    inputs_optional: Vec<DataId>,
53    #[serde(default)]
54    outputs: Vec<DataId>,
55}
56
57/// A module definition file (`*_module.yml`).
58#[derive(Debug, Clone, Deserialize)]
59#[serde(deny_unknown_fields)]
60struct ModuleFile {
61    module: ModuleHeader,
62    nodes: Vec<Node>,
63    /// Module-level build command, runs before inner node builds.
64    #[serde(default)]
65    build: Option<String>,
66}
67
68/// Metadata about which nodes came from which module, used for graph
69/// visualization with subgraph boundaries.
70#[derive(Debug, Clone, Default)]
71pub struct ModuleBoundaries {
72    /// Maps module_id -> list of expanded node IDs that belong to it.
73    pub modules: BTreeMap<String, Vec<String>>,
74}
75
76/// Result of expanding modules, including boundary metadata for visualization.
77#[derive(Debug, Clone)]
78pub struct ExpandedDescriptor {
79    pub descriptor: Descriptor,
80    pub boundaries: ModuleBoundaries,
81}
82
83/// Expand all module references in `descriptor` into flat nodes.
84///
85/// Module nodes are replaced by the inner nodes from the referenced module
86/// file. Internal IDs are prefixed with `{module_id}.` and all input/output
87/// wiring is rewritten so the result is a plain flat descriptor.
88pub fn expand_modules(descriptor: &Descriptor, base_dir: &Path) -> eyre::Result<Descriptor> {
89    Ok(expand_modules_with_boundaries(descriptor, base_dir)?.descriptor)
90}
91
92/// Like [`expand_modules`] but also returns module boundary metadata for
93/// visualization.
94pub fn expand_modules_with_boundaries(
95    descriptor: &Descriptor,
96    base_dir: &Path,
97) -> eyre::Result<ExpandedDescriptor> {
98    let has_modules = descriptor.nodes.iter().any(|n| n.module.is_some());
99    if !has_modules {
100        return Ok(ExpandedDescriptor {
101            descriptor: descriptor.clone(),
102            boundaries: ModuleBoundaries::default(),
103        });
104    }
105
106    let canonical_base = base_dir
107        .canonicalize()
108        .with_context(|| format!("failed to resolve base directory: {}", base_dir.display()))?;
109    let mut seen = HashSet::new();
110    let mut flat_nodes = Vec::new();
111    let mut output_maps: BTreeMap<String, ModuleOutputMap> = BTreeMap::new();
112    let mut boundaries = ModuleBoundaries::default();
113
114    for node in &descriptor.nodes {
115        if node.module.is_some() {
116            // Field validation happens inside `expand_module_node`, so top-level
117            // and nested module nodes go through the same whitelist.
118            let (mut expanded, omap) =
119                expand_module_node(node, base_dir, &canonical_base, 0, &mut seen)?;
120            // Propagate the module node's own `build` to each expanded leaf node,
121            // mirroring how a nested module node's build is propagated in Phase 2
122            // of `expand_module_node`. `check_module` accepts `build` for exactly
123            // this reason.
124            if let Some(ref outer_build) = node.build {
125                for expanded_node in &mut expanded {
126                    prepend_module_build_to_node(expanded_node, outer_build);
127                }
128            }
129            let module_id = node.id.to_string();
130            output_maps.insert(module_id.clone(), omap);
131            let node_ids: Vec<String> = expanded.iter().map(|n| n.id.to_string()).collect();
132            boundaries.modules.insert(module_id, node_ids);
133            flat_nodes.extend(expanded);
134        } else {
135            flat_nodes.push(node.clone());
136        }
137    }
138
139    rewrite_external_refs(&mut flat_nodes, &output_maps)?;
140
141    // Verify expanded node IDs are unique
142    let mut id_set = HashSet::new();
143    for node in &flat_nodes {
144        if !id_set.insert(node.id.to_string()) {
145            bail!(
146                "duplicate node ID `{}` after module expansion — check for \
147                 conflicting node names across modules and top-level nodes",
148                node.id
149            );
150        }
151    }
152
153    // Expansion rewrites `nodes` and nothing else, so clone the source
154    // descriptor and swap that one field. Listing the dataflow-level options
155    // individually would silently drop any option added later — `Descriptor` is
156    // `#[non_exhaustive]`, so a missing field is no longer a compile error. This
157    // also keeps the with-modules path structurally identical to the early
158    // return above, which already clones.
159    let mut expanded = descriptor.clone();
160    expanded.nodes = flat_nodes;
161
162    Ok(ExpandedDescriptor {
163        descriptor: expanded,
164        boundaries,
165    })
166}
167
168/// Validate a module file in isolation without expanding it into a dataflow.
169///
170/// Checks:
171/// - Module header is well-formed (required name, optional inputs/outputs)
172/// - All inner nodes are parseable
173/// - All `_mod/X` references point to declared inputs or optional inputs
174/// - All declared outputs are produced by some inner node (or nested module)
175/// - No circular references within the module
176pub fn check_module_file(module_path: &Path) -> eyre::Result<()> {
177    let canonical = module_path
178        .canonicalize()
179        .with_context(|| format!("module file not found: {}", module_path.display()))?;
180    let mut seen = HashSet::new();
181    check_module_file_inner(&canonical, 0, &mut seen)
182}
183
184fn check_module_file_inner(
185    canonical: &Path,
186    depth: u8,
187    seen: &mut HashSet<PathBuf>,
188) -> eyre::Result<()> {
189    if depth >= MAX_MODULE_DEPTH {
190        bail!(
191            "module nesting exceeds depth limit of {MAX_MODULE_DEPTH} while checking module file: {}",
192            canonical.display()
193        );
194    }
195
196    if !seen.insert(canonical.to_path_buf()) {
197        bail!(
198            "circular module reference detected while checking module file: {}\n\
199             hint: check that module files do not reference each other in a cycle",
200            canonical.display()
201        );
202    }
203
204    let module_file = load_module_file(canonical)?;
205    validate_module_header(&module_file.module)?;
206    let module_dir = canonical
207        .parent()
208        .expect("module file must have a parent directory");
209
210    // Collect all declared + optional input names
211    let all_input_names: BTreeSet<String> = module_file
212        .module
213        .inputs
214        .iter()
215        .chain(module_file.module.inputs_optional.iter())
216        .map(|d| d.to_string())
217        .collect();
218
219    // Check _mod/ references point to declared inputs. Runtime (operator)
220    // and legacy custom nodes wire their inputs through config.inputs /
221    // run_config.inputs rather than the node-level `inputs` map, so those
222    // need the same check (see #2441).
223    for node in &module_file.nodes {
224        for inputs in node_input_maps(node) {
225            check_mod_refs(&module_file.module.name, &node.id, inputs, &all_input_names)?;
226        }
227    }
228    let module_outputs = collect_module_source_outputs(&module_file, module_dir)?;
229    check_internal_wiring(
230        &module_file.module.name,
231        &module_file.nodes,
232        &module_outputs,
233    )?;
234
235    // Check outputs: each declared output should be produced by some inner node.
236    // For nested module children, recursively load their declared outputs.
237    // Runtime (operator) and legacy custom nodes declare their outputs in
238    // config.outputs / run_config.outputs rather than the node-level `outputs`
239    // set, so `node_output_refs` collects those too (see #2817).
240    let mut inner_outputs: BTreeMap<String, Vec<String>> = BTreeMap::new();
241    for node in module_file.nodes.iter().filter(|n| n.module.is_none()) {
242        for (name, output_ref) in node_output_refs(node) {
243            inner_outputs
244                .entry(name)
245                .or_default()
246                .push(format!("{}/{}", node.id, output_ref));
247        }
248    }
249
250    // Check nested module files exist and collect their declared outputs
251    for node in &module_file.nodes {
252        if let Some(ref mod_path) = node.module {
253            if is_absolute_any_platform(mod_path) {
254                bail!(
255                    "module `{}`: nested module path `{}` must be relative (node `{}`)",
256                    module_file.module.name,
257                    mod_path,
258                    node.id,
259                );
260            }
261            // The same field whitelist applies at every nesting level.
262            // Without this, `dora expand --module m.yml` reports a file as
263            // valid while `dora run` on a dataflow using it hard-fails.
264            classify::check_module(node)
265                .with_context(|| format!("invalid module node `{}`", node.id))?;
266            let nested = module_dir.join(mod_path);
267            let nested_canonical = nested.canonicalize().with_context(|| {
268                format!(
269                    "module `{}`: nested module `{}` referenced by node `{}` not found",
270                    module_file.module.name, mod_path, node.id,
271                )
272            })?;
273            // Note: unlike `expand_module_node`, we intentionally do NOT reject
274            // a nested reference that leaves `module_dir`. The real expansion
275            // path confines nested modules to the *project root*
276            // (`canonical_base`, threaded through recursion), which routinely
277            // sits above an individual module's directory -- a module in
278            // `modules/a/` may reference a sibling module in `modules/shared/`
279            // via `../shared/base.yml`. This linter runs on a module file in
280            // isolation, with no project root to bound against, so a
281            // `module_dir` containment check would spuriously reject
282            // cross-directory references that `dora run` / `dora build` accept
283            // and run fine (see #2851). We keep the absolute-path rejection and
284            // the `canonicalize()` existence check above; the containment
285            // boundary is enforced by the real expansion path, not this lint.
286            //
287            // Load nested module to collect its declared outputs.
288            let nested_module = load_module_file(&nested_canonical)?;
289            check_nested_module_required_inputs(
290                &module_file.module.name,
291                &node.id,
292                &nested_module.module,
293                &node.inputs,
294            )?;
295            check_module_file_inner(&nested_canonical, depth + 1, seen).with_context(|| {
296                format!(
297                    "module `{}`: while checking nested module `{}` referenced by node `{}`",
298                    module_file.module.name, nested_module.module.name, node.id,
299                )
300            })?;
301            for output in &nested_module.module.outputs {
302                inner_outputs
303                    .entry(output.to_string())
304                    .or_default()
305                    .push(format!("{}/{}", node.id, output));
306            }
307        }
308    }
309
310    for declared_output in &module_file.module.outputs {
311        let output_str = declared_output.to_string();
312        match inner_outputs.get(&output_str) {
313            None => {
314                bail!(
315                    "module `{}` declares output `{}` but no inner node produces it",
316                    module_file.module.name,
317                    declared_output,
318                );
319            }
320            Some(producers) if producers.len() > 1 => {
321                bail!(
322                    "module `{}` declares output `{}` but multiple inner nodes produce it: {}",
323                    module_file.module.name,
324                    declared_output,
325                    producers.join(", "),
326                );
327            }
328            Some(_) => {}
329        }
330    }
331
332    seen.remove(canonical);
333    Ok(())
334}
335
336fn collect_module_source_outputs(
337    module_file: &ModuleFile,
338    module_dir: &Path,
339) -> eyre::Result<BTreeMap<String, BTreeSet<String>>> {
340    let mut outputs = BTreeMap::new();
341    for node in &module_file.nodes {
342        let node_id = node.id.to_string();
343        if outputs.contains_key(&node_id) {
344            bail!(
345                "module `{}` has duplicate node ID `{}`",
346                module_file.module.name,
347                node.id,
348            );
349        }
350
351        let node_outputs = if let Some(ref mod_path) = node.module {
352            if is_absolute_any_platform(mod_path) {
353                bail!(
354                    "module `{}`: nested module path `{}` must be relative (node `{}`)",
355                    module_file.module.name,
356                    mod_path,
357                    node.id,
358                );
359            }
360            let nested = module_dir.join(mod_path);
361            let nested_canonical = nested.canonicalize().with_context(|| {
362                format!(
363                    "module `{}`: nested module `{}` referenced by node `{}` not found",
364                    module_file.module.name, mod_path, node.id,
365                )
366            })?;
367            let nested_module = load_module_file(&nested_canonical)?;
368            nested_module
369                .module
370                .outputs
371                .iter()
372                .map(|output| output.to_string())
373                .collect()
374        } else {
375            node_output_refs(node)
376                .into_iter()
377                .map(|(_, output_ref)| output_ref)
378                .collect()
379        };
380
381        outputs.insert(node_id, node_outputs);
382    }
383    Ok(outputs)
384}
385
386fn check_internal_wiring(
387    module_name: &str,
388    nodes: &[Node],
389    module_outputs: &BTreeMap<String, BTreeSet<String>>,
390) -> eyre::Result<()> {
391    for node in nodes {
392        for inputs in node_input_maps(node) {
393            for (input_id, input) in inputs {
394                if let InputMapping::User(mapping) = &input.mapping {
395                    let source = mapping.source.to_string();
396                    if source == MODULE_INPUT_SOURCE {
397                        continue;
398                    }
399                    if let Some(outputs) = module_outputs.get(&source) {
400                        let output = mapping.output.to_string();
401                        if !outputs.contains(&output) {
402                            bail!(
403                                "module `{}`: node `{}` input `{}` references \
404                                 `{}/{}` but that output is not produced",
405                                module_name,
406                                node.id,
407                                input_id,
408                                source,
409                                output,
410                            );
411                        }
412                    }
413                }
414            }
415        }
416    }
417    Ok(())
418}
419
420fn validate_module_header(module: &ModuleHeader) -> eyre::Result<()> {
421    reject_duplicate_ports(&module.name, "inputs", &module.inputs)?;
422    reject_duplicate_ports(&module.name, "inputs_optional", &module.inputs_optional)?;
423    reject_duplicate_ports(&module.name, "outputs", &module.outputs)?;
424
425    let required: BTreeSet<_> = module.inputs.iter().collect();
426    if let Some(overlap) = module
427        .inputs_optional
428        .iter()
429        .find(|input| required.contains(input))
430    {
431        bail!(
432            "module `{}` input `{}` is declared as both required and optional",
433            module.name,
434            overlap
435        );
436    }
437    Ok(())
438}
439
440fn reject_duplicate_ports(module_name: &str, field: &str, ports: &[DataId]) -> eyre::Result<()> {
441    let mut seen = BTreeSet::new();
442    for port in ports {
443        if !seen.insert(port) {
444            bail!("module `{module_name}` has duplicate `{field}` entry `{port}`");
445        }
446    }
447    Ok(())
448}
449
450fn check_nested_module_required_inputs(
451    module_name: &str,
452    node_id: &NodeId,
453    nested_module: &ModuleHeader,
454    node_inputs: &BTreeMap<DataId, Input>,
455) -> eyre::Result<()> {
456    for declared_input in &nested_module.inputs {
457        if !node_inputs.contains_key(declared_input) {
458            bail!(
459                "module `{}`: nested module `{}` declares required input `{}` \
460                 but node `{}` does not provide it",
461                module_name,
462                nested_module.name,
463                declared_input,
464                node_id,
465            );
466        }
467    }
468    Ok(())
469}
470
471/// Check that every `_mod/X` reference in `inputs` points to a declared
472/// module input (or optional input). Shared by the node-level `inputs`
473/// check and the operator/custom `config.inputs` / `run_config.inputs`
474/// checks in [`check_module_file`].
475fn check_mod_refs(
476    module_name: &str,
477    node_id: &NodeId,
478    inputs: &BTreeMap<DataId, Input>,
479    all_input_names: &BTreeSet<String>,
480) -> eyre::Result<()> {
481    for (input_id, input) in inputs {
482        if let InputMapping::User(m) = &input.mapping
483            && m.source.to_string() == MODULE_INPUT_SOURCE
484        {
485            let port = m.output.to_string();
486            if !all_input_names.contains(&port) {
487                bail!(
488                    "module `{}`: node `{}` input `{}` references \
489                         `_mod/{}` but `{}` is not declared in module \
490                         inputs or inputs_optional",
491                    module_name,
492                    node_id,
493                    input_id,
494                    port,
495                    port,
496                );
497            }
498        }
499    }
500    Ok(())
501}
502
503/// All the places a node's inputs can live: the node-level `inputs` map,
504/// plus the operator/custom `config.inputs` / `run_config.inputs` maps for
505/// runtime (operator) and legacy custom inner nodes. Centralizes the
506/// node-kind enumeration so `_mod/`-reference validation and rewriting stay
507/// in sync as node kinds are added or change (see #2441).
508fn node_input_maps(node: &Node) -> Vec<&BTreeMap<DataId, Input>> {
509    let mut maps = vec![&node.inputs];
510    if let Some(ref operators) = node.operators {
511        maps.extend(operators.operators.iter().map(|op| &op.config.inputs));
512    }
513    if let Some(ref operator) = node.operator {
514        maps.push(&operator.config.inputs);
515    }
516    maps
517}
518
519/// Mutable counterpart of [`node_input_maps`], used by the Phase 1 rewrite
520/// pass in [`expand_module_node`].
521fn node_input_maps_mut(node: &mut Node) -> Vec<&mut BTreeMap<DataId, Input>> {
522    let mut maps = vec![&mut node.inputs];
523    if let Some(ref mut operators) = node.operators {
524        maps.extend(
525            operators
526                .operators
527                .iter_mut()
528                .map(|op| &mut op.config.inputs),
529        );
530    }
531    if let Some(ref mut operator) = node.operator {
532        maps.push(&mut operator.config.inputs);
533    }
534    maps
535}
536
537/// Every output an inner node produces, as `(output_name, output_ref)` pairs
538/// where `output_ref` is how a consumer addresses that output on this node.
539///
540/// The two differ only for multi-operator runtime nodes: their outputs live in
541/// `operators[].config.outputs` and must be referenced as
542/// `<operator_id>/<output>`. Node-level `outputs` and single `operator:`
543/// outputs are both referenced by
544/// their bare name — for `operator:` the `op/` prefix is injected later by
545/// `resolve_aliases_and_set_defaults`, so adding it here would double it up.
546///
547/// Output-side counterpart of [`node_input_maps`]: centralizes the node-kind
548/// enumeration so module output resolution stays in sync as node kinds are
549/// added or change (see #2817).
550fn node_output_refs(node: &Node) -> Vec<(String, String)> {
551    fn bare(outputs: &BTreeSet<DataId>) -> impl Iterator<Item = (String, String)> {
552        outputs.iter().map(|o| (o.to_string(), o.to_string()))
553    }
554
555    let mut refs: Vec<(String, String)> = bare(&node.outputs).collect();
556    if let Some(ref operators) = node.operators {
557        for op in &operators.operators {
558            refs.extend(
559                op.config
560                    .outputs
561                    .iter()
562                    .map(|o| (o.to_string(), format!("{}/{o}", op.id))),
563            );
564        }
565    }
566    if let Some(ref operator) = node.operator {
567        refs.extend(bare(&operator.config.outputs));
568    }
569    refs
570}
571
572/// Expand a single module node into its constituent flat nodes.
573///
574/// Returns `(expanded_nodes, output_map)`; see [`ModuleOutputMap`].
575fn expand_module_node(
576    node: &Node,
577    base_dir: &Path,
578    canonical_base: &Path,
579    depth: u8,
580    seen: &mut HashSet<PathBuf>,
581) -> eyre::Result<(Vec<Node>, ModuleOutputMap)> {
582    if depth >= MAX_MODULE_DEPTH {
583        bail!(
584            "module nesting exceeds depth limit of {MAX_MODULE_DEPTH} \
585             (node `{}`)",
586            node.id
587        );
588    }
589
590    // Validate the module node's fields against the module whitelist. This is
591    // the single validation site for both top-level and nested module nodes, so
592    // a field that has no meaning on a module node (e.g. `outputs`,
593    // `cpu_affinity`) is rejected here rather than silently dropped during
594    // expansion, at every nesting level.
595    classify::check_module(node).with_context(|| format!("invalid module node `{}`", node.id))?;
596
597    let module_path_str = node
598        .module
599        .as_ref()
600        .expect("expand_module_node called on non-module node");
601
602    // Security: reject absolute paths and path traversal
603    if is_absolute_any_platform(module_path_str) {
604        bail!(
605            "module path `{}` must be relative (node `{}`)",
606            module_path_str,
607            node.id
608        );
609    }
610
611    let module_path = base_dir.join(module_path_str);
612    let canonical = module_path
613        .canonicalize()
614        .with_context(|| format!("module file not found: {}", module_path.display()))?;
615
616    if !canonical.starts_with(canonical_base) {
617        bail!(
618            "module path `{}` escapes the project directory (node `{}`)",
619            module_path_str,
620            node.id
621        );
622    }
623
624    if !seen.insert(canonical.clone()) {
625        bail!(
626            "circular module reference detected: {} (node `{}`)\n\
627             hint: check that module files do not reference each other \
628             in a cycle",
629            module_path.display(),
630            node.id
631        );
632    }
633
634    let module_file = load_module_file(&canonical)?;
635    validate_module_header(&module_file.module)?;
636    let module_id = node.id.to_string();
637    let module_dir = canonical
638        .parent()
639        .expect("module file must have a parent directory");
640
641    // Validate: all required module inputs are provided by the node's inputs
642    for declared_input in &module_file.module.inputs {
643        if !node.inputs.contains_key(declared_input) {
644            bail!(
645                "module `{}` declares required input `{}` but node `{}` \
646                 does not provide it\n\
647                 hint: add `{}: <source_node>/<output>` to the node's inputs",
648                module_file.module.name,
649                declared_input,
650                node.id,
651                declared_input,
652            );
653        }
654    }
655    // Optional inputs: no error if missing — inner nodes referencing them
656    // will simply have their input removed during rewrite.
657
658    // Build optional input set once for fast lookup
659    let optional_inputs: BTreeSet<String> = module_file
660        .module
661        .inputs_optional
662        .iter()
663        .map(|d| d.to_string())
664        .collect();
665    let declared_inputs: BTreeSet<String> = module_file
666        .module
667        .inputs
668        .iter()
669        .chain(module_file.module.inputs_optional.iter())
670        .map(|d| d.to_string())
671        .collect();
672    for provided_input in node.inputs.keys() {
673        let provided = provided_input.to_string();
674        if !declared_inputs.contains(&provided) {
675            bail!(
676                "module `{}` does not declare input `{}` provided by node `{}`\n\
677                 hint: declared inputs are: {}",
678                module_file.module.name,
679                provided_input,
680                node.id,
681                declared_inputs
682                    .iter()
683                    .cloned()
684                    .collect::<Vec<_>>()
685                    .join(", "),
686            );
687        }
688    }
689
690    // Validate and collect params
691    let mut seen_upper: BTreeMap<String, &String> = BTreeMap::new();
692    for key in node.params.keys() {
693        if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || key.is_empty() {
694            bail!(
695                "invalid param key `{}` in node `{}`: must be non-empty and \
696                 contain only [A-Za-z0-9_]",
697                key,
698                node.id
699            );
700        }
701        // Params are injected into the node env as `PARAM_<KEY>` with the key
702        // upper-cased (see `substitute_params_in_node`). Two keys that differ
703        // only in case (e.g. `mode` and `Mode`) would both map to `PARAM_MODE`,
704        // so one would silently overwrite the other. Reject the collision
705        // instead of dropping a param.
706        let upper = key.to_uppercase();
707        if let Some(existing) = seen_upper.insert(upper.clone(), key) {
708            bail!(
709                "param keys `{}` and `{}` in node `{}` collide: both map to the \
710                 env var `PARAM_{}`. Param keys must be unique case-insensitively.",
711                existing,
712                key,
713                node.id,
714                upper
715            );
716        }
717    }
718    let params = &node.params;
719
720    // Build set of inner node IDs for cross-reference rewriting
721    let inner_node_ids: BTreeSet<String> =
722        module_file.nodes.iter().map(|n| n.id.to_string()).collect();
723
724    // Phase 1: prefix IDs, rewrite inputs, resolve paths, substitute params
725    let mut prefixed_nodes = Vec::new();
726    for mut inner_node in module_file.nodes {
727        let prefixed_id: NodeId = format!("{module_id}.{}", inner_node.id).into();
728        inner_node.id = prefixed_id;
729
730        // Rewrite inputs (None = optional input not provided, skip it).
731        // Runtime (operator) nodes and legacy custom nodes wire their inputs
732        // through config.inputs / run_config.inputs rather than the
733        // node-level `inputs` map, so `node_input_maps_mut` rewrites all of
734        // them the same way — otherwise `_mod/` references and sibling
735        // cross-references wouldn't resolve for operator/custom inner nodes
736        // (see #2441).
737        for inputs in node_input_maps_mut(&mut inner_node) {
738            *inputs = rewrite_module_inputs_map(
739                inputs,
740                &module_id,
741                &node.inputs,
742                &inner_node_ids,
743                &optional_inputs,
744            )?;
745        }
746
747        resolve_inner_node_paths(&mut inner_node, module_dir, canonical_base)?;
748
749        // Propagate deploy from module node to inner nodes
750        if inner_node.deploy.is_none() {
751            inner_node.deploy = node.deploy.clone();
752        }
753
754        propagate_module_node_env(&mut inner_node, node.env.as_ref());
755
756        // Substitute params in env values
757        if !params.is_empty() {
758            substitute_params_in_node(&mut inner_node, params);
759        }
760
761        // Prepend module-level build command to inner node builds
762        if let Some(ref module_build) = module_file.build {
763            prepend_module_build_to_node(&mut inner_node, module_build);
764        }
765
766        prefixed_nodes.push(inner_node);
767    }
768
769    // Phase 2: recursively expand nested modules (before building output map)
770    // Collect nested output maps so sibling nodes can reference nested module
771    // outputs correctly via rewrite_external_refs.
772    let mut nested_output_maps: BTreeMap<String, ModuleOutputMap> = BTreeMap::new();
773    let mut direct_output_targets: BTreeMap<String, Vec<(String, UserInputMapping)>> =
774        BTreeMap::new();
775    let mut final_nodes = Vec::new();
776    for inner_node in prefixed_nodes {
777        if inner_node.module.is_some() {
778            let nested_id = inner_node.id.to_string();
779            let accumulated_build = inner_node.build.clone();
780            let (mut nested, nested_omap) =
781                expand_module_node(&inner_node, module_dir, canonical_base, depth + 1, seen)?;
782            // Propagate the outer module's accumulated build to each nested leaf node,
783            // mirroring how `deploy` is propagated through recursion.
784            if let Some(ref outer_build) = accumulated_build {
785                for nested_node in &mut nested {
786                    prepend_module_build_to_node(nested_node, outer_build);
787                }
788            }
789            for (output, target) in &nested_omap {
790                direct_output_targets
791                    .entry(output.clone())
792                    .or_default()
793                    .push((format!("{nested_id}/{output}"), target.clone()));
794            }
795            nested_output_maps.insert(nested_id, nested_omap);
796            final_nodes.extend(nested);
797        } else {
798            for (name, output_ref) in node_output_refs(&inner_node) {
799                direct_output_targets.entry(name).or_default().push((
800                    format!("{}/{}", inner_node.id, output_ref),
801                    UserInputMapping {
802                        source: inner_node.id.clone(),
803                        output: output_ref.into(),
804                    },
805                ));
806            }
807            final_nodes.push(inner_node);
808        }
809    }
810
811    // Rewrite sibling references that point to nested module outputs
812    if !nested_output_maps.is_empty() {
813        rewrite_external_refs(&mut final_nodes, &nested_output_maps)?;
814    }
815
816    // Phase 3: build output map from direct children only. A producer may
817    // be a plain node, a runtime node's operator, or a legacy custom node, so
818    // the lookup goes through `node_output_refs` — which also yields the form
819    // consumers must use to address the output (see #2817).
820    let mut output_map = ModuleOutputMap::new();
821    for declared_output in &module_file.module.outputs {
822        let declared = declared_output.to_string();
823        let target = match direct_output_targets.get(&declared).map(Vec::as_slice) {
824            None | Some([]) => {
825                bail!(
826                    "module `{}` declares output `{}` but no inner node produces it",
827                    module_file.module.name,
828                    declared_output,
829                );
830            }
831            Some([(_, target)]) => target.clone(),
832            Some(targets) => {
833                let producers = targets
834                    .iter()
835                    .map(|(producer, _)| producer.as_str())
836                    .collect::<Vec<_>>()
837                    .join(", ");
838                bail!(
839                    "module `{}` declares output `{}` but multiple inner nodes produce it: {}",
840                    module_file.module.name,
841                    declared_output,
842                    producers,
843                );
844            }
845        };
846        output_map.insert(declared, target);
847    }
848
849    // Remove from seen so the same module file can be used in different
850    // branches (multiple instances)
851    seen.remove(&canonical);
852
853    Ok((final_nodes, output_map))
854}
855
856fn resolve_inner_node_paths(
857    node: &mut Node,
858    module_dir: &Path,
859    canonical_base: &Path,
860) -> eyre::Result<()> {
861    let owner = node.id.to_string();
862    if let Some(ref mut path) = node.path {
863        resolve_module_relative_path(path, module_dir, canonical_base, &owner)?;
864    }
865    if let Some(ref mut operators) = node.operators {
866        for op in &mut operators.operators {
867            resolve_operator_source_paths(&mut op.config, module_dir, canonical_base, &owner)?;
868        }
869    }
870    if let Some(ref mut operator) = node.operator {
871        resolve_operator_source_paths(&mut operator.config, module_dir, canonical_base, &owner)?;
872    }
873    Ok(())
874}
875
876fn resolve_operator_source_paths(
877    config: &mut OperatorConfig,
878    module_dir: &Path,
879    canonical_base: &Path,
880    owner: &str,
881) -> eyre::Result<()> {
882    match &mut config.source {
883        OperatorSource::SharedLibrary(path) | OperatorSource::Wasm(path) => {
884            resolve_module_relative_path(path, module_dir, canonical_base, owner)
885        }
886        OperatorSource::Python(source) => {
887            resolve_module_relative_path(&mut source.source, module_dir, canonical_base, owner)
888        }
889    }
890}
891
892fn resolve_module_relative_path(
893    path: &mut String,
894    module_dir: &Path,
895    canonical_base: &Path,
896    owner: &str,
897) -> eyre::Result<()> {
898    // Resolve relative paths: make inner node/operator sources relative to
899    // base_dir. Normalize lexically (collapse `..`) before the containment
900    // check so paths like `../../evil` are detected even if the binary does
901    // not exist yet (ruling out filesystem canonicalize).
902    if path == DYNAMIC_SOURCE
903        || path == SHELL_SOURCE
904        || super::source_is_url(path)
905        // `Path::is_absolute()` is false for a Unix-style `/foo` on Windows,
906        // where `module_dir.join("/foo")` then rewrites it into the module
907        // directory and the confinement check below rejects it with a
908        // misleading "resolves outside the project directory".
909        || is_absolute_any_platform(path)
910    {
911        return Ok(());
912    }
913
914    let resolved = normalize_path(&module_dir.join(path.as_str()));
915    let relative = resolved.strip_prefix(canonical_base).map_err(|_| {
916        eyre::eyre!(
917            "module node `{}` path `{}` resolves outside the project \
918                 directory (resolved to `{}`)",
919            owner,
920            path,
921            resolved.display()
922        )
923    })?;
924    *path = relative.to_string_lossy().into_owned();
925    Ok(())
926}
927
928fn prepend_module_build_to_node(node: &mut Node, module_build: &str) {
929    // The node-level `build` is consumed by every standard node -- `path:`,
930    // `git:`, and `hub:` sourced -- (`build/mod.rs` reads `n.build` for all
931    // `CoreNodeKind::Custom` nodes) and by nested `module:` nodes (which
932    // forward it to their own leaves in Phase 2). It is *not* valid on
933    // runtime/operator/ROS2 nodes, where the field classifier rejects it, so
934    // for those the module build lands in the operator configs only.
935    //
936    // Do NOT key this off `node.path`: module expansion runs *before* git/hub
937    // source resolution, so a `git:`/`hub:`-sourced inner node still has
938    // `path == None` here. Keying off `path` silently dropped the module build
939    // for those nodes (#3296). Gate on "not a runtime/operator/ROS2 node"
940    // instead, which correctly includes git/hub standard leaves.
941    let is_standard_or_module = node.module.is_some()
942        || (node.operators.is_none() && node.operator.is_none() && node.ros2.is_none());
943    if is_standard_or_module {
944        prepend_build(&mut node.build, module_build);
945    }
946    if let Some(ref mut operators) = node.operators {
947        for op in &mut operators.operators {
948            prepend_build(&mut op.config.build, module_build);
949        }
950    }
951    if let Some(ref mut operator) = node.operator {
952        prepend_build(&mut operator.config.build, module_build);
953    }
954}
955
956fn prepend_build(build: &mut Option<String>, module_build: &str) {
957    let existing = build.take();
958    *build = Some(match existing {
959        Some(existing) => format!("{module_build}\n{existing}"),
960        None => module_build.to_string(),
961    });
962}
963
964/// Fill an inner node's `env` with entries from the module node that references
965/// it.
966///
967/// The inner node's own `env` wins on conflict: the module node's entries only
968/// fill keys the inner node did not set. This matches `merge_env`'s "per-node
969/// entries override global ones" and the `deploy` propagation above, which also
970/// only fills when the inner value is unset.
971fn propagate_module_node_env(
972    inner_node: &mut Node,
973    module_env: Option<&BTreeMap<String, EnvValue>>,
974) {
975    let Some(module_env) = module_env.filter(|env| !env.is_empty()) else {
976        return;
977    };
978    let env = inner_node.env.get_or_insert_with(BTreeMap::new);
979    for (key, value) in module_env {
980        env.entry(key.clone()).or_insert_with(|| value.clone());
981    }
982}
983
984/// Substitute `${_param.name}` and `$PARAM_<NAME>` references in a node's args
985/// and inject params into the node's env map as `EnvValue::String` entries.
986fn substitute_params_in_node(node: &mut Node, params: &BTreeMap<String, String>) {
987    // Substitute in args
988    if let Some(ref mut args) = node.args {
989        *args = substitute_params_in_str(args, params);
990    }
991
992    // Inject params into env map (caller params override inner defaults)
993    let env = node.env.get_or_insert_with(BTreeMap::new);
994    for (key, value) in params {
995        env.insert(
996            format!("PARAM_{}", key.to_uppercase()),
997            EnvValue::String(value.clone()),
998        );
999    }
1000}
1001
1002/// Replace every parameter reference with its value in a single left-to-right
1003/// pass. Two forms are accepted:
1004///
1005/// - `${_param.<key>}` -- delimited; the key is matched case-sensitively and
1006///   the closing brace terminates it.
1007/// - `$PARAM_<KEY>` -- the documented shell-style form (see `docs/modules.md`),
1008///   matched against the upper-cased key. Having no delimiter it extends over
1009///   the maximal run of `[A-Za-z0-9_]`, so `$PARAM_SPEED_LIMIT` is a single
1010///   token and never partially matches a declared `speed` (#2901).
1011///
1012/// A previous implementation looped over the params calling `String::replace`
1013/// on the accumulating result, which had two problems: the outcome depended on
1014/// `BTreeMap` key ordering, and a parameter *value* that itself contained a
1015/// `${_param.…}` token (or a literal one a user wanted to keep) was expanded
1016/// transitively. Scanning once and never re-examining substituted text makes
1017/// substitution simultaneous and order-independent. Unknown keys are left
1018/// verbatim, matching the old behavior of only replacing keys present in
1019/// `params`.
1020fn substitute_params_in_str(s: &str, params: &BTreeMap<String, String>) -> String {
1021    const BRACED: &str = "${_param.";
1022    const ENV_STYLE: &str = "$PARAM_";
1023
1024    // `$PARAM_<KEY>` matches against the uppercased param name, the same
1025    // mapping `substitute_params_in_node` uses for the injected env vars.
1026    // Keys that differ only in case are rejected before we get here, so this
1027    // map cannot silently drop a param.
1028    let env_style: BTreeMap<String, &String> = params
1029        .iter()
1030        .map(|(key, value)| (key.to_uppercase(), value))
1031        .collect();
1032
1033    let mut result = String::with_capacity(s.len());
1034    let mut rest = s;
1035    while let Some(start) = rest.find('$') {
1036        result.push_str(&rest[..start]);
1037        let at_token = &rest[start..];
1038
1039        if let Some(after_prefix) = at_token.strip_prefix(BRACED) {
1040            match after_prefix.find('}') {
1041                Some(end) => {
1042                    let key = &after_prefix[..end];
1043                    match params.get(key) {
1044                        Some(value) => result.push_str(value),
1045                        // Unknown key: emit the token unchanged rather than dropping it.
1046                        None => {
1047                            result.push_str(BRACED);
1048                            result.push_str(key);
1049                            result.push('}');
1050                        }
1051                    }
1052                    // Continue *after* the closing brace so a substituted value is
1053                    // never re-scanned for further tokens.
1054                    rest = &after_prefix[end + 1..];
1055                }
1056                // No closing brace: emit the prefix literally and continue past it
1057                // (guarantees progress, so the loop always terminates).
1058                None => {
1059                    result.push_str(BRACED);
1060                    rest = after_prefix;
1061                }
1062            }
1063        } else if let Some(after_prefix) = at_token.strip_prefix(ENV_STYLE) {
1064            // The undelimited form ends at the first character that cannot be
1065            // part of a shell identifier, so `$PARAM_SPEED_LIMIT` is one token
1066            // and never matches a declared `speed` as a prefix (#2901).
1067            let end = after_prefix
1068                .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1069                .unwrap_or(after_prefix.len());
1070            let key = &after_prefix[..end];
1071            match env_style.get(key) {
1072                Some(value) => result.push_str(value),
1073                // Unknown key: emit the token unchanged rather than dropping it.
1074                None => {
1075                    result.push_str(ENV_STYLE);
1076                    result.push_str(key);
1077                }
1078            }
1079            rest = &after_prefix[end..];
1080        } else {
1081            // A bare `$` that starts neither form: emit it and step past, so
1082            // the loop always makes progress.
1083            result.push('$');
1084            rest = &at_token[1..];
1085        }
1086    }
1087    result.push_str(rest);
1088    result
1089}
1090
1091/// Rewrite a single input mapping inside a module's inner node.
1092///
1093/// - `_mod/X` references are resolved to the actual source from the module
1094///   node's inputs map. Returns `None` if the port is optional and not provided.
1095/// - Internal cross-references (`sibling_node/output`) are prefixed with the
1096///   module ID.
1097/// - Timer inputs pass through unchanged.
1098fn rewrite_module_input(
1099    input: &Input,
1100    module_id: &str,
1101    module_inputs: &BTreeMap<DataId, Input>,
1102    inner_node_ids: &BTreeSet<String>,
1103    optional_inputs: &BTreeSet<String>,
1104) -> eyre::Result<Option<Input>> {
1105    match &input.mapping {
1106        InputMapping::Timer { .. } | InputMapping::Logs(_) => Ok(Some(input.clone())),
1107        InputMapping::User(user_mapping) => {
1108            let source_str = user_mapping.source.to_string();
1109
1110            if source_str == MODULE_INPUT_SOURCE {
1111                // `_mod/port_name` -> resolve from module node's inputs
1112                let port_name = user_mapping.output.to_string();
1113                match module_inputs.get(&*port_name) {
1114                    Some(bound_input) => {
1115                        // Preserve queue_size/timeout from the inner node if set,
1116                        // otherwise fall back to the module node's binding
1117                        Ok(Some(Input {
1118                            mapping: bound_input.mapping.clone(),
1119                            queue_size: input.queue_size.or(bound_input.queue_size),
1120                            input_timeout: input.input_timeout.or(bound_input.input_timeout),
1121                            queue_policy: input.queue_policy.or(bound_input.queue_policy),
1122                        }))
1123                    }
1124                    None if optional_inputs.contains(&port_name) => Ok(None),
1125                    None => bail!(
1126                        "module input reference `_mod/{}` not found in module node inputs",
1127                        port_name,
1128                    ),
1129                }
1130            } else if inner_node_ids.contains(&source_str) {
1131                // Internal cross-reference: prefix with module_id
1132                Ok(Some(Input {
1133                    mapping: InputMapping::User(UserInputMapping {
1134                        source: format!("{module_id}.{source_str}").into(),
1135                        output: user_mapping.output.clone(),
1136                    }),
1137                    queue_size: input.queue_size,
1138                    input_timeout: input.input_timeout,
1139                    queue_policy: input.queue_policy,
1140                }))
1141            } else {
1142                // External reference — pass through unchanged
1143                Ok(Some(input.clone()))
1144            }
1145        }
1146    }
1147}
1148
1149/// Rewrite every input in `inputs` via [`rewrite_module_input`], dropping
1150/// entries whose optional module input wasn't provided. Used for both the
1151/// node-level `inputs` map and the operator/custom `config.inputs` /
1152/// `run_config.inputs` maps in Phase 1 of [`expand_module_node`].
1153fn rewrite_module_inputs_map(
1154    inputs: &BTreeMap<DataId, Input>,
1155    module_id: &str,
1156    module_inputs: &BTreeMap<DataId, Input>,
1157    inner_node_ids: &BTreeSet<String>,
1158    optional_inputs: &BTreeSet<String>,
1159) -> eyre::Result<BTreeMap<DataId, Input>> {
1160    let mut new_inputs = BTreeMap::new();
1161    for (input_id, input) in inputs {
1162        if let Some(new_input) = rewrite_module_input(
1163            input,
1164            module_id,
1165            module_inputs,
1166            inner_node_ids,
1167            optional_inputs,
1168        )? {
1169            new_inputs.insert(input_id.clone(), new_input);
1170        }
1171    }
1172    Ok(new_inputs)
1173}
1174
1175/// Rewrite inputs across all nodes that reference module outputs.
1176///
1177/// If node X has input `nav_stack/cmd_vel` and `nav_stack` was a module whose
1178/// output `cmd_vel` is produced by inner node `controller`, rewrite to
1179/// `nav_stack.controller/cmd_vel`. If the producer is an operator of a runtime
1180/// node, the rewritten output keeps the operator segment the runtime node
1181/// requires: `nav_stack.runtime/controller_op/cmd_vel`.
1182fn rewrite_external_refs(
1183    nodes: &mut [Node],
1184    output_maps: &BTreeMap<String, ModuleOutputMap>,
1185) -> eyre::Result<()> {
1186    if output_maps.is_empty() {
1187        return Ok(());
1188    }
1189
1190    for node in nodes.iter_mut() {
1191        rewrite_inputs_map(&mut node.inputs, output_maps, &node.id)?;
1192
1193        if let Some(ref mut operators) = node.operators {
1194            for op in &mut operators.operators {
1195                rewrite_inputs_map(&mut op.config.inputs, output_maps, &node.id)?;
1196            }
1197        }
1198        if let Some(ref mut operator) = node.operator {
1199            rewrite_inputs_map(&mut operator.config.inputs, output_maps, &node.id)?;
1200        }
1201    }
1202    Ok(())
1203}
1204
1205fn rewrite_inputs_map(
1206    inputs: &mut BTreeMap<DataId, Input>,
1207    output_maps: &BTreeMap<String, ModuleOutputMap>,
1208    node_id: &NodeId,
1209) -> eyre::Result<()> {
1210    let mut new_inputs = BTreeMap::new();
1211    for (input_id, input) in inputs.iter() {
1212        let new_input = match &input.mapping {
1213            InputMapping::User(user_mapping) => {
1214                let source_str = user_mapping.source.to_string();
1215                if let Some(omap) = output_maps.get(&source_str) {
1216                    let output_str = user_mapping.output.to_string();
1217                    if let Some(target) = omap.get(&output_str) {
1218                        Input {
1219                            mapping: InputMapping::User(target.clone()),
1220                            queue_size: input.queue_size,
1221                            input_timeout: input.input_timeout,
1222                            queue_policy: input.queue_policy,
1223                        }
1224                    } else {
1225                        bail!(
1226                            "node `{}` references `{}/{}` but module `{}` \
1227                             does not declare output `{}`",
1228                            node_id,
1229                            source_str,
1230                            output_str,
1231                            source_str,
1232                            output_str,
1233                        );
1234                    }
1235                } else {
1236                    input.clone()
1237                }
1238            }
1239            InputMapping::Timer { .. } | InputMapping::Logs(_) => input.clone(),
1240        };
1241        new_inputs.insert(input_id.clone(), new_input);
1242    }
1243    *inputs = new_inputs;
1244    Ok(())
1245}
1246
1247fn load_module_file(path: &Path) -> eyre::Result<ModuleFile> {
1248    use std::io::Read as _;
1249    // Bound the read itself, not just a `metadata().len()` pre-check: that
1250    // check reports 0 for FIFOs/character devices (e.g. `/dev/zero`) and is
1251    // racy against a file being appended to, so it would sail past the cap and
1252    // then `std::fs::read` unboundedly — exactly the "infinite files" DoS the
1253    // limit is meant to prevent. `take(MAX + 1)` caps the read regardless of
1254    // what the file claims its size is; if we hit the cap we reject. Mirrors
1255    // the manifest loader in `crate::manifest`.
1256    let file = std::fs::File::open(path)
1257        .with_context(|| format!("failed to read module file: {}", path.display()))?;
1258    let mut buf = Vec::new();
1259    file.take(MAX_MODULE_FILE_SIZE + 1)
1260        .read_to_end(&mut buf)
1261        .with_context(|| format!("failed to read module file: {}", path.display()))?;
1262    if buf.len() as u64 > MAX_MODULE_FILE_SIZE {
1263        bail!(
1264            "module file too large (limit {} bytes): {}",
1265            MAX_MODULE_FILE_SIZE,
1266            path.display()
1267        );
1268    }
1269    serde_yaml::from_slice(&buf)
1270        .with_context(|| format!("failed to parse module file: {}", path.display()))
1271}
1272
1273#[cfg(test)]
1274mod tests {
1275    use super::*;
1276    use std::io::Write;
1277    use tempfile::TempDir;
1278
1279    fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
1280        let path = dir.join(name);
1281        if let Some(parent) = path.parent() {
1282            std::fs::create_dir_all(parent).unwrap();
1283        }
1284        let mut f = std::fs::File::create(&path).unwrap();
1285        f.write_all(content.as_bytes()).unwrap();
1286        path
1287    }
1288
1289    fn parse_descriptor(yaml: &str) -> Descriptor {
1290        serde_yaml::from_str(yaml).unwrap()
1291    }
1292
1293    /// dora-rs/dora#2920: a dataflow-level setting that expansion drops is
1294    /// silently lost for every dataflow that uses modules. The completion
1295    /// policy decides whether the graph can ever end, so losing it turns a
1296    /// batch run into a hang.
1297    ///
1298    /// Both paths now clone the source descriptor, so this is a regression
1299    /// guard rather than the primary defense. The descriptor MUST still
1300    /// contain a module: without one, `expand_modules_with_boundaries`
1301    /// short-circuits before the expansion path this exercises.
1302    #[test]
1303    fn expand_preserves_exit_when_nodes_finish() {
1304        let tmp = TempDir::new().unwrap();
1305        let base = tmp.path();
1306        write_file(
1307            base,
1308            "echo_module.yml",
1309            r#"
1310module:
1311  name: echo
1312  inputs: [data_in]
1313  outputs: [data_out]
1314
1315nodes:
1316  - id: passthrough
1317    path: echo.py
1318    inputs:
1319      incoming: _mod/data_in
1320    outputs:
1321      - data_out
1322"#,
1323        );
1324        let descriptor = parse_descriptor(
1325            r#"
1326exit_when_nodes_finish: true
1327
1328nodes:
1329  - id: source
1330    path: source.py
1331    outputs:
1332      - number
1333
1334  - id: my_echo
1335    module: echo_module.yml
1336    inputs:
1337      data_in: source/number
1338"#,
1339        );
1340        assert_eq!(descriptor.exit_when_nodes_finish, Some(true));
1341        assert!(
1342            descriptor.nodes.iter().any(|n| n.module.is_some()),
1343            "precondition: without a module, expansion clones the whole \
1344             descriptor and this test would pass even if the rebuild \
1345             dropped the field"
1346        );
1347
1348        let expanded = expand_modules(&descriptor, base).unwrap();
1349        assert_eq!(
1350            expanded.exit_when_nodes_finish,
1351            Some(true),
1352            "expansion must carry the completion policy through, or a \
1353             module-using dataflow silently loses it and never ends"
1354        );
1355    }
1356
1357    /// Absent means absent: it must not become `Some(false)`, which
1358    /// would be indistinguishable from an explicit opt out and would
1359    /// start round-tripping into serialized output.
1360    #[test]
1361    fn expand_leaves_exit_when_nodes_finish_unset() {
1362        let tmp = TempDir::new().unwrap();
1363        let descriptor = parse_descriptor(
1364            "nodes:\n  \
1365               - id: worker\n    \
1366                 path: ./worker\n",
1367        );
1368        assert_eq!(descriptor.exit_when_nodes_finish, None);
1369        let expanded = expand_modules(&descriptor, tmp.path()).unwrap();
1370        assert_eq!(expanded.exit_when_nodes_finish, None);
1371    }
1372
1373    #[test]
1374    fn load_module_file_rejects_oversized_file() {
1375        let tmp = TempDir::new().unwrap();
1376        // A valid module prefix followed by padding that pushes the file just
1377        // over the cap. The read is bounded, so this is rejected before the
1378        // whole (potentially unbounded) file is pulled into memory.
1379        let mut content = String::from("module:\n  name: big\n  outputs: [x]\nnodes: []\n");
1380        content.push_str("# ");
1381        content.push_str(&"a".repeat((MAX_MODULE_FILE_SIZE + 16) as usize));
1382        let path = write_file(tmp.path(), "big_module.yml", &content);
1383
1384        let err = load_module_file(&path).unwrap_err().to_string();
1385        assert!(err.contains("too large"), "unexpected error: {err}");
1386    }
1387
1388    #[test]
1389    fn load_module_file_accepts_file_at_limit() {
1390        let tmp = TempDir::new().unwrap();
1391        // Exactly the cap (padding the comment out to MAX_MODULE_FILE_SIZE
1392        // bytes total) must still load — the `+ 1` in `take` is what makes the
1393        // boundary inclusive.
1394        let prefix = "module:\n  name: ok\n  outputs: [x]\nnodes: []\n# ";
1395        let pad = (MAX_MODULE_FILE_SIZE as usize) - prefix.len();
1396        let content = format!("{prefix}{}", "a".repeat(pad));
1397        assert_eq!(content.len() as u64, MAX_MODULE_FILE_SIZE);
1398        let path = write_file(tmp.path(), "ok_module.yml", &content);
1399
1400        assert!(load_module_file(&path).is_ok());
1401    }
1402
1403    #[test]
1404    fn expand_flat_passthrough() {
1405        let tmp = TempDir::new().unwrap();
1406        let base = tmp.path();
1407
1408        write_file(
1409            base,
1410            "echo_module.yml",
1411            r#"
1412module:
1413  name: echo
1414  inputs: [data_in]
1415  outputs: [data_out]
1416
1417nodes:
1418  - id: passthrough
1419    path: echo.py
1420    inputs:
1421      incoming: _mod/data_in
1422    outputs:
1423      - data_out
1424"#,
1425        );
1426
1427        let desc = parse_descriptor(
1428            r#"
1429nodes:
1430  - id: source
1431    path: source.py
1432    outputs:
1433      - number
1434
1435  - id: my_echo
1436    module: echo_module.yml
1437    inputs:
1438      data_in: source/number
1439
1440  - id: sink
1441    path: sink.py
1442    inputs:
1443      result: my_echo/data_out
1444"#,
1445        );
1446
1447        let expanded = expand_modules(&desc, base).unwrap();
1448
1449        assert_eq!(expanded.nodes.len(), 3);
1450        let names: Vec<_> = expanded.nodes.iter().map(|n| n.id.to_string()).collect();
1451        assert!(names.contains(&"my_echo.passthrough".to_string()));
1452        assert!(!names.contains(&"my_echo".to_string()));
1453
1454        let passthrough = expanded
1455            .nodes
1456            .iter()
1457            .find(|n| n.id.to_string() == "my_echo.passthrough")
1458            .unwrap();
1459        let incoming = &passthrough.inputs[&DataId::from("incoming".to_string())];
1460        match &incoming.mapping {
1461            InputMapping::User(m) => {
1462                assert_eq!(m.source.to_string(), "source");
1463                assert_eq!(m.output.to_string(), "number");
1464            }
1465            _ => panic!("expected user mapping"),
1466        }
1467
1468        let sink = expanded
1469            .nodes
1470            .iter()
1471            .find(|n| n.id.to_string() == "sink")
1472            .unwrap();
1473        let result = &sink.inputs[&DataId::from("result".to_string())];
1474        match &result.mapping {
1475            InputMapping::User(m) => {
1476                assert_eq!(m.source.to_string(), "my_echo.passthrough");
1477                assert_eq!(m.output.to_string(), "data_out");
1478            }
1479            _ => panic!("expected user mapping"),
1480        }
1481    }
1482
1483    #[test]
1484    fn expand_internal_cross_ref() {
1485        let tmp = TempDir::new().unwrap();
1486        let base = tmp.path();
1487
1488        write_file(
1489            base,
1490            "pipeline_module.yml",
1491            r#"
1492module:
1493  name: pipeline
1494  inputs: [data_in]
1495  outputs: [data_out]
1496
1497nodes:
1498  - id: stage_a
1499    path: a.py
1500    inputs:
1501      raw: _mod/data_in
1502    outputs:
1503      - intermediate
1504
1505  - id: stage_b
1506    path: b.py
1507    inputs:
1508      intermediate: stage_a/intermediate
1509    outputs:
1510      - data_out
1511"#,
1512        );
1513
1514        let desc = parse_descriptor(
1515            r#"
1516nodes:
1517  - id: src
1518    path: src.py
1519    outputs: [val]
1520  - id: pipe
1521    module: pipeline_module.yml
1522    inputs:
1523      data_in: src/val
1524  - id: dst
1525    path: dst.py
1526    inputs:
1527      result: pipe/data_out
1528"#,
1529        );
1530
1531        let expanded = expand_modules(&desc, base).unwrap();
1532
1533        let stage_b = expanded
1534            .nodes
1535            .iter()
1536            .find(|n| n.id.to_string() == "pipe.stage_b")
1537            .unwrap();
1538        let inter = &stage_b.inputs[&DataId::from("intermediate".to_string())];
1539        match &inter.mapping {
1540            InputMapping::User(m) => {
1541                assert_eq!(m.source.to_string(), "pipe.stage_a");
1542                assert_eq!(m.output.to_string(), "intermediate");
1543            }
1544            _ => panic!("expected user mapping"),
1545        }
1546    }
1547
1548    #[test]
1549    fn expand_depth_limit() {
1550        let tmp = TempDir::new().unwrap();
1551        let base = tmp.path();
1552
1553        for i in 0..=MAX_MODULE_DEPTH {
1554            let next = if i < MAX_MODULE_DEPTH {
1555                format!(
1556                    "  - id: inner\n    module: level{}_module.yml\n    inputs:\n      x: _mod/x",
1557                    i + 1
1558                )
1559            } else {
1560                "  - id: inner\n    path: leaf.py\n    inputs:\n      x: _mod/x\n    outputs:\n      - y".to_string()
1561            };
1562
1563            write_file(
1564                base,
1565                &format!("level{i}_module.yml"),
1566                &format!(
1567                    r#"
1568module:
1569  name: level{i}
1570  inputs: [x]
1571  outputs: [y]
1572
1573nodes:
1574{next}
1575"#
1576                ),
1577            );
1578        }
1579
1580        let desc = parse_descriptor(
1581            r#"
1582nodes:
1583  - id: root
1584    module: level0_module.yml
1585    inputs:
1586      x: somewhere/val
1587"#,
1588        );
1589
1590        let result = expand_modules(&desc, base);
1591        assert!(result.is_err());
1592        assert!(
1593            result
1594                .unwrap_err()
1595                .to_string()
1596                .contains("nesting exceeds depth limit")
1597        );
1598    }
1599
1600    #[test]
1601    fn expand_circular_reference() {
1602        let tmp = TempDir::new().unwrap();
1603        let base = tmp.path();
1604
1605        write_file(
1606            base,
1607            "self_module.yml",
1608            r#"
1609module:
1610  name: self_ref
1611  inputs: [x]
1612  outputs: [y]
1613
1614nodes:
1615  - id: recurse
1616    module: self_module.yml
1617    inputs:
1618      x: _mod/x
1619"#,
1620        );
1621
1622        let desc = parse_descriptor(
1623            r#"
1624nodes:
1625  - id: top
1626    module: self_module.yml
1627    inputs:
1628      x: somewhere/val
1629"#,
1630        );
1631
1632        let result = expand_modules(&desc, base);
1633        assert!(result.is_err());
1634        let err_msg = result.unwrap_err().to_string();
1635        assert!(err_msg.contains("circular module reference"));
1636        // Feature 8: better error message with hint
1637        assert!(err_msg.contains("hint"));
1638    }
1639
1640    #[test]
1641    fn expand_missing_module_file() {
1642        let tmp = TempDir::new().unwrap();
1643        let base = tmp.path();
1644
1645        let desc = parse_descriptor(
1646            r#"
1647nodes:
1648  - id: broken
1649    module: nonexistent_module.yml
1650    inputs:
1651      x: somewhere/val
1652"#,
1653        );
1654
1655        let result = expand_modules(&desc, base);
1656        assert!(result.is_err());
1657        assert!(
1658            result
1659                .unwrap_err()
1660                .to_string()
1661                .contains("nonexistent_module.yml")
1662        );
1663    }
1664
1665    #[test]
1666    fn expand_undefined_input_port() {
1667        let tmp = TempDir::new().unwrap();
1668        let base = tmp.path();
1669
1670        write_file(
1671            base,
1672            "needs_input_module.yml",
1673            r#"
1674module:
1675  name: needs_input
1676  inputs: [required_port]
1677  outputs: [out]
1678
1679nodes:
1680  - id: inner
1681    path: inner.py
1682    inputs:
1683      x: _mod/required_port
1684    outputs:
1685      - out
1686"#,
1687        );
1688
1689        let desc = parse_descriptor(
1690            r#"
1691nodes:
1692  - id: mod_node
1693    module: needs_input_module.yml
1694    inputs:
1695      wrong_name: somewhere/val
1696"#,
1697        );
1698
1699        let result = expand_modules(&desc, base);
1700        assert!(result.is_err());
1701        let err_msg = result.unwrap_err().to_string();
1702        assert!(err_msg.contains("required_port"));
1703        // Feature 8: hint in error
1704        assert!(err_msg.contains("hint"));
1705    }
1706
1707    #[test]
1708    fn expand_rejects_duplicate_module_header_inputs() {
1709        let tmp = TempDir::new().unwrap();
1710        let base = tmp.path();
1711
1712        write_file(
1713            base,
1714            "dup_header_module.yml",
1715            r#"
1716module:
1717  name: dup_header
1718  inputs: [data, data]
1719  outputs: [out]
1720
1721nodes:
1722  - id: inner
1723    path: inner.py
1724    inputs:
1725      x: _mod/data
1726    outputs:
1727      - out
1728"#,
1729        );
1730
1731        let desc = parse_descriptor(
1732            r#"
1733nodes:
1734  - id: src
1735    path: src.py
1736    outputs: [val]
1737  - id: m
1738    module: dup_header_module.yml
1739    inputs:
1740      data: src/val
1741"#,
1742        );
1743
1744        let result = expand_modules(&desc, base);
1745        assert!(result.is_err());
1746        let msg = result.unwrap_err().to_string();
1747        assert!(msg.contains("duplicate"), "got: {msg}");
1748        assert!(msg.contains("inputs"), "got: {msg}");
1749        assert!(msg.contains("data"), "got: {msg}");
1750    }
1751
1752    #[test]
1753    fn expand_undefined_output_port() {
1754        let tmp = TempDir::new().unwrap();
1755        let base = tmp.path();
1756
1757        write_file(
1758            base,
1759            "bad_output_module.yml",
1760            r#"
1761module:
1762  name: bad_output
1763  inputs: []
1764  outputs: [nonexistent]
1765
1766nodes:
1767  - id: inner
1768    path: inner.py
1769    outputs:
1770      - something_else
1771"#,
1772        );
1773
1774        let desc = parse_descriptor(
1775            r#"
1776nodes:
1777  - id: mod_node
1778    module: bad_output_module.yml
1779"#,
1780        );
1781
1782        let result = expand_modules(&desc, base);
1783        assert!(result.is_err());
1784        assert!(result.unwrap_err().to_string().contains("nonexistent"));
1785    }
1786
1787    #[test]
1788    fn expand_no_modules_passthrough() {
1789        let desc = parse_descriptor(
1790            r#"
1791nodes:
1792  - id: a
1793    path: a.py
1794    outputs: [x]
1795  - id: b
1796    path: b.py
1797    inputs:
1798      x: a/x
1799"#,
1800        );
1801
1802        let tmp = TempDir::new().unwrap();
1803        let expanded = expand_modules(&desc, tmp.path()).unwrap();
1804        assert_eq!(expanded.nodes.len(), 2);
1805        assert_eq!(expanded.nodes[0].id.to_string(), "a");
1806        assert_eq!(expanded.nodes[1].id.to_string(), "b");
1807    }
1808
1809    #[test]
1810    fn expand_multiple_instances() {
1811        let tmp = TempDir::new().unwrap();
1812        let base = tmp.path();
1813
1814        write_file(
1815            base,
1816            "filter_module.yml",
1817            r#"
1818module:
1819  name: filter
1820  inputs: [raw]
1821  outputs: [filtered]
1822
1823nodes:
1824  - id: proc
1825    path: filter.py
1826    inputs:
1827      data: _mod/raw
1828    outputs:
1829      - filtered
1830"#,
1831        );
1832
1833        let desc = parse_descriptor(
1834            r#"
1835nodes:
1836  - id: cam1
1837    path: cam.py
1838    outputs: [frame]
1839  - id: cam2
1840    path: cam.py
1841    outputs: [frame]
1842  - id: filter1
1843    module: filter_module.yml
1844    inputs:
1845      raw: cam1/frame
1846  - id: filter2
1847    module: filter_module.yml
1848    inputs:
1849      raw: cam2/frame
1850  - id: merger
1851    path: merge.py
1852    inputs:
1853      a: filter1/filtered
1854      b: filter2/filtered
1855"#,
1856        );
1857
1858        let expanded = expand_modules(&desc, base).unwrap();
1859
1860        let names: Vec<_> = expanded.nodes.iter().map(|n| n.id.to_string()).collect();
1861        assert!(names.contains(&"filter1.proc".to_string()));
1862        assert!(names.contains(&"filter2.proc".to_string()));
1863        assert_eq!(expanded.nodes.len(), 5);
1864
1865        let merger = expanded
1866            .nodes
1867            .iter()
1868            .find(|n| n.id.to_string() == "merger")
1869            .unwrap();
1870        let a_input = &merger.inputs[&DataId::from("a".to_string())];
1871        match &a_input.mapping {
1872            InputMapping::User(m) => assert_eq!(m.source.to_string(), "filter1.proc"),
1873            _ => panic!("expected user mapping"),
1874        }
1875        let b_input = &merger.inputs[&DataId::from("b".to_string())];
1876        match &b_input.mapping {
1877            InputMapping::User(m) => assert_eq!(m.source.to_string(), "filter2.proc"),
1878            _ => panic!("expected user mapping"),
1879        }
1880    }
1881
1882    #[test]
1883    fn expand_nested_modules() {
1884        let tmp = TempDir::new().unwrap();
1885        let base = tmp.path();
1886
1887        write_file(
1888            base,
1889            "inner_module.yml",
1890            r#"
1891module:
1892  name: inner
1893  inputs: [x]
1894  outputs: [y]
1895
1896nodes:
1897  - id: leaf
1898    path: leaf.py
1899    inputs:
1900      x: _mod/x
1901    outputs:
1902      - y
1903"#,
1904        );
1905
1906        write_file(
1907            base,
1908            "outer_module.yml",
1909            r#"
1910module:
1911  name: outer
1912  inputs: [a]
1913  outputs: [y]
1914
1915nodes:
1916  - id: nested
1917    module: inner_module.yml
1918    inputs:
1919      x: _mod/a
1920"#,
1921        );
1922
1923        let desc = parse_descriptor(
1924            r#"
1925nodes:
1926  - id: src
1927    path: src.py
1928    outputs: [val]
1929  - id: wrapper
1930    module: outer_module.yml
1931    inputs:
1932      a: src/val
1933"#,
1934        );
1935
1936        let expanded = expand_modules(&desc, base).unwrap();
1937        let names: Vec<_> = expanded.nodes.iter().map(|n| n.id.to_string()).collect();
1938        assert!(names.contains(&"wrapper.nested.leaf".to_string()));
1939    }
1940
1941    // ---- Feature 3: optional inputs ----
1942
1943    #[test]
1944    fn expand_optional_input_provided() {
1945        let tmp = TempDir::new().unwrap();
1946        let base = tmp.path();
1947
1948        write_file(
1949            base,
1950            "opt_module.yml",
1951            r#"
1952module:
1953  name: opt
1954  inputs: [required]
1955  inputs_optional: [config]
1956  outputs: [out]
1957
1958nodes:
1959  - id: worker
1960    path: worker.py
1961    inputs:
1962      data: _mod/required
1963      cfg: _mod/config
1964    outputs:
1965      - out
1966"#,
1967        );
1968
1969        // Provide both required and optional
1970        let desc = parse_descriptor(
1971            r#"
1972nodes:
1973  - id: src
1974    path: src.py
1975    outputs: [data, cfg]
1976  - id: m
1977    module: opt_module.yml
1978    inputs:
1979      required: src/data
1980      config: src/cfg
1981"#,
1982        );
1983
1984        let expanded = expand_modules(&desc, base).unwrap();
1985        let worker = expanded
1986            .nodes
1987            .iter()
1988            .find(|n| n.id.to_string() == "m.worker")
1989            .unwrap();
1990        // Both inputs should be present
1991        assert_eq!(worker.inputs.len(), 2);
1992    }
1993
1994    #[test]
1995    fn expand_optional_input_omitted() {
1996        let tmp = TempDir::new().unwrap();
1997        let base = tmp.path();
1998
1999        write_file(
2000            base,
2001            "opt_module.yml",
2002            r#"
2003module:
2004  name: opt
2005  inputs: [required]
2006  inputs_optional: [config]
2007  outputs: [out]
2008
2009nodes:
2010  - id: worker
2011    path: worker.py
2012    inputs:
2013      data: _mod/required
2014      cfg: _mod/config
2015    outputs:
2016      - out
2017"#,
2018        );
2019
2020        // Only provide required, not optional
2021        let desc = parse_descriptor(
2022            r#"
2023nodes:
2024  - id: src
2025    path: src.py
2026    outputs: [data]
2027  - id: m
2028    module: opt_module.yml
2029    inputs:
2030      required: src/data
2031"#,
2032        );
2033
2034        let expanded = expand_modules(&desc, base).unwrap();
2035        let worker = expanded
2036            .nodes
2037            .iter()
2038            .find(|n| n.id.to_string() == "m.worker")
2039            .unwrap();
2040        // Only the required input should be present; optional was dropped
2041        assert_eq!(worker.inputs.len(), 1);
2042        assert!(
2043            worker
2044                .inputs
2045                .contains_key(&DataId::from("data".to_string()))
2046        );
2047    }
2048
2049    #[test]
2050    fn expand_rejects_module_input_not_declared_required_or_optional() {
2051        let tmp = TempDir::new().unwrap();
2052        let base = tmp.path();
2053
2054        write_file(
2055            base,
2056            "strict_inputs_module.yml",
2057            r#"
2058module:
2059  name: strict_inputs
2060  inputs: [required]
2061  inputs_optional: [config]
2062  outputs: [out]
2063
2064nodes:
2065  - id: worker
2066    path: worker.py
2067    inputs:
2068      data: _mod/required
2069    outputs:
2070      - out
2071"#,
2072        );
2073
2074        let desc = parse_descriptor(
2075            r#"
2076nodes:
2077  - id: src
2078    path: src.py
2079    outputs: [data, extra]
2080  - id: m
2081    module: strict_inputs_module.yml
2082    inputs:
2083      required: src/data
2084      typo: src/extra
2085"#,
2086        );
2087
2088        let result = expand_modules(&desc, base);
2089        assert!(result.is_err());
2090        let msg = result.unwrap_err().to_string();
2091        assert!(msg.contains("does not declare input"), "got: {msg}");
2092        assert!(msg.contains("typo"), "got: {msg}");
2093        assert!(msg.contains("required"), "got: {msg}");
2094        assert!(msg.contains("config"), "got: {msg}");
2095    }
2096
2097    // ---- Feature 4: params substitution ----
2098
2099    #[test]
2100    fn expand_params_in_env() {
2101        let tmp = TempDir::new().unwrap();
2102        let base = tmp.path();
2103
2104        write_file(
2105            base,
2106            "param_module.yml",
2107            r#"
2108module:
2109  name: parameterized
2110  inputs: [data]
2111  outputs: [out]
2112
2113nodes:
2114  - id: proc
2115    path: proc.py
2116    inputs:
2117      data: _mod/data
2118    outputs:
2119      - out
2120"#,
2121        );
2122
2123        let desc = parse_descriptor(
2124            r#"
2125nodes:
2126  - id: src
2127    path: src.py
2128    outputs: [val]
2129  - id: m
2130    module: param_module.yml
2131    inputs:
2132      data: src/val
2133    params:
2134      speed: "1.5"
2135      mode: turbo
2136"#,
2137        );
2138
2139        let expanded = expand_modules(&desc, base).unwrap();
2140        let proc = expanded
2141            .nodes
2142            .iter()
2143            .find(|n| n.id.to_string() == "m.proc")
2144            .unwrap();
2145        let env = proc.env.as_ref().unwrap();
2146        // Params are injected as PARAM_<UPPERCASE_KEY>
2147        assert_eq!(env["PARAM_SPEED"], EnvValue::String("1.5".to_string()));
2148        assert_eq!(env["PARAM_MODE"], EnvValue::String("turbo".to_string()));
2149    }
2150
2151    #[test]
2152    fn expand_params_in_args() {
2153        let tmp = TempDir::new().unwrap();
2154        let base = tmp.path();
2155
2156        write_file(
2157            base,
2158            "args_module.yml",
2159            r#"
2160module:
2161  name: with_args
2162  inputs: [data]
2163  outputs: [out]
2164
2165nodes:
2166  - id: proc
2167    path: proc.py
2168    inputs:
2169      data: _mod/data
2170    outputs:
2171      - out
2172    args: --speed ${_param.speed} --verbose
2173"#,
2174        );
2175
2176        let desc = parse_descriptor(
2177            r#"
2178nodes:
2179  - id: src
2180    path: src.py
2181    outputs: [val]
2182  - id: m
2183    module: args_module.yml
2184    inputs:
2185      data: src/val
2186    params:
2187      speed: "2.0"
2188"#,
2189        );
2190
2191        let expanded = expand_modules(&desc, base).unwrap();
2192        let proc = expanded
2193            .nodes
2194            .iter()
2195            .find(|n| n.id.to_string() == "m.proc")
2196            .unwrap();
2197        assert_eq!(proc.args.as_deref(), Some("--speed 2.0 --verbose"));
2198    }
2199
2200    #[test]
2201    fn expand_params_in_documented_env_style_args() {
2202        let tmp = TempDir::new().unwrap();
2203        let base = tmp.path();
2204
2205        write_file(
2206            base,
2207            "args_module.yml",
2208            r#"
2209module:
2210  name: with_args
2211  inputs: [data]
2212  outputs: [out]
2213
2214nodes:
2215  - id: proc
2216    path: proc.py
2217    inputs:
2218      data: _mod/data
2219    outputs:
2220      - out
2221    args: --speed $PARAM_SPEED --mode $PARAM_MODE
2222"#,
2223        );
2224
2225        let desc = parse_descriptor(
2226            r#"
2227nodes:
2228  - id: src
2229    path: src.py
2230    outputs: [val]
2231  - id: m
2232    module: args_module.yml
2233    inputs:
2234      data: src/val
2235    params:
2236      speed: "2.0"
2237      mode: turbo
2238"#,
2239        );
2240
2241        let expanded = expand_modules(&desc, base).unwrap();
2242        let proc = expanded
2243            .nodes
2244            .iter()
2245            .find(|n| n.id.to_string() == "m.proc")
2246            .unwrap();
2247        assert_eq!(proc.args.as_deref(), Some("--speed 2.0 --mode turbo"));
2248    }
2249
2250    #[test]
2251    fn expand_params_in_env_style_args_distinguishes_overlapping_keys() {
2252        // `speed` and `speed_limit` overlap as prefixes. The scanner takes the
2253        // maximal `[A-Za-z0-9_]` run as the key, so each token resolves to the
2254        // key it names exactly -- no ordering between the params is involved.
2255        // (The old implementation needed a longest-first sort here.)
2256        let tmp = TempDir::new().unwrap();
2257        let base = tmp.path();
2258
2259        write_file(
2260            base,
2261            "args_module.yml",
2262            r#"
2263module:
2264  name: with_args
2265  inputs: [data]
2266  outputs: [out]
2267
2268nodes:
2269  - id: proc
2270    path: proc.py
2271    inputs:
2272      data: _mod/data
2273    outputs:
2274      - out
2275    args: --short $PARAM_SPEED --long $PARAM_SPEED_LIMIT
2276"#,
2277        );
2278
2279        let desc = parse_descriptor(
2280            r#"
2281nodes:
2282  - id: src
2283    path: src.py
2284    outputs: [val]
2285  - id: m
2286    module: args_module.yml
2287    inputs:
2288      data: src/val
2289    params:
2290      speed: "2.0"
2291      speed_limit: "4.5"
2292"#,
2293        );
2294
2295        let expanded = expand_modules(&desc, base).unwrap();
2296        let proc = expanded
2297            .nodes
2298            .iter()
2299            .find(|n| n.id.to_string() == "m.proc")
2300            .unwrap();
2301        assert_eq!(proc.args.as_deref(), Some("--short 2.0 --long 4.5"));
2302    }
2303
2304    #[test]
2305    fn substitute_params_basic_and_unknown() {
2306        let params = BTreeMap::from([
2307            ("speed".to_string(), "2.0".to_string()),
2308            ("name".to_string(), "robot".to_string()),
2309        ]);
2310        assert_eq!(
2311            substitute_params_in_str("--speed ${_param.speed} --name ${_param.name}", &params),
2312            "--speed 2.0 --name robot"
2313        );
2314        // Multiple occurrences of the same key are all replaced.
2315        assert_eq!(
2316            substitute_params_in_str("${_param.speed}/${_param.speed}", &params),
2317            "2.0/2.0"
2318        );
2319        // An unknown key is left verbatim, not dropped.
2320        assert_eq!(
2321            substitute_params_in_str("${_param.missing}", &params),
2322            "${_param.missing}"
2323        );
2324        // A dangling prefix without a closing brace is emitted literally.
2325        assert_eq!(
2326            substitute_params_in_str("prefix ${_param.speed", &params),
2327            "prefix ${_param.speed"
2328        );
2329    }
2330
2331    #[test]
2332    fn substitute_params_env_style_requires_an_identifier_boundary() {
2333        // `$PARAM_<KEY>` has no terminator, so the key must run to the end of
2334        // the shell-identifier characters. A declared param that is only a
2335        // *prefix* of the token must not match (#2901).
2336        let params = BTreeMap::from([("speed".to_string(), "2.0".to_string())]);
2337        assert_eq!(
2338            substitute_params_in_str("--flag $PARAM_SPEED_LIMIT", &params),
2339            "--flag $PARAM_SPEED_LIMIT"
2340        );
2341        // The exact token still substitutes, and a non-identifier character
2342        // terminates it.
2343        assert_eq!(
2344            substitute_params_in_str("--flag $PARAM_SPEED --x", &params),
2345            "--flag 2.0 --x"
2346        );
2347        assert_eq!(
2348            substitute_params_in_str("$PARAM_SPEED,$PARAM_SPEED", &params),
2349            "2.0,2.0"
2350        );
2351        // A bare `$` and an unknown token are both emitted verbatim.
2352        assert_eq!(
2353            substitute_params_in_str("cost $5 $PARAM_MISSING", &params),
2354            "cost $5 $PARAM_MISSING"
2355        );
2356    }
2357
2358    #[test]
2359    fn substitute_params_env_style_is_not_re_expanded() {
2360        // A value that itself looks like a token is inserted verbatim, never
2361        // re-scanned — the property the single-pass scanner exists for.
2362        let params = BTreeMap::from([
2363            ("a".to_string(), "$PARAM_B".to_string()),
2364            ("b".to_string(), "x".to_string()),
2365        ]);
2366        assert_eq!(substitute_params_in_str("$PARAM_A", &params), "$PARAM_B");
2367    }
2368
2369    #[test]
2370    fn substitute_params_is_order_independent_and_non_transitive() {
2371        // A parameter value that itself looks like a `${_param.…}` token must be
2372        // inserted verbatim, never re-expanded — and the result must not depend
2373        // on `BTreeMap` key ordering. With the old chained-`replace` approach,
2374        // `a`'s value `${_param.b}` was expanded to `x` because `a` sorts first.
2375        let params = BTreeMap::from([
2376            ("a".to_string(), "${_param.b}".to_string()),
2377            ("b".to_string(), "x".to_string()),
2378        ]);
2379        assert_eq!(
2380            substitute_params_in_str("${_param.a}", &params),
2381            "${_param.b}"
2382        );
2383        assert_eq!(substitute_params_in_str("${_param.b}", &params), "x");
2384    }
2385
2386    #[test]
2387    fn expand_module_node_env_propagates_to_inner_nodes() {
2388        let tmp = TempDir::new().unwrap();
2389        let base = tmp.path();
2390
2391        write_file(
2392            base,
2393            "env_module.yml",
2394            r#"
2395module:
2396  name: env_module
2397  inputs: [data]
2398  outputs: [out]
2399
2400nodes:
2401  - id: worker
2402    path: worker.py
2403    env:
2404      INNER_ONLY: from-inner
2405      SHARED: inner
2406    inputs:
2407      data: _mod/data
2408    outputs:
2409      - out
2410"#,
2411        );
2412
2413        let desc = parse_descriptor(
2414            r#"
2415nodes:
2416  - id: src
2417    path: src.py
2418    outputs: [val]
2419  - id: m
2420    module: env_module.yml
2421    env:
2422      WRAPPER_ONLY: from-wrapper
2423      SHARED: wrapper
2424    inputs:
2425      data: src/val
2426"#,
2427        );
2428
2429        let expanded = expand_modules(&desc, base).unwrap();
2430        let worker = expanded
2431            .nodes
2432            .iter()
2433            .find(|n| n.id.to_string() == "m.worker")
2434            .unwrap();
2435        let env = worker.env.as_ref().unwrap();
2436        assert_eq!(
2437            env["INNER_ONLY"],
2438            EnvValue::String("from-inner".to_string())
2439        );
2440        assert_eq!(
2441            env["WRAPPER_ONLY"],
2442            EnvValue::String("from-wrapper".to_string())
2443        );
2444        // The inner node's own `env` wins over the module node's, matching
2445        // `merge_env` ("per-node entries override global ones") and `deploy`.
2446        assert_eq!(env["SHARED"], EnvValue::String("inner".to_string()));
2447    }
2448
2449    #[test]
2450    fn expand_module_node_with_empty_env_leaves_inner_env_unset() {
2451        let tmp = TempDir::new().unwrap();
2452        let base = tmp.path();
2453
2454        write_file(
2455            base,
2456            "empty_env_module.yml",
2457            r#"
2458module:
2459  name: empty_env_module
2460  inputs: [data]
2461  outputs: [out]
2462
2463nodes:
2464  - id: worker
2465    path: worker.py
2466    inputs:
2467      data: _mod/data
2468    outputs:
2469      - out
2470"#,
2471        );
2472
2473        let desc = parse_descriptor(
2474            r#"
2475nodes:
2476  - id: src
2477    path: src.py
2478    outputs: [val]
2479  - id: m
2480    module: empty_env_module.yml
2481    env: {}
2482    inputs:
2483      data: src/val
2484"#,
2485        );
2486
2487        let expanded = expand_modules(&desc, base).unwrap();
2488        let worker = expanded
2489            .nodes
2490            .iter()
2491            .find(|n| n.id.to_string() == "m.worker")
2492            .unwrap();
2493        assert_eq!(worker.env, None);
2494    }
2495
2496    #[test]
2497    fn expand_outer_params_reach_nested_module_inner_nodes_as_env() {
2498        let tmp = TempDir::new().unwrap();
2499        let base = tmp.path();
2500
2501        write_file(
2502            base,
2503            "inner.yml",
2504            r#"
2505module:
2506  name: inner
2507  inputs: [data]
2508  outputs: [out]
2509
2510nodes:
2511  - id: worker
2512    path: worker.py
2513    inputs:
2514      data: _mod/data
2515    outputs:
2516      - out
2517"#,
2518        );
2519
2520        write_file(
2521            base,
2522            "outer.yml",
2523            r#"
2524module:
2525  name: outer
2526  inputs: [data]
2527  outputs: [out]
2528
2529nodes:
2530  - id: inner
2531    module: inner.yml
2532    inputs:
2533      data: _mod/data
2534"#,
2535        );
2536
2537        let desc = parse_descriptor(
2538            r#"
2539nodes:
2540  - id: src
2541    path: src.py
2542    outputs: [val]
2543  - id: outer
2544    module: outer.yml
2545    inputs:
2546      data: src/val
2547    params:
2548      speed: "2.0"
2549"#,
2550        );
2551
2552        let expanded = expand_modules(&desc, base).unwrap();
2553        let worker = expanded
2554            .nodes
2555            .iter()
2556            .find(|n| n.id.to_string() == "outer.inner.worker")
2557            .unwrap();
2558        let env = worker.env.as_ref().unwrap();
2559        assert_eq!(env["PARAM_SPEED"], EnvValue::String("2.0".to_string()));
2560    }
2561
2562    // ---- Feature 5: module-level build ----
2563
2564    /// Regression for #3258 (defect 1): a `build:` set on the module node itself
2565    /// (not the module file header) is accepted and propagates into the expanded
2566    /// leaf nodes, matching the documented contract and the nested behavior.
2567    #[test]
2568    fn expand_top_level_module_build_propagated() {
2569        let tmp = TempDir::new().unwrap();
2570        let base = tmp.path();
2571
2572        write_file(
2573            base,
2574            "leaf_module.yml",
2575            r#"
2576module:
2577  name: leaf
2578  inputs: [data]
2579  outputs: [out]
2580
2581nodes:
2582  - id: proc
2583    path: proc.py
2584    inputs:
2585      data: _mod/data
2586    outputs:
2587      - out
2588    build: python setup.py build
2589"#,
2590        );
2591
2592        let desc = parse_descriptor(
2593            r#"
2594nodes:
2595  - id: src
2596    path: src.py
2597    outputs: [val]
2598  - id: m
2599    module: leaf_module.yml
2600    build: pip install foo
2601    inputs:
2602      data: src/val
2603"#,
2604        );
2605
2606        let expanded = expand_modules(&desc, base).unwrap();
2607        let proc = expanded
2608            .nodes
2609            .iter()
2610            .find(|n| n.id.to_string() == "m.proc")
2611            .unwrap();
2612        let build = proc.build.as_deref().unwrap();
2613        assert!(
2614            build.starts_with("pip install foo"),
2615            "the module node's own build must be prepended; got: {build}"
2616        );
2617        assert!(build.contains("python setup.py build"), "{build}");
2618    }
2619
2620    /// Regression for #3258 (defect 2): a per-node runtime field that has no
2621    /// meaning on a module node (here `outputs`) is rejected at expansion time
2622    /// rather than silently dropped, at every nesting level.
2623    #[test]
2624    fn nested_module_node_rejects_disallowed_field() {
2625        let tmp = TempDir::new().unwrap();
2626        let base = tmp.path();
2627
2628        write_file(
2629            base,
2630            "leaf_module.yml",
2631            r#"
2632module:
2633  name: leaf
2634  inputs: [x]
2635  outputs: [y]
2636
2637nodes:
2638  - id: worker
2639    path: worker.py
2640    inputs:
2641      x: _mod/x
2642    outputs:
2643      - y
2644"#,
2645        );
2646
2647        // `outputs` on the nested module node is not part of the module
2648        // whitelist; expansion must reject it.
2649        write_file(
2650            base,
2651            "outer_module.yml",
2652            r#"
2653module:
2654  name: outer
2655  inputs: [x]
2656  outputs: [y]
2657
2658nodes:
2659  - id: inner
2660    module: leaf_module.yml
2661    inputs:
2662      x: _mod/x
2663    outputs:
2664      - y
2665"#,
2666        );
2667
2668        let desc = parse_descriptor(
2669            r#"
2670nodes:
2671  - id: src
2672    path: src.py
2673    outputs: [val]
2674  - id: top
2675    module: outer_module.yml
2676    inputs:
2677      x: src/val
2678"#,
2679        );
2680
2681        let error = format!("{:#}", expand_modules(&desc, base).unwrap_err());
2682        assert!(
2683            error.contains("outputs") && error.contains("Module"),
2684            "nested module node with `outputs` should be rejected; got: {error}"
2685        );
2686    }
2687
2688    #[test]
2689    fn expand_module_build_prepended() {
2690        let tmp = TempDir::new().unwrap();
2691        let base = tmp.path();
2692
2693        write_file(
2694            base,
2695            "build_module.yml",
2696            r#"
2697module:
2698  name: buildable
2699  inputs: [data]
2700  outputs: [out]
2701
2702build: pip install -r requirements.txt
2703
2704nodes:
2705  - id: proc
2706    path: proc.py
2707    inputs:
2708      data: _mod/data
2709    outputs:
2710      - out
2711    build: python setup.py build
2712"#,
2713        );
2714
2715        let desc = parse_descriptor(
2716            r#"
2717nodes:
2718  - id: src
2719    path: src.py
2720    outputs: [val]
2721  - id: m
2722    module: build_module.yml
2723    inputs:
2724      data: src/val
2725"#,
2726        );
2727
2728        let expanded = expand_modules(&desc, base).unwrap();
2729        let proc = expanded
2730            .nodes
2731            .iter()
2732            .find(|n| n.id.to_string() == "m.proc")
2733            .unwrap();
2734        let build = proc.build.as_deref().unwrap();
2735        assert!(build.starts_with("pip install -r requirements.txt"));
2736        assert!(build.contains("python setup.py build"));
2737    }
2738
2739    #[test]
2740    fn expand_module_build_no_inner_build() {
2741        let tmp = TempDir::new().unwrap();
2742        let base = tmp.path();
2743
2744        write_file(
2745            base,
2746            "build_only_module.yml",
2747            r#"
2748module:
2749  name: build_only
2750  inputs: [data]
2751  outputs: [out]
2752
2753build: make all
2754
2755nodes:
2756  - id: proc
2757    path: proc.py
2758    inputs:
2759      data: _mod/data
2760    outputs:
2761      - out
2762"#,
2763        );
2764
2765        let desc = parse_descriptor(
2766            r#"
2767nodes:
2768  - id: src
2769    path: src.py
2770    outputs: [val]
2771  - id: m
2772    module: build_only_module.yml
2773    inputs:
2774      data: src/val
2775"#,
2776        );
2777
2778        let expanded = expand_modules(&desc, base).unwrap();
2779        let proc = expanded
2780            .nodes
2781            .iter()
2782            .find(|n| n.id.to_string() == "m.proc")
2783            .unwrap();
2784        assert_eq!(proc.build.as_deref(), Some("make all"));
2785    }
2786
2787    /// A module-level `build:` must reach `git:`/`hub:`-sourced inner nodes,
2788    /// not just `path:`-sourced ones. Module expansion runs before git/hub
2789    /// source resolution, so these nodes still have `path == None` at this
2790    /// point; the build must be keyed off the node kind, not `node.path`
2791    /// (#3296).
2792    #[test]
2793    fn expand_module_build_prepended_to_git_and_hub_inner_nodes() {
2794        let tmp = TempDir::new().unwrap();
2795        let base = tmp.path();
2796
2797        write_file(
2798            base,
2799            "git_hub_module.yml",
2800            r#"
2801module:
2802  name: git_hub
2803  outputs: [from_git, from_hub]
2804
2805build: pip install -r requirements.txt
2806
2807nodes:
2808  - id: worker
2809    git: https://github.com/example/worker.git
2810    outputs:
2811      - from_git
2812    build: cargo build --release
2813  - id: fetched
2814    hub: example/fetched
2815    outputs:
2816      - from_hub
2817"#,
2818        );
2819
2820        let desc = parse_descriptor(
2821            r#"
2822nodes:
2823  - id: m
2824    module: git_hub_module.yml
2825"#,
2826        );
2827
2828        let expanded = expand_modules(&desc, base).unwrap();
2829
2830        // git-sourced inner node: module build prepended before its own build.
2831        let worker = expanded
2832            .nodes
2833            .iter()
2834            .find(|n| n.id.to_string() == "m.worker")
2835            .unwrap();
2836        assert_eq!(
2837            worker.build.as_deref(),
2838            Some("pip install -r requirements.txt\ncargo build --release"),
2839        );
2840
2841        // hub-sourced inner node with no own build: module build is set.
2842        let fetched = expanded
2843            .nodes
2844            .iter()
2845            .find(|n| n.id.to_string() == "m.fetched")
2846            .unwrap();
2847        assert_eq!(
2848            fetched.build.as_deref(),
2849            Some("pip install -r requirements.txt"),
2850        );
2851    }
2852
2853    /// A module-level `build:` must not make an operator inner node
2854    /// unresolvable. The expansion-only tests above stop at `expand_modules`;
2855    /// the field classifier runs at *resolution*, so the regression this pins
2856    /// lives in the seam between the two (#3070).
2857    #[test]
2858    fn module_build_keeps_operator_inner_nodes_resolvable() {
2859        let tmp = TempDir::new().unwrap();
2860        let base = tmp.path();
2861
2862        write_file(
2863            base,
2864            "kinds_module.yml",
2865            r#"
2866build: pip install shared
2867
2868module:
2869  name: kinds
2870  inputs: [data]
2871  outputs: [from_runtime, from_operator]
2872
2873nodes:
2874  - id: runtime
2875    operators:
2876      - id: proc
2877        shared-library: proc
2878        inputs:
2879          data: _mod/data
2880        outputs:
2881          - from_runtime
2882
2883  - id: single
2884    operator:
2885      python: single.py
2886      inputs:
2887        data: _mod/data
2888      outputs:
2889        - from_operator
2890"#,
2891        );
2892
2893        let desc = parse_descriptor(
2894            r#"
2895nodes:
2896  - id: src
2897    path: src.py
2898    outputs: [val]
2899  - id: m
2900    module: kinds_module.yml
2901    inputs:
2902      data: src/val
2903"#,
2904        );
2905
2906        let expanded = expand_modules(&desc, base).unwrap();
2907        // The module build must land in the operator configs, which is where
2908        // `build/mod.rs` reads it from for runtime nodes...
2909        let runtime = expanded
2910            .nodes
2911            .iter()
2912            .find(|n| n.id.to_string() == "m.runtime")
2913            .unwrap();
2914        assert_eq!(
2915            runtime.operators.as_ref().unwrap().operators[0]
2916                .config
2917                .build
2918                .as_deref(),
2919            Some("pip install shared"),
2920        );
2921        // ...and must NOT land on the node itself, where no kind but
2922        // `path:`/`module:` consumes it.
2923        assert_eq!(runtime.build, None);
2924
2925        crate::descriptor::DescriptorExt::resolve_aliases_and_set_defaults(&expanded)
2926            .expect("a module-level build must not make operator nodes unresolvable");
2927    }
2928
2929    #[test]
2930    fn expand_module_build_prepended_to_operator_and_custom_builds() {
2931        let tmp = TempDir::new().unwrap();
2932        let base = tmp.path();
2933
2934        write_file(
2935            base,
2936            "inner_kind_build_module.yml",
2937            r#"
2938module:
2939  name: inner_kind_builds
2940  inputs: [data]
2941  outputs: [from_runtime, from_operator]
2942
2943build: pip install shared
2944
2945nodes:
2946  - id: runtime
2947    operators:
2948      - id: proc
2949        shared-library: libproc.so
2950        build: make proc
2951        inputs:
2952          data: _mod/data
2953        outputs:
2954          - from_runtime
2955
2956  - id: single
2957    operator:
2958      python: single.py
2959      build: pip install single
2960      inputs:
2961        data: _mod/data
2962      outputs:
2963        - from_operator
2964"#,
2965        );
2966
2967        let desc = parse_descriptor(
2968            r#"
2969nodes:
2970  - id: src
2971    path: src.py
2972    outputs: [val]
2973  - id: m
2974    module: inner_kind_build_module.yml
2975    inputs:
2976      data: src/val
2977"#,
2978        );
2979
2980        let expanded = expand_modules(&desc, base).unwrap();
2981
2982        let runtime = expanded
2983            .nodes
2984            .iter()
2985            .find(|n| n.id.to_string() == "m.runtime")
2986            .unwrap();
2987        let runtime_build = runtime.operators.as_ref().unwrap().operators[0]
2988            .config
2989            .build
2990            .as_deref()
2991            .unwrap();
2992        assert_eq!(runtime_build, "pip install shared\nmake proc");
2993
2994        let single = expanded
2995            .nodes
2996            .iter()
2997            .find(|n| n.id.to_string() == "m.single")
2998            .unwrap();
2999        let single_build = single.operator.as_ref().unwrap().config.build.as_deref();
3000        assert_eq!(single_build, Some("pip install shared\npip install single"));
3001    }
3002
3003    // ---- Feature 1: boundaries metadata ----
3004
3005    #[test]
3006    fn expand_returns_boundaries() {
3007        let tmp = TempDir::new().unwrap();
3008        let base = tmp.path();
3009
3010        write_file(
3011            base,
3012            "simple_module.yml",
3013            r#"
3014module:
3015  name: simple
3016  inputs: [x]
3017  outputs: [y]
3018
3019nodes:
3020  - id: a
3021    path: a.py
3022    inputs:
3023      x: _mod/x
3024    outputs: [mid]
3025  - id: b
3026    path: b.py
3027    inputs:
3028      mid: a/mid
3029    outputs: [y]
3030"#,
3031        );
3032
3033        let desc = parse_descriptor(
3034            r#"
3035nodes:
3036  - id: src
3037    path: src.py
3038    outputs: [val]
3039  - id: mod1
3040    module: simple_module.yml
3041    inputs:
3042      x: src/val
3043"#,
3044        );
3045
3046        let result = expand_modules_with_boundaries(&desc, base).unwrap();
3047        assert!(result.boundaries.modules.contains_key("mod1"));
3048        let members = &result.boundaries.modules["mod1"];
3049        assert!(members.contains(&"mod1.a".to_string()));
3050        assert!(members.contains(&"mod1.b".to_string()));
3051    }
3052
3053    // ---- Feature 9: check_module_file ----
3054
3055    #[test]
3056    fn check_module_file_valid() {
3057        let tmp = TempDir::new().unwrap();
3058        let path = write_file(
3059            tmp.path(),
3060            "valid_module.yml",
3061            r#"
3062module:
3063  name: valid
3064  inputs: [x]
3065  outputs: [y]
3066
3067nodes:
3068  - id: proc
3069    path: proc.py
3070    inputs:
3071      x: _mod/x
3072    outputs:
3073      - y
3074"#,
3075        );
3076
3077        check_module_file(&path).unwrap();
3078    }
3079
3080    #[test]
3081    fn check_module_file_bad_mod_ref() {
3082        let tmp = TempDir::new().unwrap();
3083        let path = write_file(
3084            tmp.path(),
3085            "bad_ref_module.yml",
3086            r#"
3087module:
3088  name: bad_ref
3089  inputs: [x]
3090  outputs: [y]
3091
3092nodes:
3093  - id: proc
3094    path: proc.py
3095    inputs:
3096      x: _mod/nonexistent
3097    outputs:
3098      - y
3099"#,
3100        );
3101
3102        let result = check_module_file(&path);
3103        assert!(result.is_err());
3104        assert!(result.unwrap_err().to_string().contains("nonexistent"));
3105    }
3106
3107    #[test]
3108    fn check_module_file_rejects_duplicate_header_ports() {
3109        let tmp = TempDir::new().unwrap();
3110
3111        let duplicate_inputs = write_file(
3112            tmp.path(),
3113            "duplicate_inputs.yml",
3114            r#"
3115module:
3116  name: duplicate_inputs
3117  inputs: [data, data]
3118  outputs: [out]
3119
3120nodes:
3121  - id: worker
3122    path: worker.py
3123    inputs:
3124      x: _mod/data
3125    outputs:
3126      - out
3127"#,
3128        );
3129        let msg = check_module_file(&duplicate_inputs)
3130            .unwrap_err()
3131            .to_string();
3132        assert!(msg.contains("duplicate"), "got: {msg}");
3133        assert!(msg.contains("inputs"), "got: {msg}");
3134        assert!(msg.contains("data"), "got: {msg}");
3135
3136        let duplicate_optional = write_file(
3137            tmp.path(),
3138            "duplicate_optional.yml",
3139            r#"
3140module:
3141  name: duplicate_optional
3142  inputs: [data]
3143  inputs_optional: [cfg, cfg]
3144  outputs: [out]
3145
3146nodes:
3147  - id: worker
3148    path: worker.py
3149    inputs:
3150      x: _mod/data
3151    outputs:
3152      - out
3153"#,
3154        );
3155        let msg = check_module_file(&duplicate_optional)
3156            .unwrap_err()
3157            .to_string();
3158        assert!(msg.contains("duplicate"), "got: {msg}");
3159        assert!(msg.contains("inputs_optional"), "got: {msg}");
3160        assert!(msg.contains("cfg"), "got: {msg}");
3161
3162        let duplicate_outputs = write_file(
3163            tmp.path(),
3164            "duplicate_outputs.yml",
3165            r#"
3166module:
3167  name: duplicate_outputs
3168  inputs: [data]
3169  outputs: [out, out]
3170
3171nodes:
3172  - id: worker
3173    path: worker.py
3174    inputs:
3175      x: _mod/data
3176    outputs:
3177      - out
3178"#,
3179        );
3180        let msg = check_module_file(&duplicate_outputs)
3181            .unwrap_err()
3182            .to_string();
3183        assert!(msg.contains("duplicate"), "got: {msg}");
3184        assert!(msg.contains("outputs"), "got: {msg}");
3185        assert!(msg.contains("out"), "got: {msg}");
3186    }
3187
3188    #[test]
3189    fn check_module_file_bad_output() {
3190        let tmp = TempDir::new().unwrap();
3191        let path = write_file(
3192            tmp.path(),
3193            "bad_out_module.yml",
3194            r#"
3195module:
3196  name: bad_out
3197  inputs: []
3198  outputs: [missing]
3199
3200nodes:
3201  - id: proc
3202    path: proc.py
3203    outputs:
3204      - other
3205"#,
3206        );
3207
3208        let result = check_module_file(&path);
3209        assert!(result.is_err());
3210        assert!(result.unwrap_err().to_string().contains("missing"));
3211    }
3212
3213    #[test]
3214    fn check_module_file_rejects_ambiguous_declared_output() {
3215        let tmp = TempDir::new().unwrap();
3216        let path = write_file(
3217            tmp.path(),
3218            "ambiguous_output.yml",
3219            r#"
3220module:
3221  name: ambiguous
3222  inputs: []
3223  outputs: [out]
3224
3225nodes:
3226  - id: first
3227    path: first.py
3228    outputs:
3229      - out
3230  - id: second
3231    path: second.py
3232    outputs:
3233      - out
3234"#,
3235        );
3236
3237        let result = check_module_file(&path);
3238        assert!(result.is_err());
3239        let msg = result.unwrap_err().to_string();
3240        assert!(msg.contains("out"), "got: {msg}");
3241        assert!(msg.contains("multiple"), "got: {msg}");
3242        assert!(msg.contains("first"), "got: {msg}");
3243        assert!(msg.contains("second"), "got: {msg}");
3244    }
3245
3246    #[test]
3247    fn check_module_file_rejects_unknown_module_header_field() {
3248        let tmp = TempDir::new().unwrap();
3249        let path = write_file(
3250            tmp.path(),
3251            "unknown_header_field.yml",
3252            r#"
3253module:
3254  name: bad
3255  inputz: [data]
3256  outputs: [out]
3257
3258nodes:
3259  - id: worker
3260    path: worker.py
3261    outputs:
3262      - out
3263"#,
3264        );
3265
3266        let result = check_module_file(&path);
3267        assert!(result.is_err());
3268        let msg = format!("{:?}", result.unwrap_err());
3269        assert!(msg.contains("inputz"), "got: {msg}");
3270        assert!(msg.contains("unknown field"), "got: {msg}");
3271    }
3272
3273    #[test]
3274    fn check_module_file_rejects_duplicate_inner_node_ids() {
3275        let tmp = TempDir::new().unwrap();
3276        let path = write_file(
3277            tmp.path(),
3278            "duplicate_inner_nodes.yml",
3279            r#"
3280module:
3281  name: duplicate_inner_nodes
3282  inputs: []
3283  outputs: [y]
3284
3285nodes:
3286  - id: proc
3287    path: a.py
3288    outputs:
3289      - y
3290  - id: proc
3291    path: b.py
3292    outputs:
3293      - z
3294"#,
3295        );
3296
3297        let err = check_module_file(&path).unwrap_err().to_string();
3298        assert!(err.contains("duplicate node ID"), "{err}");
3299        assert!(err.contains("proc"), "{err}");
3300    }
3301
3302    #[test]
3303    fn check_module_file_rejects_invalid_internal_wiring() {
3304        let tmp = TempDir::new().unwrap();
3305        let path = write_file(
3306            tmp.path(),
3307            "bad_internal_wiring.yml",
3308            r#"
3309module:
3310  name: bad_internal_wiring
3311  inputs: []
3312  outputs: [y]
3313
3314nodes:
3315  - id: preprocessor
3316    path: preprocessor.py
3317    outputs:
3318      - cleaned
3319  - id: producer
3320    path: producer.py
3321    inputs:
3322      data: preprocessor/cleaned
3323    outputs:
3324      - y
3325  - id: valid_consumer
3326    path: valid_consumer.py
3327    inputs:
3328      data: producer/y
3329    outputs:
3330      - z
3331  - id: consumer
3332    path: consumer.py
3333    inputs:
3334      data: producer/missing
3335  - id: independent
3336    path: independent.py
3337    outputs:
3338      - side
3339"#,
3340        );
3341
3342        let err = check_module_file(&path).unwrap_err().to_string();
3343        assert!(err.contains("producer/missing"), "{err}");
3344    }
3345
3346    #[test]
3347    fn check_module_file_rejects_nested_module_missing_required_input() {
3348        let tmp = TempDir::new().unwrap();
3349
3350        write_file(
3351            tmp.path(),
3352            "leaf.yml",
3353            r#"
3354module:
3355  name: leaf
3356  inputs: [data]
3357  outputs: [out]
3358
3359nodes:
3360  - id: worker
3361    path: worker.py
3362    inputs:
3363      x: _mod/data
3364    outputs:
3365      - out
3366"#,
3367        );
3368
3369        let path = write_file(
3370            tmp.path(),
3371            "outer.yml",
3372            r#"
3373module:
3374  name: outer
3375  inputs: []
3376  outputs: [out]
3377
3378nodes:
3379  - id: nested
3380    module: leaf.yml
3381"#,
3382        );
3383
3384        let result = check_module_file(&path);
3385        assert!(result.is_err());
3386        let msg = result.unwrap_err().to_string();
3387        assert!(msg.contains("leaf"), "got: {msg}");
3388        assert!(msg.contains("data"), "got: {msg}");
3389        assert!(msg.contains("nested"), "got: {msg}");
3390    }
3391
3392    #[test]
3393    fn check_module_file_rejects_invalid_nested_module() {
3394        let tmp = TempDir::new().unwrap();
3395
3396        write_file(
3397            tmp.path(),
3398            "leaf.yml",
3399            r#"
3400module:
3401  name: leaf
3402  inputs: []
3403  outputs: [out]
3404
3405nodes:
3406  - id: worker
3407    path: worker.py
3408    outputs:
3409      - other
3410"#,
3411        );
3412
3413        let path = write_file(
3414            tmp.path(),
3415            "outer.yml",
3416            r#"
3417module:
3418  name: outer
3419  inputs: []
3420  outputs: [out]
3421
3422nodes:
3423  - id: nested
3424    module: leaf.yml
3425"#,
3426        );
3427
3428        let result = check_module_file(&path);
3429        assert!(result.is_err());
3430        let msg = format!("{:#}", result.unwrap_err());
3431        assert!(msg.contains("leaf"), "got: {msg}");
3432        assert!(msg.contains("out"), "got: {msg}");
3433        assert!(msg.contains("no inner node produces it"), "got: {msg}");
3434        // The breadcrumb must name the referencing node so a failure several
3435        // levels down is findable.
3436        assert!(msg.contains("nested"), "got: {msg}");
3437    }
3438
3439    /// The `seen` set must reject a module that includes itself. Without the
3440    /// `seen.insert` guard this recurses to the depth limit and reports a
3441    /// misleading depth error instead of naming the cycle.
3442    #[test]
3443    fn check_module_file_rejects_self_referencing_module() {
3444        let tmp = TempDir::new().unwrap();
3445        let path = write_file(
3446            tmp.path(),
3447            "self.yml",
3448            r#"
3449module:
3450  name: selfref
3451  inputs: []
3452  outputs: []
3453
3454nodes:
3455  - id: me
3456    module: self.yml
3457"#,
3458        );
3459
3460        let msg = format!("{:#}", check_module_file(&path).unwrap_err());
3461        assert!(msg.contains("circular module reference"), "got: {msg}");
3462    }
3463
3464    /// `seen.remove` on the success path must keep a diamond (`a -> b -> d`
3465    /// and `a -> c -> d`) accepted. Drop that line and every shared nested
3466    /// module starts failing as a false "circular reference".
3467    #[test]
3468    fn check_module_file_accepts_diamond_module_graph() {
3469        let tmp = TempDir::new().unwrap();
3470        let base = tmp.path();
3471
3472        write_file(
3473            base,
3474            "d.yml",
3475            r#"
3476module:
3477  name: d
3478  inputs: []
3479  outputs: [d_out]
3480
3481nodes:
3482  - id: leaf
3483    path: leaf.py
3484    outputs:
3485      - d_out
3486"#,
3487        );
3488        for name in ["b.yml", "c.yml"] {
3489            write_file(
3490                base,
3491                name,
3492                r#"
3493module:
3494  name: mid
3495  inputs: []
3496  outputs: [d_out]
3497
3498nodes:
3499  - id: node_d
3500    module: d.yml
3501"#,
3502            );
3503        }
3504        let path = write_file(
3505            base,
3506            "a.yml",
3507            r#"
3508module:
3509  name: a
3510  inputs: []
3511  outputs: []
3512
3513nodes:
3514  - id: node_b
3515    module: b.yml
3516  - id: node_c
3517    module: c.yml
3518"#,
3519        );
3520
3521        check_module_file(&path).unwrap();
3522    }
3523
3524    #[test]
3525    fn check_module_file_rejects_depth_limit() {
3526        let tmp = TempDir::new().unwrap();
3527        let base = tmp.path();
3528
3529        for i in 0..=MAX_MODULE_DEPTH {
3530            let next = if i < MAX_MODULE_DEPTH {
3531                format!("  - id: inner\n    module: level{}_module.yml", i + 1)
3532            } else {
3533                "  - id: worker\n    path: worker.py\n    outputs:\n      - out".to_string()
3534            };
3535
3536            write_file(
3537                base,
3538                &format!("level{i}_module.yml"),
3539                &format!(
3540                    r#"
3541module:
3542  name: level{i}
3543  inputs: []
3544  outputs: [out]
3545
3546nodes:
3547{next}
3548"#
3549                ),
3550            );
3551        }
3552
3553        let result = check_module_file(&base.join("level0_module.yml"));
3554        assert!(result.is_err());
3555        // `{:#}` renders the whole context chain: the nested-module breadcrumb
3556        // wraps the underlying depth-limit error.
3557        assert!(format!("{:#}", result.unwrap_err()).contains("nesting exceeds depth limit"));
3558    }
3559
3560    /// Regression test for #2851: `check_module_file` must accept a nested
3561    /// module reference that points to a sibling directory inside the same
3562    /// project (e.g. `../shared/base.yml`). The real expansion path
3563    /// (`expand_module_node`) confines nested modules to the project root, not
3564    /// to the referencing module's own directory, so such a reference is valid
3565    /// and runnable -- the standalone linter must not spuriously reject it.
3566    #[test]
3567    fn check_module_file_accepts_cross_directory_nested_ref() {
3568        let tmp = TempDir::new().unwrap();
3569
3570        // project/modules/shared/base.yml -- the nested module.
3571        write_file(
3572            tmp.path(),
3573            "modules/shared/base.yml",
3574            r#"
3575module:
3576  name: base
3577  inputs: []
3578  outputs: [y]
3579
3580nodes:
3581  - id: inner
3582    path: inner.py
3583    outputs:
3584      - y
3585"#,
3586        );
3587
3588        // project/modules/a/mod.yml -- references the sibling module via `..`.
3589        let path = write_file(
3590            tmp.path(),
3591            "modules/a/mod.yml",
3592            r#"
3593module:
3594  name: outer
3595  inputs: []
3596  outputs: [y]
3597
3598nodes:
3599  - id: nested
3600    module: ../shared/base.yml
3601"#,
3602        );
3603
3604        // Must pass: the nested reference leaves `modules/a/` but stays inside
3605        // the project, exactly what real expansion accepts.
3606        check_module_file(&path).unwrap();
3607    }
3608
3609    // ---- Security tests ----
3610
3611    #[test]
3612    fn reject_absolute_module_path() {
3613        let desc = parse_descriptor(
3614            r#"
3615nodes:
3616  - id: evil
3617    module: /etc/passwd
3618"#,
3619        );
3620        let tmp = TempDir::new().unwrap();
3621        let result = expand_modules(&desc, tmp.path());
3622        assert!(result.is_err());
3623        let msg = result.unwrap_err().to_string();
3624        assert!(msg.contains("must be relative"), "got: {msg}");
3625    }
3626
3627    #[test]
3628    fn reject_path_traversal_module() {
3629        let tmp = TempDir::new().unwrap();
3630        let base = tmp.path();
3631
3632        // Create a module outside the base dir
3633        let parent = base.parent().unwrap();
3634        write_file(parent, "escape_module.yml", "module:\n  name: x\nnodes: []");
3635
3636        let desc = parse_descriptor(
3637            r#"
3638nodes:
3639  - id: evil
3640    module: ../escape_module.yml
3641"#,
3642        );
3643        let result = expand_modules(&desc, base);
3644        assert!(result.is_err());
3645        let msg = result.unwrap_err().to_string();
3646        assert!(msg.contains("escapes"), "got: {msg}");
3647    }
3648
3649    #[test]
3650    fn reject_inner_node_path_traversal_via_dotdot() {
3651        let tmp = TempDir::new().unwrap();
3652        let base = tmp.path();
3653
3654        // Module file that declares an inner node with a `..`-escaping path.
3655        // The binary doesn't need to exist — the check is purely lexical.
3656        write_file(
3657            base,
3658            "escape_node_module.yml",
3659            r#"
3660module:
3661  name: escape_node
3662  inputs: []
3663  outputs: []
3664
3665nodes:
3666  - id: evil
3667    path: ../../etc/evil-binary
3668"#,
3669        );
3670
3671        let desc = parse_descriptor(
3672            r#"
3673nodes:
3674  - id: m
3675    module: escape_node_module.yml
3676"#,
3677        );
3678
3679        let result = expand_modules(&desc, base);
3680        assert!(result.is_err(), "expected error but got success");
3681        let msg = result.unwrap_err().to_string();
3682        assert!(
3683            msg.contains("resolves outside the project directory"),
3684            "got: {msg}"
3685        );
3686    }
3687
3688    #[test]
3689    fn reject_inner_operator_source_path_traversal_via_dotdot() {
3690        let tmp = TempDir::new().unwrap();
3691        let base = tmp.path();
3692
3693        write_file(
3694            base,
3695            "escape_operator_module.yml",
3696            r#"
3697module:
3698  name: escape_operator
3699  inputs: []
3700  outputs: []
3701
3702nodes:
3703  - id: runtime
3704    operators:
3705      - id: evil
3706        shared-library: ../../etc/evil-operator
3707"#,
3708        );
3709
3710        let desc = parse_descriptor(
3711            r#"
3712nodes:
3713  - id: m
3714    module: escape_operator_module.yml
3715"#,
3716        );
3717
3718        let result = expand_modules(&desc, base);
3719        assert!(result.is_err(), "expected error but got success");
3720        let msg = result.unwrap_err().to_string();
3721        assert!(
3722            msg.contains("resolves outside the project directory"),
3723            "got: {msg}"
3724        );
3725    }
3726
3727    #[test]
3728    fn reject_invalid_param_key() {
3729        let tmp = TempDir::new().unwrap();
3730        let base = tmp.path();
3731
3732        write_file(
3733            base,
3734            "param_mod.yml",
3735            r#"
3736module:
3737  name: p
3738  inputs: [x]
3739  outputs: [y]
3740
3741nodes:
3742  - id: n
3743    path: n.py
3744    inputs:
3745      x: _mod/x
3746    outputs: [y]
3747"#,
3748        );
3749
3750        let desc = parse_descriptor(
3751            r#"
3752nodes:
3753  - id: src
3754    path: src.py
3755    outputs: [v]
3756  - id: m
3757    module: param_mod.yml
3758    inputs:
3759      x: src/v
3760    params:
3761      "bad}key": value
3762"#,
3763        );
3764        let result = expand_modules(&desc, base);
3765        assert!(result.is_err());
3766        let msg = result.unwrap_err().to_string();
3767        assert!(msg.contains("invalid param key"), "got: {msg}");
3768    }
3769
3770    #[test]
3771    fn reject_case_colliding_param_keys() {
3772        let tmp = TempDir::new().unwrap();
3773        let base = tmp.path();
3774
3775        write_file(
3776            base,
3777            "param_mod.yml",
3778            r#"
3779module:
3780  name: p
3781  inputs: [x]
3782  outputs: [y]
3783
3784nodes:
3785  - id: n
3786    path: n.py
3787    inputs:
3788      x: _mod/x
3789    outputs: [y]
3790"#,
3791        );
3792
3793        // `mode` and `Mode` are both valid keys but both map to `PARAM_MODE`,
3794        // so one would silently overwrite the other in the node env.
3795        let desc = parse_descriptor(
3796            r#"
3797nodes:
3798  - id: src
3799    path: src.py
3800    outputs: [v]
3801  - id: m
3802    module: param_mod.yml
3803    inputs:
3804      x: src/v
3805    params:
3806      mode: safe
3807      Mode: turbo
3808"#,
3809        );
3810        let result = expand_modules(&desc, base);
3811        assert!(result.is_err());
3812        let msg = result.unwrap_err().to_string();
3813        assert!(msg.contains("PARAM_MODE"), "got: {msg}");
3814    }
3815
3816    #[test]
3817    fn reject_duplicate_node_ids() {
3818        let tmp = TempDir::new().unwrap();
3819        let base = tmp.path();
3820
3821        write_file(
3822            base,
3823            "dup_module.yml",
3824            r#"
3825module:
3826  name: dup
3827  inputs: []
3828  outputs: [y]
3829
3830nodes:
3831  - id: inner
3832    path: inner.py
3833    outputs: [y]
3834"#,
3835        );
3836
3837        // Top-level node named "m.inner" collides with module expansion
3838        let desc = parse_descriptor(
3839            r#"
3840nodes:
3841  - id: m.inner
3842    path: other.py
3843    outputs: [z]
3844  - id: m
3845    module: dup_module.yml
3846"#,
3847        );
3848        let result = expand_modules(&desc, base);
3849        assert!(result.is_err());
3850        let msg = result.unwrap_err().to_string();
3851        assert!(msg.contains("duplicate node ID"), "got: {msg}");
3852    }
3853
3854    #[test]
3855    fn expand_nested_module_build_propagated() {
3856        let tmp = TempDir::new().unwrap();
3857        let base = tmp.path();
3858
3859        write_file(
3860            base,
3861            "leaf_module.yml",
3862            r#"
3863module:
3864  name: leaf
3865  inputs: [x]
3866  outputs: [y]
3867
3868nodes:
3869  - id: worker
3870    path: worker.py
3871    inputs:
3872      x: _mod/x
3873    outputs:
3874      - y
3875"#,
3876        );
3877
3878        write_file(
3879            base,
3880            "outer_module.yml",
3881            r#"
3882module:
3883  name: outer
3884  inputs: [x]
3885  outputs: [y]
3886
3887build: pip install outer-deps
3888
3889nodes:
3890  - id: inner
3891    module: leaf_module.yml
3892    inputs:
3893      x: _mod/x
3894"#,
3895        );
3896
3897        let desc = parse_descriptor(
3898            r#"
3899nodes:
3900  - id: src
3901    path: src.py
3902    outputs: [val]
3903  - id: top
3904    module: outer_module.yml
3905    inputs:
3906      x: src/val
3907"#,
3908        );
3909
3910        let expanded = expand_modules(&desc, base).unwrap();
3911        let worker = expanded
3912            .nodes
3913            .iter()
3914            .find(|n| n.id.to_string() == "top.inner.worker")
3915            .unwrap();
3916        let build = worker.build.as_deref().unwrap();
3917        assert!(
3918            build.contains("pip install outer-deps"),
3919            "outer module build must propagate through nested modules; got: {build}"
3920        );
3921    }
3922
3923    #[test]
3924    fn param_override_precedence() {
3925        let tmp = TempDir::new().unwrap();
3926        let base = tmp.path();
3927
3928        write_file(
3929            base,
3930            "override_mod.yml",
3931            r#"
3932module:
3933  name: override
3934  inputs: [x]
3935  outputs: [y]
3936
3937nodes:
3938  - id: proc
3939    path: proc.py
3940    inputs:
3941      x: _mod/x
3942    outputs: [y]
3943    env:
3944      PARAM_SPEED: "default_value"
3945"#,
3946        );
3947
3948        let desc = parse_descriptor(
3949            r#"
3950nodes:
3951  - id: src
3952    path: src.py
3953    outputs: [v]
3954  - id: m
3955    module: override_mod.yml
3956    inputs:
3957      x: src/v
3958    params:
3959      speed: "caller_value"
3960"#,
3961        );
3962
3963        let expanded = expand_modules(&desc, base).unwrap();
3964        let proc = expanded
3965            .nodes
3966            .iter()
3967            .find(|n| n.id.to_string() == "m.proc")
3968            .unwrap();
3969        let env = proc.env.as_ref().unwrap();
3970        // Caller params should override inner default
3971        assert_eq!(
3972            env["PARAM_SPEED"],
3973            EnvValue::String("caller_value".to_string())
3974        );
3975    }
3976
3977    /// Regression test for #2441: operator (runtime) inner nodes wire their
3978    /// inputs through `config.inputs`, not the node-level `inputs` map, so
3979    /// `_mod/` and sibling references inside an operator input must be
3980    /// rewritten the same way as node-level inputs.
3981    #[test]
3982    fn expand_rewrites_operator_inputs() {
3983        let tmp = TempDir::new().unwrap();
3984        let base = tmp.path();
3985
3986        write_file(
3987            base,
3988            "runtime_module.yml",
3989            r#"
3990module:
3991  name: rt
3992  inputs: [data]
3993
3994nodes:
3995  - id: stage_a
3996    path: a.py
3997    outputs: [aux]
3998
3999  - id: runtime
4000    operators:
4001      - id: proc
4002        shared-library: proc.so
4003        inputs:
4004          x: _mod/data
4005          y: stage_a/aux
4006        outputs:
4007          - result
4008"#,
4009        );
4010
4011        let desc = parse_descriptor(
4012            r#"
4013nodes:
4014  - id: src
4015    path: src.py
4016    outputs: [val]
4017  - id: m
4018    module: runtime_module.yml
4019    inputs:
4020      data: src/val
4021"#,
4022        );
4023
4024        let expanded = expand_modules(&desc, base).unwrap();
4025
4026        let runtime = expanded
4027            .nodes
4028            .iter()
4029            .find(|n| n.id.to_string() == "m.runtime")
4030            .unwrap();
4031        let proc = runtime
4032            .operators
4033            .as_ref()
4034            .unwrap()
4035            .operators
4036            .iter()
4037            .find(|op| op.id.to_string() == "proc")
4038            .unwrap();
4039
4040        let x = &proc.config.inputs[&DataId::from("x".to_string())];
4041        match &x.mapping {
4042            InputMapping::User(m) => {
4043                assert_eq!(m.source.to_string(), "src");
4044                assert_eq!(m.output.to_string(), "val");
4045            }
4046            _ => panic!("expected user mapping"),
4047        }
4048
4049        let y = &proc.config.inputs[&DataId::from("y".to_string())];
4050        match &y.mapping {
4051            InputMapping::User(m) => {
4052                assert_eq!(m.source.to_string(), "m.stage_a");
4053                assert_eq!(m.output.to_string(), "aux");
4054            }
4055            _ => panic!("expected user mapping"),
4056        }
4057    }
4058
4059    /// Regression test for #2441: `check_module_file` must catch an invalid
4060    /// `_mod/` reference inside an operator input, not just node-level inputs.
4061    #[test]
4062    fn check_module_file_rejects_invalid_mod_ref_in_operator_input() {
4063        let tmp = TempDir::new().unwrap();
4064        let base = tmp.path();
4065
4066        let path = write_file(
4067            base,
4068            "bad_operator_module.yml",
4069            r#"
4070module:
4071  name: bad
4072  inputs: [data]
4073  outputs: [result]
4074
4075nodes:
4076  - id: runtime
4077    operators:
4078      - id: proc
4079        shared-library: proc.so
4080        inputs:
4081          x: _mod/nonexistent
4082        outputs:
4083          - result
4084"#,
4085        );
4086
4087        let err = check_module_file(&path).unwrap_err();
4088        assert!(err.to_string().contains("_mod/nonexistent"));
4089    }
4090
4091    #[test]
4092    fn expand_resolves_inner_operator_and_node_sources_relative_to_module_file() {
4093        let tmp = TempDir::new().unwrap();
4094        let base = tmp.path();
4095
4096        write_file(
4097            base,
4098            "modules/nested/source_paths.yml",
4099            r#"
4100module:
4101  name: source_paths
4102  inputs: [data]
4103  outputs: [from_runtime, from_operator, from_path]
4104
4105nodes:
4106  - id: runtime
4107    operators:
4108      - id: proc
4109        shared-library: libproc.so
4110        inputs:
4111          x: _mod/data
4112        outputs:
4113          - from_runtime
4114
4115  - id: single
4116    operator:
4117      python: single.py
4118      inputs:
4119        x: _mod/data
4120      outputs:
4121        - from_operator
4122
4123  - id: runner
4124    path: runner.py
4125    inputs:
4126      x: _mod/data
4127    outputs:
4128      - from_path
4129"#,
4130        );
4131
4132        let desc = parse_descriptor(
4133            r#"
4134nodes:
4135  - id: src
4136    path: src.py
4137    outputs: [val]
4138  - id: m
4139    module: modules/nested/source_paths.yml
4140    inputs:
4141      data: src/val
4142"#,
4143        );
4144
4145        let expanded = expand_modules(&desc, base).unwrap();
4146        let runtime = expanded
4147            .nodes
4148            .iter()
4149            .find(|n| n.id.to_string() == "m.runtime")
4150            .unwrap();
4151        let proc_source = &runtime
4152            .operators
4153            .as_ref()
4154            .unwrap()
4155            .operators
4156            .first()
4157            .unwrap()
4158            .config
4159            .source;
4160        // `normalize_path` builds the rewritten path with `MAIN_SEPARATOR`,
4161        // so on Windows these come back as `modules\\nested\\...`. Compare on a
4162        // normalized form -- CI runs this suite on windows-latest nightly.
4163        assert!(
4164            matches!(proc_source, dora_message::descriptor::OperatorSource::SharedLibrary(path) if path.replace('\\', "/") == "modules/nested/libproc.so"),
4165            "unexpected runtime operator source: {proc_source:?}"
4166        );
4167
4168        let single = expanded
4169            .nodes
4170            .iter()
4171            .find(|n| n.id.to_string() == "m.single")
4172            .unwrap();
4173        let single_source = &single.operator.as_ref().unwrap().config.source;
4174        assert!(
4175            matches!(single_source, dora_message::descriptor::OperatorSource::Python(source) if source.source.replace('\\', "/") == "modules/nested/single.py"),
4176            "unexpected single operator source: {single_source:?}"
4177        );
4178
4179        let runner = expanded
4180            .nodes
4181            .iter()
4182            .find(|n| n.id.to_string() == "m.runner")
4183            .unwrap();
4184        assert_eq!(
4185            runner
4186                .path
4187                .as_deref()
4188                .map(|p| p.replace('\\', "/"))
4189                .as_deref(),
4190            Some("modules/nested/runner.py")
4191        );
4192    }
4193
4194    /// `path: dynamic` is a sentinel, not a file: the daemon matches it
4195    /// verbatim to decide a node is externally spawned. Module expansion must
4196    /// leave it alone, or a module in a subdirectory turns it into
4197    /// `modules/nested/dynamic` and the node is spawned as a binary instead.
4198    #[test]
4199    fn expand_preserves_dynamic_source_sentinel() {
4200        let tmp = TempDir::new().unwrap();
4201        let base = tmp.path();
4202
4203        write_file(
4204            base,
4205            "modules/nested/dynamic_source.yml",
4206            r#"
4207module:
4208  name: dynamic_source
4209  inputs: [data]
4210  outputs: [result]
4211
4212nodes:
4213  - id: dyn
4214    path: dynamic
4215    inputs:
4216      x: _mod/data
4217    outputs:
4218      - result
4219"#,
4220        );
4221
4222        let desc = parse_descriptor(
4223            r#"
4224nodes:
4225  - id: src
4226    path: src.py
4227    outputs: [val]
4228  - id: m
4229    module: modules/nested/dynamic_source.yml
4230    inputs:
4231      data: src/val
4232"#,
4233        );
4234
4235        let expanded = expand_modules(&desc, base).unwrap();
4236        let dyn_node = expanded
4237            .nodes
4238            .iter()
4239            .find(|n| n.id.to_string() == "m.dyn")
4240            .unwrap();
4241
4242        assert_eq!(dyn_node.path.as_deref(), Some("dynamic"));
4243    }
4244
4245    /// Same as the `dynamic` case: `path: shell` tells the daemon to run
4246    /// `args` through a shell, so expansion must not rewrite it into a path.
4247    #[test]
4248    fn expand_preserves_shell_source_sentinel() {
4249        let tmp = TempDir::new().unwrap();
4250        let base = tmp.path();
4251
4252        write_file(
4253            base,
4254            "modules/nested/shell_source.yml",
4255            r#"
4256module:
4257  name: shell_source
4258  inputs: [data]
4259  outputs: [result]
4260
4261nodes:
4262  - id: shell
4263    path: shell
4264    args: echo hi
4265    inputs:
4266      x: _mod/data
4267    outputs:
4268      - result
4269"#,
4270        );
4271
4272        let desc = parse_descriptor(
4273            r#"
4274nodes:
4275  - id: src
4276    path: src.py
4277    outputs: [val]
4278  - id: m
4279    module: modules/nested/shell_source.yml
4280    inputs:
4281      data: src/val
4282"#,
4283        );
4284
4285        let expanded = expand_modules(&desc, base).unwrap();
4286        let shell_node = expanded
4287            .nodes
4288            .iter()
4289            .find(|n| n.id.to_string() == "m.shell")
4290            .unwrap();
4291
4292        assert_eq!(shell_node.path.as_deref(), Some("shell"));
4293    }
4294
4295    /// Expand a dataflow whose single module node `m` re-exports `result`, and
4296    /// return the mapping the downstream `sink` node ends up with. Also asserts
4297    /// that the expanded dataflow passes wiring validation, which is what
4298    /// catches a producer reference that is syntactically plausible but wrong
4299    /// for the producer's node kind (e.g. a missing operator segment).
4300    fn expand_and_resolve_sink_input(base: &Path) -> UserInputMapping {
4301        let desc = parse_descriptor(
4302            r#"
4303nodes:
4304  - id: src
4305    path: src.py
4306    outputs: [val]
4307  - id: m
4308    module: mod.yml
4309    inputs:
4310      data: src/val
4311  - id: sink
4312    path: sink.py
4313    inputs:
4314      r: m/result
4315"#,
4316        );
4317
4318        let expanded = expand_modules(&desc, base).unwrap();
4319        crate::descriptor::validate::check_wiring(&expanded).unwrap();
4320
4321        let sink = expanded
4322            .nodes
4323            .iter()
4324            .find(|n| n.id.to_string() == "sink")
4325            .unwrap();
4326        match &sink.inputs[&DataId::from("r".to_string())].mapping {
4327            InputMapping::User(m) => m.clone(),
4328            _ => panic!("expected user mapping"),
4329        }
4330    }
4331
4332    /// Regression test for #2817: a module output produced by an operator of a
4333    /// multi-operator runtime node must resolve. Consumers of a runtime node
4334    /// address outputs as `<node>/<operator>/<output>`, so the rewritten
4335    /// reference has to keep the `proc/` segment.
4336    #[test]
4337    fn expand_resolves_operator_produced_module_output() {
4338        let tmp = TempDir::new().unwrap();
4339        let base = tmp.path();
4340
4341        write_file(
4342            base,
4343            "mod.yml",
4344            r#"
4345module:
4346  name: rt
4347  inputs: [data]
4348  outputs: [result]
4349
4350nodes:
4351  - id: runtime
4352    operators:
4353      - id: proc
4354        shared-library: proc.so
4355        inputs:
4356          x: _mod/data
4357        outputs:
4358          - result
4359"#,
4360        );
4361
4362        let mapping = expand_and_resolve_sink_input(base);
4363        assert_eq!(mapping.source.to_string(), "m.runtime");
4364        assert_eq!(mapping.output.to_string(), "proc/result");
4365    }
4366
4367    /// Regression test for #2817: same for a single `operator:` node. Here the
4368    /// output stays bare — `resolve_aliases_and_set_defaults` injects the `op/`
4369    /// prefix afterwards, so adding it during expansion would double it up.
4370    #[test]
4371    fn expand_resolves_single_operator_produced_module_output() {
4372        let tmp = TempDir::new().unwrap();
4373        let base = tmp.path();
4374
4375        write_file(
4376            base,
4377            "mod.yml",
4378            r#"
4379module:
4380  name: rt
4381  inputs: [data]
4382  outputs: [result]
4383
4384nodes:
4385  - id: runtime
4386    operator:
4387      shared-library: proc.so
4388      inputs:
4389        x: _mod/data
4390      outputs:
4391        - result
4392"#,
4393        );
4394
4395        let mapping = expand_and_resolve_sink_input(base);
4396        assert_eq!(mapping.source.to_string(), "m.runtime");
4397        assert_eq!(mapping.output.to_string(), "result");
4398    }
4399
4400    #[test]
4401    fn expand_rejects_ambiguous_module_output() {
4402        let tmp = TempDir::new().unwrap();
4403        let base = tmp.path();
4404
4405        write_file(
4406            base,
4407            "mod.yml",
4408            r#"
4409module:
4410  name: ambiguous
4411  inputs: []
4412  outputs: [out]
4413
4414nodes:
4415  - id: first
4416    path: first.py
4417    outputs:
4418      - out
4419  - id: second
4420    path: second.py
4421    outputs:
4422      - out
4423"#,
4424        );
4425
4426        let desc = parse_descriptor(
4427            r#"
4428nodes:
4429  - id: m
4430    module: mod.yml
4431  - id: sink
4432    path: sink.py
4433    inputs:
4434      value: m/out
4435"#,
4436        );
4437
4438        let result = expand_modules(&desc, base);
4439        assert!(result.is_err());
4440        let msg = result.unwrap_err().to_string();
4441        assert!(msg.contains("out"), "got: {msg}");
4442        assert!(msg.contains("multiple"), "got: {msg}");
4443        assert!(msg.contains("m.first"), "got: {msg}");
4444        assert!(msg.contains("m.second"), "got: {msg}");
4445    }
4446
4447    #[test]
4448    fn expand_rejects_nested_module_private_output_export() {
4449        let tmp = TempDir::new().unwrap();
4450        let base = tmp.path();
4451
4452        write_file(
4453            base,
4454            "leaf.yml",
4455            r#"
4456module:
4457  name: leaf
4458  inputs: []
4459  outputs: [public]
4460
4461nodes:
4462  - id: worker
4463    path: worker.py
4464    outputs:
4465      - public
4466      - private
4467"#,
4468        );
4469
4470        write_file(
4471            base,
4472            "outer.yml",
4473            r#"
4474module:
4475  name: outer
4476  inputs: []
4477  outputs: [private]
4478
4479nodes:
4480  - id: nested
4481    module: leaf.yml
4482"#,
4483        );
4484
4485        let desc = parse_descriptor(
4486            r#"
4487nodes:
4488  - id: m
4489    module: outer.yml
4490  - id: sink
4491    path: sink.py
4492    inputs:
4493      value: m/private
4494"#,
4495        );
4496
4497        let result = expand_modules(&desc, base);
4498        assert!(result.is_err());
4499        let msg = result.unwrap_err().to_string();
4500        assert!(msg.contains("private"), "got: {msg}");
4501        assert!(msg.contains("no inner node produces it"), "got: {msg}");
4502    }
4503
4504    /// Regression test for #2817: `check_module_file` must accept a declared
4505    /// output produced by an operator inner node, and still reject one nothing
4506    /// produces.
4507    #[test]
4508    fn check_module_file_accepts_operator_produced_outputs() {
4509        let tmp = TempDir::new().unwrap();
4510        let base = tmp.path();
4511
4512        let path = write_file(
4513            base,
4514            "mixed_module.yml",
4515            r#"
4516module:
4517  name: mixed
4518  inputs: [data]
4519  outputs: [from_operator]
4520
4521nodes:
4522  - id: runtime
4523    operators:
4524      - id: proc
4525        shared-library: proc.so
4526        inputs:
4527          x: _mod/data
4528        outputs:
4529          - from_operator
4530"#,
4531        );
4532        check_module_file(&path).unwrap();
4533
4534        let missing = write_file(
4535            base,
4536            "missing_module.yml",
4537            r#"
4538module:
4539  name: missing
4540  outputs: [nobody_produces_this]
4541
4542nodes:
4543  - id: runtime
4544    operators:
4545      - id: proc
4546        shared-library: proc.so
4547        outputs:
4548          - something_else
4549"#,
4550        );
4551        let err = check_module_file(&missing).unwrap_err().to_string();
4552        assert!(err.contains("no inner node produces it"), "{err}");
4553    }
4554}