1use super::classify;
2use dora_message::{
3 config::{Input, InputMapping, LogSubscriptionFilter, 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
18fn is_absolute_any_platform(path: &str) -> bool {
22 Path::new(path).is_absolute() || path.starts_with('/')
23}
24
25const MAX_MODULE_DEPTH: u8 = 8;
30
31const MAX_MODULE_FILE_SIZE: u64 = 1_048_576;
33
34const MODULE_INPUT_SOURCE: &str = "_mod";
37
38type ModuleOutputMap = BTreeMap<String, UserInputMapping>;
43
44#[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#[derive(Debug, Clone, Deserialize)]
59#[serde(deny_unknown_fields)]
60struct ModuleFile {
61 module: ModuleHeader,
62 nodes: Vec<Node>,
63 #[serde(default)]
65 build: Option<String>,
66}
67
68#[derive(Debug, Clone, Default)]
71pub struct ModuleBoundaries {
72 pub modules: BTreeMap<String, Vec<String>>,
74}
75
76#[derive(Debug, Clone)]
78pub struct ExpandedDescriptor {
79 pub descriptor: Descriptor,
80 pub boundaries: ModuleBoundaries,
81}
82
83pub fn expand_modules(descriptor: &Descriptor, base_dir: &Path) -> eyre::Result<Descriptor> {
89 Ok(expand_modules_with_boundaries(descriptor, base_dir)?.descriptor)
90}
91
92pub 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 project_root = normalize_path(dunce::simplified(
107 &std::path::absolute(base_dir)
108 .with_context(|| format!("failed to make base_dir absolute: {}", base_dir.display()))?,
109 ));
110 let canonical_project_root = dunce::canonicalize(&project_root)
111 .with_context(|| format!("failed to resolve project root: {}", project_root.display()))?;
112 let mut seen = HashSet::new();
113 let mut flat_nodes = Vec::new();
114 let mut output_maps: BTreeMap<String, ModuleOutputMap> = BTreeMap::new();
115 let mut boundaries = ModuleBoundaries::default();
116
117 for node in &descriptor.nodes {
118 if node.module.is_some() {
119 let (mut expanded, omap) = expand_module_node(
122 node,
123 base_dir,
124 &project_root,
125 &canonical_project_root,
126 0,
127 &mut seen,
128 )?;
129 if let Some(ref outer_build) = node.build {
134 for expanded_node in &mut expanded {
135 prepend_module_build_to_node(expanded_node, outer_build);
136 }
137 }
138 let module_id = node.id.to_string();
139 output_maps.insert(module_id.clone(), omap);
140 let node_ids: Vec<String> = expanded.iter().map(|n| n.id.to_string()).collect();
141 boundaries.modules.insert(module_id, node_ids);
142 flat_nodes.extend(expanded);
143 } else {
144 flat_nodes.push(node.clone());
145 }
146 }
147
148 rewrite_external_refs(&mut flat_nodes, &output_maps)?;
149
150 let mut id_set = HashSet::new();
152 for node in &flat_nodes {
153 if !id_set.insert(node.id.to_string()) {
154 bail!(
155 "duplicate node ID `{}` after module expansion — check for \
156 conflicting node names across modules and top-level nodes",
157 node.id
158 );
159 }
160 }
161
162 let mut expanded = descriptor.clone();
169 expanded.nodes = flat_nodes;
170
171 Ok(ExpandedDescriptor {
172 descriptor: expanded,
173 boundaries,
174 })
175}
176
177pub fn check_module_file(module_path: &Path) -> eyre::Result<()> {
186 let canonical = module_path
187 .canonicalize()
188 .with_context(|| format!("module file not found: {}", module_path.display()))?;
189 let mut seen = HashSet::new();
190 check_module_file_inner(&canonical, 0, &mut seen)
191}
192
193fn check_module_file_inner(
194 canonical: &Path,
195 depth: u8,
196 seen: &mut HashSet<PathBuf>,
197) -> eyre::Result<()> {
198 if depth >= MAX_MODULE_DEPTH {
199 bail!(
200 "module nesting exceeds depth limit of {MAX_MODULE_DEPTH} while checking module file: {}",
201 canonical.display()
202 );
203 }
204
205 if !seen.insert(canonical.to_path_buf()) {
206 bail!(
207 "circular module reference detected while checking module file: {}\n\
208 hint: check that module files do not reference each other in a cycle",
209 canonical.display()
210 );
211 }
212
213 let module_file = load_module_file(canonical)?;
214 validate_module_header(&module_file.module)?;
215 let module_dir = canonical
216 .parent()
217 .expect("module file must have a parent directory");
218
219 let all_input_names: BTreeSet<String> = module_file
221 .module
222 .inputs
223 .iter()
224 .chain(module_file.module.inputs_optional.iter())
225 .map(|d| d.to_string())
226 .collect();
227
228 for node in &module_file.nodes {
233 for inputs in node_input_maps(node) {
234 check_mod_refs(&module_file.module.name, &node.id, inputs, &all_input_names)?;
235 }
236 }
237 let module_outputs = collect_module_source_outputs(&module_file, module_dir)?;
238 check_internal_wiring(
239 &module_file.module.name,
240 &module_file.nodes,
241 &module_outputs,
242 )?;
243
244 let mut inner_outputs: BTreeMap<String, Vec<String>> = BTreeMap::new();
250 for node in module_file.nodes.iter().filter(|n| n.module.is_none()) {
251 for (name, output_ref) in node_output_refs(node) {
252 inner_outputs
253 .entry(name)
254 .or_default()
255 .push(format!("{}/{}", node.id, output_ref));
256 }
257 }
258
259 for node in &module_file.nodes {
261 if let Some(ref mod_path) = node.module {
262 if is_absolute_any_platform(mod_path) {
263 bail!(
264 "module `{}`: nested module path `{}` must be relative (node `{}`)",
265 module_file.module.name,
266 mod_path,
267 node.id,
268 );
269 }
270 classify::check_module(node)
274 .with_context(|| format!("invalid module node `{}`", node.id))?;
275 let nested = module_dir.join(mod_path);
276 let nested_canonical = nested.canonicalize().with_context(|| {
277 format!(
278 "module `{}`: nested module `{}` referenced by node `{}` not found",
279 module_file.module.name, mod_path, node.id,
280 )
281 })?;
282 let nested_module = load_module_file(&nested_canonical)?;
298 check_nested_module_required_inputs(
299 &module_file.module.name,
300 &node.id,
301 &nested_module.module,
302 &node.inputs,
303 )?;
304 check_module_file_inner(&nested_canonical, depth + 1, seen).with_context(|| {
305 format!(
306 "module `{}`: while checking nested module `{}` referenced by node `{}`",
307 module_file.module.name, nested_module.module.name, node.id,
308 )
309 })?;
310 for output in &nested_module.module.outputs {
311 inner_outputs
312 .entry(output.to_string())
313 .or_default()
314 .push(format!("{}/{}", node.id, output));
315 }
316 }
317 }
318
319 for declared_output in &module_file.module.outputs {
320 let output_str = declared_output.to_string();
321 match inner_outputs.get(&output_str) {
322 None => {
323 bail!(
324 "module `{}` declares output `{}` but no inner node produces it",
325 module_file.module.name,
326 declared_output,
327 );
328 }
329 Some(producers) if producers.len() > 1 => {
330 bail!(
331 "module `{}` declares output `{}` but multiple inner nodes produce it: {}",
332 module_file.module.name,
333 declared_output,
334 producers.join(", "),
335 );
336 }
337 Some(_) => {}
338 }
339 }
340
341 seen.remove(canonical);
342 Ok(())
343}
344
345fn collect_module_source_outputs(
346 module_file: &ModuleFile,
347 module_dir: &Path,
348) -> eyre::Result<BTreeMap<String, BTreeSet<String>>> {
349 let mut outputs = BTreeMap::new();
350 for node in &module_file.nodes {
351 let node_id = node.id.to_string();
352 if outputs.contains_key(&node_id) {
353 bail!(
354 "module `{}` has duplicate node ID `{}`",
355 module_file.module.name,
356 node.id,
357 );
358 }
359
360 let node_outputs = if let Some(ref mod_path) = node.module {
361 if is_absolute_any_platform(mod_path) {
362 bail!(
363 "module `{}`: nested module path `{}` must be relative (node `{}`)",
364 module_file.module.name,
365 mod_path,
366 node.id,
367 );
368 }
369 let nested = module_dir.join(mod_path);
370 let nested_canonical = nested.canonicalize().with_context(|| {
371 format!(
372 "module `{}`: nested module `{}` referenced by node `{}` not found",
373 module_file.module.name, mod_path, node.id,
374 )
375 })?;
376 let nested_module = load_module_file(&nested_canonical)?;
377 nested_module
378 .module
379 .outputs
380 .iter()
381 .map(|output| output.to_string())
382 .collect()
383 } else {
384 node_output_refs(node)
385 .into_iter()
386 .map(|(_, output_ref)| output_ref)
387 .collect()
388 };
389
390 outputs.insert(node_id, node_outputs);
391 }
392 Ok(outputs)
393}
394
395fn check_internal_wiring(
396 module_name: &str,
397 nodes: &[Node],
398 module_outputs: &BTreeMap<String, BTreeSet<String>>,
399) -> eyre::Result<()> {
400 for node in nodes {
401 for inputs in node_input_maps(node) {
402 for (input_id, input) in inputs {
403 if let InputMapping::User(mapping) = &input.mapping {
404 let source = mapping.source.to_string();
405 if source == MODULE_INPUT_SOURCE {
406 continue;
407 }
408 if let Some(outputs) = module_outputs.get(&source) {
409 let output = mapping.output.to_string();
410 if !outputs.contains(&output) {
411 bail!(
412 "module `{}`: node `{}` input `{}` references \
413 `{}/{}` but that output is not produced",
414 module_name,
415 node.id,
416 input_id,
417 source,
418 output,
419 );
420 }
421 }
422 }
423 }
424 }
425 }
426 Ok(())
427}
428
429fn validate_module_header(module: &ModuleHeader) -> eyre::Result<()> {
430 reject_duplicate_ports(&module.name, "inputs", &module.inputs)?;
431 reject_duplicate_ports(&module.name, "inputs_optional", &module.inputs_optional)?;
432 reject_duplicate_ports(&module.name, "outputs", &module.outputs)?;
433
434 let required: BTreeSet<_> = module.inputs.iter().collect();
435 if let Some(overlap) = module
436 .inputs_optional
437 .iter()
438 .find(|input| required.contains(input))
439 {
440 bail!(
441 "module `{}` input `{}` is declared as both required and optional",
442 module.name,
443 overlap
444 );
445 }
446 Ok(())
447}
448
449fn reject_duplicate_ports(module_name: &str, field: &str, ports: &[DataId]) -> eyre::Result<()> {
450 let mut seen = BTreeSet::new();
451 for port in ports {
452 if !seen.insert(port) {
453 bail!("module `{module_name}` has duplicate `{field}` entry `{port}`");
454 }
455 }
456 Ok(())
457}
458
459fn check_nested_module_required_inputs(
460 module_name: &str,
461 node_id: &NodeId,
462 nested_module: &ModuleHeader,
463 node_inputs: &BTreeMap<DataId, Input>,
464) -> eyre::Result<()> {
465 for declared_input in &nested_module.inputs {
466 if !node_inputs.contains_key(declared_input) {
467 bail!(
468 "module `{}`: nested module `{}` declares required input `{}` \
469 but node `{}` does not provide it",
470 module_name,
471 nested_module.name,
472 declared_input,
473 node_id,
474 );
475 }
476 }
477 Ok(())
478}
479
480fn check_mod_refs(
485 module_name: &str,
486 node_id: &NodeId,
487 inputs: &BTreeMap<DataId, Input>,
488 all_input_names: &BTreeSet<String>,
489) -> eyre::Result<()> {
490 for (input_id, input) in inputs {
491 if let InputMapping::User(m) = &input.mapping
492 && m.source.to_string() == MODULE_INPUT_SOURCE
493 {
494 let port = m.output.to_string();
495 if !all_input_names.contains(&port) {
496 bail!(
497 "module `{}`: node `{}` input `{}` references \
498 `_mod/{}` but `{}` is not declared in module \
499 inputs or inputs_optional",
500 module_name,
501 node_id,
502 input_id,
503 port,
504 port,
505 );
506 }
507 }
508 }
509 Ok(())
510}
511
512fn node_input_maps(node: &Node) -> Vec<&BTreeMap<DataId, Input>> {
518 let mut maps = vec![&node.inputs];
519 if let Some(ref operators) = node.operators {
520 maps.extend(operators.operators.iter().map(|op| &op.config.inputs));
521 }
522 if let Some(ref operator) = node.operator {
523 maps.push(&operator.config.inputs);
524 }
525 maps
526}
527
528fn node_input_maps_mut(node: &mut Node) -> Vec<&mut BTreeMap<DataId, Input>> {
531 let mut maps = vec![&mut node.inputs];
532 if let Some(ref mut operators) = node.operators {
533 maps.extend(
534 operators
535 .operators
536 .iter_mut()
537 .map(|op| &mut op.config.inputs),
538 );
539 }
540 if let Some(ref mut operator) = node.operator {
541 maps.push(&mut operator.config.inputs);
542 }
543 maps
544}
545
546fn node_output_refs(node: &Node) -> Vec<(String, String)> {
560 fn bare(outputs: &BTreeSet<DataId>) -> impl Iterator<Item = (String, String)> {
561 outputs.iter().map(|o| (o.to_string(), o.to_string()))
562 }
563
564 let mut refs: Vec<(String, String)> = bare(&node.outputs).collect();
565 if let Some(ref operators) = node.operators {
566 for op in &operators.operators {
567 refs.extend(
568 op.config
569 .outputs
570 .iter()
571 .map(|o| (o.to_string(), format!("{}/{o}", op.id))),
572 );
573 }
574 }
575 if let Some(ref operator) = node.operator {
576 refs.extend(bare(&operator.config.outputs));
577 }
578 refs
579}
580
581fn expand_module_node(
585 node: &Node,
586 base_dir: &Path,
587 project_root: &Path,
588 canonical_project_root: &Path,
589 depth: u8,
590 seen: &mut HashSet<PathBuf>,
591) -> eyre::Result<(Vec<Node>, ModuleOutputMap)> {
592 if depth >= MAX_MODULE_DEPTH {
593 bail!(
594 "module nesting exceeds depth limit of {MAX_MODULE_DEPTH} \
595 (node `{}`)",
596 node.id
597 );
598 }
599
600 classify::check_module(node).with_context(|| format!("invalid module node `{}`", node.id))?;
606
607 let module_path_str = node
608 .module
609 .as_ref()
610 .expect("expand_module_node called on non-module node");
611
612 if is_absolute_any_platform(module_path_str) {
614 bail!(
615 "module path `{}` must be relative (node `{}`)",
616 module_path_str,
617 node.id
618 );
619 }
620
621 let mut current = base_dir.to_path_buf();
623 let mut symlink_target_root: Option<PathBuf> = None;
624 for component in Path::new(module_path_str).components() {
625 match component {
626 std::path::Component::Normal(c) => {
627 current.push(c);
628 if let Ok(meta) = std::fs::symlink_metadata(¤t)
629 && meta.is_symlink()
630 && symlink_target_root.is_none()
631 {
632 symlink_target_root = dunce::canonicalize(¤t).ok();
633 }
634 }
635 std::path::Component::ParentDir => {
636 if let Ok(meta) = std::fs::symlink_metadata(¤t)
637 && meta.is_symlink()
638 {
639 let target = dunce::canonicalize(¤t).ok();
640 let is_in_tree = target
641 .as_ref()
642 .is_some_and(|t| t.starts_with(canonical_project_root));
643 if !is_in_tree {
644 bail!(
645 "module path `{}` escapes the project directory (node `{}`)",
646 module_path_str,
647 node.id
648 );
649 }
650 }
651 current.pop();
652 }
653 _ => {}
654 }
655 }
656
657 let module_path = base_dir.join(module_path_str);
658 let canonical = dunce::canonicalize(&module_path)
659 .with_context(|| format!("module file not found: {}", module_path.display()))?;
660
661 let absolute_module = normalize_path(dunce::simplified(
662 &std::path::absolute(&module_path).with_context(|| {
663 format!(
664 "failed to make module path absolute: {}",
665 module_path.display()
666 )
667 })?,
668 ));
669 if !absolute_module.starts_with(project_root) {
670 bail!(
671 "module path `{}` escapes the project directory (node `{}`)",
672 module_path_str,
673 node.id
674 );
675 }
676
677 if dunce::canonicalize(&absolute_module).ok().as_ref() != Some(&canonical) {
680 bail!(
681 "module path `{}` escapes the project directory (node `{}`)",
682 module_path_str,
683 node.id
684 );
685 }
686
687 let in_project = canonical.starts_with(canonical_project_root);
691 let in_symlink = symlink_target_root
692 .as_ref()
693 .is_some_and(|target| canonical.starts_with(target));
694
695 if !in_project && !in_symlink {
696 bail!(
697 "module path `{}` physically escapes the project directory (node `{}`)",
698 module_path_str,
699 node.id
700 );
701 }
702
703 if !seen.insert(canonical.clone()) {
704 bail!(
705 "circular module reference detected: {} (node `{}`)\n\
706 hint: check that module files do not reference each other \
707 in a cycle",
708 module_path.display(),
709 node.id
710 );
711 }
712
713 let module_file = load_module_file(&canonical)?;
714 validate_module_header(&module_file.module)?;
715 let module_id = node.id.to_string();
716 let module_dir = absolute_module
717 .parent()
718 .expect("module file must have a parent directory");
719
720 for declared_input in &module_file.module.inputs {
722 if !node.inputs.contains_key(declared_input) {
723 bail!(
724 "module `{}` declares required input `{}` but node `{}` \
725 does not provide it\n\
726 hint: add `{}: <source_node>/<output>` to the node's inputs",
727 module_file.module.name,
728 declared_input,
729 node.id,
730 declared_input,
731 );
732 }
733 }
734 let optional_inputs: BTreeSet<String> = module_file
739 .module
740 .inputs_optional
741 .iter()
742 .map(|d| d.to_string())
743 .collect();
744 let declared_inputs: BTreeSet<String> = module_file
745 .module
746 .inputs
747 .iter()
748 .chain(module_file.module.inputs_optional.iter())
749 .map(|d| d.to_string())
750 .collect();
751 for provided_input in node.inputs.keys() {
752 let provided = provided_input.to_string();
753 if !declared_inputs.contains(&provided) {
754 bail!(
755 "module `{}` does not declare input `{}` provided by node `{}`\n\
756 hint: declared inputs are: {}",
757 module_file.module.name,
758 provided_input,
759 node.id,
760 declared_inputs
761 .iter()
762 .cloned()
763 .collect::<Vec<_>>()
764 .join(", "),
765 );
766 }
767 }
768
769 let mut seen_upper: BTreeMap<String, &String> = BTreeMap::new();
771 for key in node.params.keys() {
772 if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') || key.is_empty() {
773 bail!(
774 "invalid param key `{}` in node `{}`: must be non-empty and \
775 contain only [A-Za-z0-9_]",
776 key,
777 node.id
778 );
779 }
780 let upper = key.to_uppercase();
786 if let Some(existing) = seen_upper.insert(upper.clone(), key) {
787 bail!(
788 "param keys `{}` and `{}` in node `{}` collide: both map to the \
789 env var `PARAM_{}`. Param keys must be unique case-insensitively.",
790 existing,
791 key,
792 node.id,
793 upper
794 );
795 }
796 }
797 let params = &node.params;
798
799 let inner_node_ids: BTreeSet<String> =
801 module_file.nodes.iter().map(|n| n.id.to_string()).collect();
802
803 let mut prefixed_nodes = Vec::new();
805 for mut inner_node in module_file.nodes {
806 let prefixed_id: NodeId = format!("{module_id}.{}", inner_node.id).into();
807 inner_node.id = prefixed_id;
808
809 for inputs in node_input_maps_mut(&mut inner_node) {
817 *inputs = rewrite_module_inputs_map(
818 inputs,
819 &module_id,
820 &node.inputs,
821 &inner_node_ids,
822 &optional_inputs,
823 )?;
824 }
825
826 resolve_inner_node_paths(&mut inner_node, module_dir, project_root)?;
827
828 if inner_node.deploy.is_none() {
830 inner_node.deploy = node.deploy.clone();
831 }
832
833 propagate_module_node_env(&mut inner_node, node.env.as_ref());
834
835 if !params.is_empty() {
837 substitute_params_in_node(&mut inner_node, params);
838 }
839
840 if let Some(ref module_build) = module_file.build {
842 prepend_module_build_to_node(&mut inner_node, module_build);
843 }
844
845 prefixed_nodes.push(inner_node);
846 }
847
848 let mut nested_output_maps: BTreeMap<String, ModuleOutputMap> = BTreeMap::new();
852 let mut direct_output_targets: BTreeMap<String, Vec<(String, UserInputMapping)>> =
853 BTreeMap::new();
854 let mut final_nodes = Vec::new();
855 for inner_node in prefixed_nodes {
856 if inner_node.module.is_some() {
857 let nested_id = inner_node.id.to_string();
858 let accumulated_build = inner_node.build.clone();
859 let (mut nested, nested_omap) = expand_module_node(
860 &inner_node,
861 module_dir,
862 project_root,
863 canonical_project_root,
864 depth + 1,
865 seen,
866 )?;
867 if let Some(ref outer_build) = accumulated_build {
870 for nested_node in &mut nested {
871 prepend_module_build_to_node(nested_node, outer_build);
872 }
873 }
874 for (output, target) in &nested_omap {
875 direct_output_targets
876 .entry(output.clone())
877 .or_default()
878 .push((format!("{nested_id}/{output}"), target.clone()));
879 }
880 nested_output_maps.insert(nested_id, nested_omap);
881 final_nodes.extend(nested);
882 } else {
883 for (name, output_ref) in node_output_refs(&inner_node) {
884 let output: DataId = output_ref.parse().map_err(|e| {
891 eyre::eyre!(
892 "node `{}` produces an invalid output id `{output_ref}`: {e}",
893 inner_node.id
894 )
895 })?;
896 direct_output_targets.entry(name).or_default().push((
897 format!("{}/{output}", inner_node.id),
898 UserInputMapping {
899 source: inner_node.id.clone(),
900 output,
901 },
902 ));
903 }
904 final_nodes.push(inner_node);
905 }
906 }
907
908 if !nested_output_maps.is_empty() {
910 rewrite_external_refs(&mut final_nodes, &nested_output_maps)?;
911 }
912
913 let mut output_map = ModuleOutputMap::new();
918 for declared_output in &module_file.module.outputs {
919 let declared = declared_output.to_string();
920 let target = match direct_output_targets.get(&declared).map(Vec::as_slice) {
921 None | Some([]) => {
922 bail!(
923 "module `{}` declares output `{}` but no inner node produces it",
924 module_file.module.name,
925 declared_output,
926 );
927 }
928 Some([(_, target)]) => target.clone(),
929 Some(targets) => {
930 let producers = targets
931 .iter()
932 .map(|(producer, _)| producer.as_str())
933 .collect::<Vec<_>>()
934 .join(", ");
935 bail!(
936 "module `{}` declares output `{}` but multiple inner nodes produce it: {}",
937 module_file.module.name,
938 declared_output,
939 producers,
940 );
941 }
942 };
943 output_map.insert(declared, target);
944 }
945
946 seen.remove(&canonical);
949
950 Ok((final_nodes, output_map))
951}
952
953fn resolve_inner_node_paths(
954 node: &mut Node,
955 module_dir: &Path,
956 project_root: &Path,
957) -> eyre::Result<()> {
958 let owner = node.id.to_string();
959 if node.git.is_none()
962 && let Some(ref mut path) = node.path
963 {
964 resolve_module_relative_path(path, module_dir, project_root, &owner)?;
965 }
966 if let Some(ref mut operators) = node.operators {
967 for op in &mut operators.operators {
968 resolve_operator_source_paths(&mut op.config, module_dir, project_root, &owner)?;
969 }
970 }
971 if let Some(ref mut operator) = node.operator {
972 resolve_operator_source_paths(&mut operator.config, module_dir, project_root, &owner)?;
973 }
974 Ok(())
975}
976
977fn resolve_operator_source_paths(
978 config: &mut OperatorConfig,
979 module_dir: &Path,
980 project_root: &Path,
981 owner: &str,
982) -> eyre::Result<()> {
983 match &mut config.source {
984 OperatorSource::SharedLibrary(path) | OperatorSource::Wasm(path) => {
985 resolve_module_relative_path(path, module_dir, project_root, owner)
986 }
987 OperatorSource::Python(source) => {
988 resolve_module_relative_path(&mut source.source, module_dir, project_root, owner)
989 }
990 }
991}
992
993fn resolve_module_relative_path(
994 path: &mut String,
995 module_dir: &Path,
996 project_root: &Path,
997 owner: &str,
998) -> eyre::Result<()> {
999 if path == DYNAMIC_SOURCE
1004 || path == SHELL_SOURCE
1005 || super::source_is_url(path)
1006 || is_absolute_any_platform(path)
1011 {
1012 return Ok(());
1013 }
1014
1015 let resolved = normalize_path(&module_dir.join(path.as_str()));
1016 let relative = resolved.strip_prefix(project_root).map_err(|_| {
1017 eyre::eyre!(
1018 "module node `{}` path `{}` resolves outside the project \
1019 directory (resolved to `{}`)",
1020 owner,
1021 path,
1022 resolved.display()
1023 )
1024 })?;
1025 *path = relative.to_string_lossy().into_owned();
1026 Ok(())
1027}
1028
1029fn prepend_module_build_to_node(node: &mut Node, module_build: &str) {
1030 let is_standard_or_module = node.module.is_some()
1043 || (node.operators.is_none() && node.operator.is_none() && node.ros2.is_none());
1044 if is_standard_or_module {
1045 prepend_build(&mut node.build, module_build);
1046 }
1047 if let Some(ref mut operators) = node.operators {
1048 for op in &mut operators.operators {
1049 prepend_build(&mut op.config.build, module_build);
1050 }
1051 }
1052 if let Some(ref mut operator) = node.operator {
1053 prepend_build(&mut operator.config.build, module_build);
1054 }
1055}
1056
1057fn prepend_build(build: &mut Option<String>, module_build: &str) {
1058 let existing = build.take();
1059 *build = Some(match existing {
1060 Some(existing) => format!("{module_build}\n{existing}"),
1061 None => module_build.to_string(),
1062 });
1063}
1064
1065fn propagate_module_node_env(
1073 inner_node: &mut Node,
1074 module_env: Option<&BTreeMap<String, EnvValue>>,
1075) {
1076 let Some(module_env) = module_env.filter(|env| !env.is_empty()) else {
1077 return;
1078 };
1079 let env = inner_node.env.get_or_insert_with(BTreeMap::new);
1080 for (key, value) in module_env {
1081 env.entry(key.clone()).or_insert_with(|| value.clone());
1082 }
1083}
1084
1085fn substitute_params_in_node(node: &mut Node, params: &BTreeMap<String, String>) {
1088 if let Some(ref mut args) = node.args {
1090 *args = substitute_params_in_str(args, params);
1091 }
1092
1093 let env = node.env.get_or_insert_with(BTreeMap::new);
1095 for (key, value) in params {
1096 env.insert(
1097 format!("PARAM_{}", key.to_uppercase()),
1098 EnvValue::String(value.clone()),
1099 );
1100 }
1101}
1102
1103fn substitute_params_in_str(s: &str, params: &BTreeMap<String, String>) -> String {
1122 const BRACED: &str = "${_param.";
1123 const ENV_STYLE: &str = "$PARAM_";
1124
1125 let env_style: BTreeMap<String, &String> = params
1130 .iter()
1131 .map(|(key, value)| (key.to_uppercase(), value))
1132 .collect();
1133
1134 let mut result = String::with_capacity(s.len());
1135 let mut rest = s;
1136 while let Some(start) = rest.find('$') {
1137 result.push_str(&rest[..start]);
1138 let at_token = &rest[start..];
1139
1140 if let Some(after_prefix) = at_token.strip_prefix(BRACED) {
1141 match after_prefix.find('}') {
1142 Some(end) => {
1143 let key = &after_prefix[..end];
1144 match params.get(key) {
1145 Some(value) => result.push_str(value),
1146 None => {
1148 result.push_str(BRACED);
1149 result.push_str(key);
1150 result.push('}');
1151 }
1152 }
1153 rest = &after_prefix[end + 1..];
1156 }
1157 None => {
1160 result.push_str(BRACED);
1161 rest = after_prefix;
1162 }
1163 }
1164 } else if let Some(after_prefix) = at_token.strip_prefix(ENV_STYLE) {
1165 let end = after_prefix
1169 .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
1170 .unwrap_or(after_prefix.len());
1171 let key = &after_prefix[..end];
1172 match env_style.get(key) {
1173 Some(value) => result.push_str(value),
1174 None => {
1176 result.push_str(ENV_STYLE);
1177 result.push_str(key);
1178 }
1179 }
1180 rest = &after_prefix[end..];
1181 } else {
1182 result.push('$');
1185 rest = &at_token[1..];
1186 }
1187 }
1188 result.push_str(rest);
1189 result
1190}
1191
1192fn rewrite_module_input(
1200 input: &Input,
1201 module_id: &str,
1202 module_inputs: &BTreeMap<DataId, Input>,
1203 inner_node_ids: &BTreeSet<String>,
1204 optional_inputs: &BTreeSet<String>,
1205) -> eyre::Result<Option<Input>> {
1206 match &input.mapping {
1207 InputMapping::Logs(LogSubscriptionFilter {
1211 min_level,
1212 node_filter: Some(node),
1213 }) if inner_node_ids.contains(node.as_ref()) => Ok(Some(Input {
1214 mapping: InputMapping::Logs(LogSubscriptionFilter {
1215 min_level: min_level.clone(),
1216 node_filter: Some(format!("{module_id}.{node}").into()),
1217 }),
1218 ..input.clone()
1219 })),
1220 InputMapping::Timer { .. } | InputMapping::Logs(_) => Ok(Some(input.clone())),
1221 InputMapping::User(user_mapping) => {
1222 let source_str = user_mapping.source.to_string();
1223
1224 if source_str == MODULE_INPUT_SOURCE {
1225 let port_name = user_mapping.output.to_string();
1227 match module_inputs.get(&*port_name) {
1228 Some(bound_input) => {
1229 Ok(Some(Input {
1232 mapping: bound_input.mapping.clone(),
1233 queue_size: input.queue_size.or(bound_input.queue_size),
1234 input_timeout: input.input_timeout.or(bound_input.input_timeout),
1235 queue_policy: input.queue_policy.or(bound_input.queue_policy),
1236 }))
1237 }
1238 None if optional_inputs.contains(&port_name) => Ok(None),
1239 None => bail!(
1240 "module input reference `_mod/{}` not found in module node inputs",
1241 port_name,
1242 ),
1243 }
1244 } else if inner_node_ids.contains(&source_str) {
1245 Ok(Some(Input {
1247 mapping: InputMapping::User(UserInputMapping {
1248 source: format!("{module_id}.{source_str}").into(),
1249 output: user_mapping.output.clone(),
1250 }),
1251 queue_size: input.queue_size,
1252 input_timeout: input.input_timeout,
1253 queue_policy: input.queue_policy,
1254 }))
1255 } else {
1256 Ok(Some(input.clone()))
1258 }
1259 }
1260 }
1261}
1262
1263fn rewrite_module_inputs_map(
1268 inputs: &BTreeMap<DataId, Input>,
1269 module_id: &str,
1270 module_inputs: &BTreeMap<DataId, Input>,
1271 inner_node_ids: &BTreeSet<String>,
1272 optional_inputs: &BTreeSet<String>,
1273) -> eyre::Result<BTreeMap<DataId, Input>> {
1274 let mut new_inputs = BTreeMap::new();
1275 for (input_id, input) in inputs {
1276 if let Some(new_input) = rewrite_module_input(
1277 input,
1278 module_id,
1279 module_inputs,
1280 inner_node_ids,
1281 optional_inputs,
1282 )? {
1283 new_inputs.insert(input_id.clone(), new_input);
1284 }
1285 }
1286 Ok(new_inputs)
1287}
1288
1289fn rewrite_external_refs(
1297 nodes: &mut [Node],
1298 output_maps: &BTreeMap<String, ModuleOutputMap>,
1299) -> eyre::Result<()> {
1300 if output_maps.is_empty() {
1301 return Ok(());
1302 }
1303
1304 for node in nodes.iter_mut() {
1305 rewrite_inputs_map(&mut node.inputs, output_maps, &node.id)?;
1306
1307 if let Some(ref mut operators) = node.operators {
1308 for op in &mut operators.operators {
1309 rewrite_inputs_map(&mut op.config.inputs, output_maps, &node.id)?;
1310 }
1311 }
1312 if let Some(ref mut operator) = node.operator {
1313 rewrite_inputs_map(&mut operator.config.inputs, output_maps, &node.id)?;
1314 }
1315 }
1316 Ok(())
1317}
1318
1319fn rewrite_inputs_map(
1320 inputs: &mut BTreeMap<DataId, Input>,
1321 output_maps: &BTreeMap<String, ModuleOutputMap>,
1322 node_id: &NodeId,
1323) -> eyre::Result<()> {
1324 let mut new_inputs = BTreeMap::new();
1325 for (input_id, input) in inputs.iter() {
1326 let new_input = match &input.mapping {
1327 InputMapping::User(user_mapping) => {
1328 let source_str = user_mapping.source.to_string();
1329 if let Some(omap) = output_maps.get(&source_str) {
1330 let output_str = user_mapping.output.to_string();
1331 if let Some(target) = omap.get(&output_str) {
1332 Input {
1333 mapping: InputMapping::User(target.clone()),
1334 queue_size: input.queue_size,
1335 input_timeout: input.input_timeout,
1336 queue_policy: input.queue_policy,
1337 }
1338 } else {
1339 bail!(
1340 "node `{}` references `{}/{}` but module `{}` \
1341 does not declare output `{}`",
1342 node_id,
1343 source_str,
1344 output_str,
1345 source_str,
1346 output_str,
1347 );
1348 }
1349 } else {
1350 input.clone()
1351 }
1352 }
1353 InputMapping::Timer { .. } | InputMapping::Logs(_) => input.clone(),
1354 };
1355 new_inputs.insert(input_id.clone(), new_input);
1356 }
1357 *inputs = new_inputs;
1358 Ok(())
1359}
1360
1361fn load_module_file(path: &Path) -> eyre::Result<ModuleFile> {
1362 use std::io::Read as _;
1363 let file = std::fs::File::open(path)
1371 .with_context(|| format!("failed to read module file: {}", path.display()))?;
1372 let mut buf = Vec::new();
1373 file.take(MAX_MODULE_FILE_SIZE + 1)
1374 .read_to_end(&mut buf)
1375 .with_context(|| format!("failed to read module file: {}", path.display()))?;
1376 if buf.len() as u64 > MAX_MODULE_FILE_SIZE {
1377 bail!(
1378 "module file too large (limit {} bytes): {}",
1379 MAX_MODULE_FILE_SIZE,
1380 path.display()
1381 );
1382 }
1383 serde_yaml::from_slice(&buf)
1384 .with_context(|| format!("failed to parse module file: {}", path.display()))
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use super::*;
1390 use std::io::Write;
1391 use tempfile::TempDir;
1392
1393 fn write_file(dir: &Path, name: &str, content: &str) -> PathBuf {
1394 let path = dir.join(name);
1395 if let Some(parent) = path.parent() {
1396 std::fs::create_dir_all(parent).unwrap();
1397 }
1398 let mut f = std::fs::File::create(&path).unwrap();
1399 f.write_all(content.as_bytes()).unwrap();
1400 path
1401 }
1402
1403 fn parse_descriptor(yaml: &str) -> Descriptor {
1404 serde_yaml::from_str(yaml).unwrap()
1405 }
1406
1407 #[test]
1417 fn expand_preserves_exit_when_nodes_finish() {
1418 let tmp = TempDir::new().unwrap();
1419 let base = tmp.path();
1420 write_file(
1421 base,
1422 "echo_module.yml",
1423 r#"
1424module:
1425 name: echo
1426 inputs: [data_in]
1427 outputs: [data_out]
1428
1429nodes:
1430 - id: passthrough
1431 path: echo.py
1432 inputs:
1433 incoming: _mod/data_in
1434 outputs:
1435 - data_out
1436"#,
1437 );
1438 let descriptor = parse_descriptor(
1439 r#"
1440exit_when_nodes_finish: true
1441
1442nodes:
1443 - id: source
1444 path: source.py
1445 outputs:
1446 - number
1447
1448 - id: my_echo
1449 module: echo_module.yml
1450 inputs:
1451 data_in: source/number
1452"#,
1453 );
1454 assert_eq!(descriptor.exit_when_nodes_finish, Some(true));
1455 assert!(
1456 descriptor.nodes.iter().any(|n| n.module.is_some()),
1457 "precondition: without a module, expansion clones the whole \
1458 descriptor and this test would pass even if the rebuild \
1459 dropped the field"
1460 );
1461
1462 let expanded = expand_modules(&descriptor, base).unwrap();
1463 assert_eq!(
1464 expanded.exit_when_nodes_finish,
1465 Some(true),
1466 "expansion must carry the completion policy through, or a \
1467 module-using dataflow silently loses it and never ends"
1468 );
1469 }
1470
1471 #[test]
1475 fn expand_leaves_exit_when_nodes_finish_unset() {
1476 let tmp = TempDir::new().unwrap();
1477 let descriptor = parse_descriptor(
1478 "nodes:\n \
1479 - id: worker\n \
1480 path: ./worker\n",
1481 );
1482 assert_eq!(descriptor.exit_when_nodes_finish, None);
1483 let expanded = expand_modules(&descriptor, tmp.path()).unwrap();
1484 assert_eq!(expanded.exit_when_nodes_finish, None);
1485 }
1486
1487 #[test]
1494 fn expand_rejects_invalid_operator_output_id_without_panicking() {
1495 let tmp = TempDir::new().unwrap();
1496 let base = tmp.path();
1497 write_file(
1498 base,
1499 "bad_module.yml",
1500 r#"
1501module:
1502 name: bad
1503 outputs: [data_out]
1504
1505nodes:
1506 - id: runtime_node
1507 operators:
1508 - id: "bad id"
1509 shared-library: op
1510 outputs:
1511 - data_out
1512"#,
1513 );
1514 let descriptor = parse_descriptor(
1515 r#"
1516nodes:
1517 - id: my_mod
1518 module: bad_module.yml
1519"#,
1520 );
1521 let err = expand_modules(&descriptor, base)
1522 .expect_err("an invalid operator output id must be a clean error, not a panic");
1523 let msg = err.to_string();
1524 assert!(msg.contains("invalid output id"), "unexpected error: {msg}");
1525 }
1526
1527 #[test]
1528 fn load_module_file_rejects_oversized_file() {
1529 let tmp = TempDir::new().unwrap();
1530 let mut content = String::from("module:\n name: big\n outputs: [x]\nnodes: []\n");
1534 content.push_str("# ");
1535 content.push_str(&"a".repeat((MAX_MODULE_FILE_SIZE + 16) as usize));
1536 let path = write_file(tmp.path(), "big_module.yml", &content);
1537
1538 let err = load_module_file(&path).unwrap_err().to_string();
1539 assert!(err.contains("too large"), "unexpected error: {err}");
1540 }
1541
1542 #[test]
1543 fn load_module_file_accepts_file_at_limit() {
1544 let tmp = TempDir::new().unwrap();
1545 let prefix = "module:\n name: ok\n outputs: [x]\nnodes: []\n# ";
1549 let pad = (MAX_MODULE_FILE_SIZE as usize) - prefix.len();
1550 let content = format!("{prefix}{}", "a".repeat(pad));
1551 assert_eq!(content.len() as u64, MAX_MODULE_FILE_SIZE);
1552 let path = write_file(tmp.path(), "ok_module.yml", &content);
1553
1554 assert!(load_module_file(&path).is_ok());
1555 }
1556
1557 #[test]
1558 fn expand_flat_passthrough() {
1559 let tmp = TempDir::new().unwrap();
1560 let base = tmp.path();
1561
1562 write_file(
1563 base,
1564 "echo_module.yml",
1565 r#"
1566module:
1567 name: echo
1568 inputs: [data_in]
1569 outputs: [data_out]
1570
1571nodes:
1572 - id: passthrough
1573 path: echo.py
1574 inputs:
1575 incoming: _mod/data_in
1576 outputs:
1577 - data_out
1578"#,
1579 );
1580
1581 let desc = parse_descriptor(
1582 r#"
1583nodes:
1584 - id: source
1585 path: source.py
1586 outputs:
1587 - number
1588
1589 - id: my_echo
1590 module: echo_module.yml
1591 inputs:
1592 data_in: source/number
1593
1594 - id: sink
1595 path: sink.py
1596 inputs:
1597 result: my_echo/data_out
1598"#,
1599 );
1600
1601 let expanded = expand_modules(&desc, base).unwrap();
1602
1603 assert_eq!(expanded.nodes.len(), 3);
1604 let names: Vec<_> = expanded.nodes.iter().map(|n| n.id.to_string()).collect();
1605 assert!(names.contains(&"my_echo.passthrough".to_string()));
1606 assert!(!names.contains(&"my_echo".to_string()));
1607
1608 let passthrough = expanded
1609 .nodes
1610 .iter()
1611 .find(|n| n.id.to_string() == "my_echo.passthrough")
1612 .unwrap();
1613 let incoming = &passthrough.inputs[&DataId::from("incoming".to_string())];
1614 match &incoming.mapping {
1615 InputMapping::User(m) => {
1616 assert_eq!(m.source.to_string(), "source");
1617 assert_eq!(m.output.to_string(), "number");
1618 }
1619 _ => panic!("expected user mapping"),
1620 }
1621
1622 let sink = expanded
1623 .nodes
1624 .iter()
1625 .find(|n| n.id.to_string() == "sink")
1626 .unwrap();
1627 let result = &sink.inputs[&DataId::from("result".to_string())];
1628 match &result.mapping {
1629 InputMapping::User(m) => {
1630 assert_eq!(m.source.to_string(), "my_echo.passthrough");
1631 assert_eq!(m.output.to_string(), "data_out");
1632 }
1633 _ => panic!("expected user mapping"),
1634 }
1635 }
1636
1637 #[test]
1638 fn expand_internal_cross_ref() {
1639 let tmp = TempDir::new().unwrap();
1640 let base = tmp.path();
1641
1642 write_file(
1643 base,
1644 "pipeline_module.yml",
1645 r#"
1646module:
1647 name: pipeline
1648 inputs: [data_in]
1649 outputs: [data_out]
1650
1651nodes:
1652 - id: stage_a
1653 path: a.py
1654 inputs:
1655 raw: _mod/data_in
1656 outputs:
1657 - intermediate
1658
1659 - id: stage_b
1660 path: b.py
1661 inputs:
1662 intermediate: stage_a/intermediate
1663 outputs:
1664 - data_out
1665"#,
1666 );
1667
1668 let desc = parse_descriptor(
1669 r#"
1670nodes:
1671 - id: src
1672 path: src.py
1673 outputs: [val]
1674 - id: pipe
1675 module: pipeline_module.yml
1676 inputs:
1677 data_in: src/val
1678 - id: dst
1679 path: dst.py
1680 inputs:
1681 result: pipe/data_out
1682"#,
1683 );
1684
1685 let expanded = expand_modules(&desc, base).unwrap();
1686
1687 let stage_b = expanded
1688 .nodes
1689 .iter()
1690 .find(|n| n.id.to_string() == "pipe.stage_b")
1691 .unwrap();
1692 let inter = &stage_b.inputs[&DataId::from("intermediate".to_string())];
1693 match &inter.mapping {
1694 InputMapping::User(m) => {
1695 assert_eq!(m.source.to_string(), "pipe.stage_a");
1696 assert_eq!(m.output.to_string(), "intermediate");
1697 }
1698 _ => panic!("expected user mapping"),
1699 }
1700 }
1701
1702 #[test]
1703 fn expand_depth_limit() {
1704 let tmp = TempDir::new().unwrap();
1705 let base = tmp.path();
1706
1707 for i in 0..=MAX_MODULE_DEPTH {
1708 let next = if i < MAX_MODULE_DEPTH {
1709 format!(
1710 " - id: inner\n module: level{}_module.yml\n inputs:\n x: _mod/x",
1711 i + 1
1712 )
1713 } else {
1714 " - id: inner\n path: leaf.py\n inputs:\n x: _mod/x\n outputs:\n - y".to_string()
1715 };
1716
1717 write_file(
1718 base,
1719 &format!("level{i}_module.yml"),
1720 &format!(
1721 r#"
1722module:
1723 name: level{i}
1724 inputs: [x]
1725 outputs: [y]
1726
1727nodes:
1728{next}
1729"#
1730 ),
1731 );
1732 }
1733
1734 let desc = parse_descriptor(
1735 r#"
1736nodes:
1737 - id: root
1738 module: level0_module.yml
1739 inputs:
1740 x: somewhere/val
1741"#,
1742 );
1743
1744 let result = expand_modules(&desc, base);
1745 assert!(result.is_err());
1746 assert!(
1747 result
1748 .unwrap_err()
1749 .to_string()
1750 .contains("nesting exceeds depth limit")
1751 );
1752 }
1753
1754 #[test]
1755 fn expand_circular_reference() {
1756 let tmp = TempDir::new().unwrap();
1757 let base = tmp.path();
1758
1759 write_file(
1760 base,
1761 "self_module.yml",
1762 r#"
1763module:
1764 name: self_ref
1765 inputs: [x]
1766 outputs: [y]
1767
1768nodes:
1769 - id: recurse
1770 module: self_module.yml
1771 inputs:
1772 x: _mod/x
1773"#,
1774 );
1775
1776 let desc = parse_descriptor(
1777 r#"
1778nodes:
1779 - id: top
1780 module: self_module.yml
1781 inputs:
1782 x: somewhere/val
1783"#,
1784 );
1785
1786 let result = expand_modules(&desc, base);
1787 assert!(result.is_err());
1788 let err_msg = result.unwrap_err().to_string();
1789 assert!(err_msg.contains("circular module reference"));
1790 assert!(err_msg.contains("hint"));
1792 }
1793
1794 #[test]
1795 fn expand_missing_module_file() {
1796 let tmp = TempDir::new().unwrap();
1797 let base = tmp.path();
1798
1799 let desc = parse_descriptor(
1800 r#"
1801nodes:
1802 - id: broken
1803 module: nonexistent_module.yml
1804 inputs:
1805 x: somewhere/val
1806"#,
1807 );
1808
1809 let result = expand_modules(&desc, base);
1810 assert!(result.is_err());
1811 assert!(
1812 result
1813 .unwrap_err()
1814 .to_string()
1815 .contains("nonexistent_module.yml")
1816 );
1817 }
1818
1819 #[test]
1820 fn expand_undefined_input_port() {
1821 let tmp = TempDir::new().unwrap();
1822 let base = tmp.path();
1823
1824 write_file(
1825 base,
1826 "needs_input_module.yml",
1827 r#"
1828module:
1829 name: needs_input
1830 inputs: [required_port]
1831 outputs: [out]
1832
1833nodes:
1834 - id: inner
1835 path: inner.py
1836 inputs:
1837 x: _mod/required_port
1838 outputs:
1839 - out
1840"#,
1841 );
1842
1843 let desc = parse_descriptor(
1844 r#"
1845nodes:
1846 - id: mod_node
1847 module: needs_input_module.yml
1848 inputs:
1849 wrong_name: somewhere/val
1850"#,
1851 );
1852
1853 let result = expand_modules(&desc, base);
1854 assert!(result.is_err());
1855 let err_msg = result.unwrap_err().to_string();
1856 assert!(err_msg.contains("required_port"));
1857 assert!(err_msg.contains("hint"));
1859 }
1860
1861 #[test]
1862 fn expand_rejects_duplicate_module_header_inputs() {
1863 let tmp = TempDir::new().unwrap();
1864 let base = tmp.path();
1865
1866 write_file(
1867 base,
1868 "dup_header_module.yml",
1869 r#"
1870module:
1871 name: dup_header
1872 inputs: [data, data]
1873 outputs: [out]
1874
1875nodes:
1876 - id: inner
1877 path: inner.py
1878 inputs:
1879 x: _mod/data
1880 outputs:
1881 - out
1882"#,
1883 );
1884
1885 let desc = parse_descriptor(
1886 r#"
1887nodes:
1888 - id: src
1889 path: src.py
1890 outputs: [val]
1891 - id: m
1892 module: dup_header_module.yml
1893 inputs:
1894 data: src/val
1895"#,
1896 );
1897
1898 let result = expand_modules(&desc, base);
1899 assert!(result.is_err());
1900 let msg = result.unwrap_err().to_string();
1901 assert!(msg.contains("duplicate"), "got: {msg}");
1902 assert!(msg.contains("inputs"), "got: {msg}");
1903 assert!(msg.contains("data"), "got: {msg}");
1904 }
1905
1906 #[test]
1907 fn expand_undefined_output_port() {
1908 let tmp = TempDir::new().unwrap();
1909 let base = tmp.path();
1910
1911 write_file(
1912 base,
1913 "bad_output_module.yml",
1914 r#"
1915module:
1916 name: bad_output
1917 inputs: []
1918 outputs: [nonexistent]
1919
1920nodes:
1921 - id: inner
1922 path: inner.py
1923 outputs:
1924 - something_else
1925"#,
1926 );
1927
1928 let desc = parse_descriptor(
1929 r#"
1930nodes:
1931 - id: mod_node
1932 module: bad_output_module.yml
1933"#,
1934 );
1935
1936 let result = expand_modules(&desc, base);
1937 assert!(result.is_err());
1938 assert!(result.unwrap_err().to_string().contains("nonexistent"));
1939 }
1940
1941 #[test]
1942 fn expand_no_modules_passthrough() {
1943 let desc = parse_descriptor(
1944 r#"
1945nodes:
1946 - id: a
1947 path: a.py
1948 outputs: [x]
1949 - id: b
1950 path: b.py
1951 inputs:
1952 x: a/x
1953"#,
1954 );
1955
1956 let tmp = TempDir::new().unwrap();
1957 let expanded = expand_modules(&desc, tmp.path()).unwrap();
1958 assert_eq!(expanded.nodes.len(), 2);
1959 assert_eq!(expanded.nodes[0].id.to_string(), "a");
1960 assert_eq!(expanded.nodes[1].id.to_string(), "b");
1961 }
1962
1963 #[test]
1964 fn expand_multiple_instances() {
1965 let tmp = TempDir::new().unwrap();
1966 let base = tmp.path();
1967
1968 write_file(
1969 base,
1970 "filter_module.yml",
1971 r#"
1972module:
1973 name: filter
1974 inputs: [raw]
1975 outputs: [filtered]
1976
1977nodes:
1978 - id: proc
1979 path: filter.py
1980 inputs:
1981 data: _mod/raw
1982 outputs:
1983 - filtered
1984"#,
1985 );
1986
1987 let desc = parse_descriptor(
1988 r#"
1989nodes:
1990 - id: cam1
1991 path: cam.py
1992 outputs: [frame]
1993 - id: cam2
1994 path: cam.py
1995 outputs: [frame]
1996 - id: filter1
1997 module: filter_module.yml
1998 inputs:
1999 raw: cam1/frame
2000 - id: filter2
2001 module: filter_module.yml
2002 inputs:
2003 raw: cam2/frame
2004 - id: merger
2005 path: merge.py
2006 inputs:
2007 a: filter1/filtered
2008 b: filter2/filtered
2009"#,
2010 );
2011
2012 let expanded = expand_modules(&desc, base).unwrap();
2013
2014 let names: Vec<_> = expanded.nodes.iter().map(|n| n.id.to_string()).collect();
2015 assert!(names.contains(&"filter1.proc".to_string()));
2016 assert!(names.contains(&"filter2.proc".to_string()));
2017 assert_eq!(expanded.nodes.len(), 5);
2018
2019 let merger = expanded
2020 .nodes
2021 .iter()
2022 .find(|n| n.id.to_string() == "merger")
2023 .unwrap();
2024 let a_input = &merger.inputs[&DataId::from("a".to_string())];
2025 match &a_input.mapping {
2026 InputMapping::User(m) => assert_eq!(m.source.to_string(), "filter1.proc"),
2027 _ => panic!("expected user mapping"),
2028 }
2029 let b_input = &merger.inputs[&DataId::from("b".to_string())];
2030 match &b_input.mapping {
2031 InputMapping::User(m) => assert_eq!(m.source.to_string(), "filter2.proc"),
2032 _ => panic!("expected user mapping"),
2033 }
2034 }
2035
2036 #[test]
2037 fn expand_nested_modules() {
2038 let tmp = TempDir::new().unwrap();
2039 let base = tmp.path();
2040
2041 write_file(
2042 base,
2043 "inner_module.yml",
2044 r#"
2045module:
2046 name: inner
2047 inputs: [x]
2048 outputs: [y]
2049
2050nodes:
2051 - id: leaf
2052 path: leaf.py
2053 inputs:
2054 x: _mod/x
2055 outputs:
2056 - y
2057"#,
2058 );
2059
2060 write_file(
2061 base,
2062 "outer_module.yml",
2063 r#"
2064module:
2065 name: outer
2066 inputs: [a]
2067 outputs: [y]
2068
2069nodes:
2070 - id: nested
2071 module: inner_module.yml
2072 inputs:
2073 x: _mod/a
2074"#,
2075 );
2076
2077 let desc = parse_descriptor(
2078 r#"
2079nodes:
2080 - id: src
2081 path: src.py
2082 outputs: [val]
2083 - id: wrapper
2084 module: outer_module.yml
2085 inputs:
2086 a: src/val
2087"#,
2088 );
2089
2090 let expanded = expand_modules(&desc, base).unwrap();
2091 let names: Vec<_> = expanded.nodes.iter().map(|n| n.id.to_string()).collect();
2092 assert!(names.contains(&"wrapper.nested.leaf".to_string()));
2093 }
2094
2095 #[test]
2098 fn expand_optional_input_provided() {
2099 let tmp = TempDir::new().unwrap();
2100 let base = tmp.path();
2101
2102 write_file(
2103 base,
2104 "opt_module.yml",
2105 r#"
2106module:
2107 name: opt
2108 inputs: [required]
2109 inputs_optional: [config]
2110 outputs: [out]
2111
2112nodes:
2113 - id: worker
2114 path: worker.py
2115 inputs:
2116 data: _mod/required
2117 cfg: _mod/config
2118 outputs:
2119 - out
2120"#,
2121 );
2122
2123 let desc = parse_descriptor(
2125 r#"
2126nodes:
2127 - id: src
2128 path: src.py
2129 outputs: [data, cfg]
2130 - id: m
2131 module: opt_module.yml
2132 inputs:
2133 required: src/data
2134 config: src/cfg
2135"#,
2136 );
2137
2138 let expanded = expand_modules(&desc, base).unwrap();
2139 let worker = expanded
2140 .nodes
2141 .iter()
2142 .find(|n| n.id.to_string() == "m.worker")
2143 .unwrap();
2144 assert_eq!(worker.inputs.len(), 2);
2146 }
2147
2148 #[test]
2149 fn expand_optional_input_omitted() {
2150 let tmp = TempDir::new().unwrap();
2151 let base = tmp.path();
2152
2153 write_file(
2154 base,
2155 "opt_module.yml",
2156 r#"
2157module:
2158 name: opt
2159 inputs: [required]
2160 inputs_optional: [config]
2161 outputs: [out]
2162
2163nodes:
2164 - id: worker
2165 path: worker.py
2166 inputs:
2167 data: _mod/required
2168 cfg: _mod/config
2169 outputs:
2170 - out
2171"#,
2172 );
2173
2174 let desc = parse_descriptor(
2176 r#"
2177nodes:
2178 - id: src
2179 path: src.py
2180 outputs: [data]
2181 - id: m
2182 module: opt_module.yml
2183 inputs:
2184 required: src/data
2185"#,
2186 );
2187
2188 let expanded = expand_modules(&desc, base).unwrap();
2189 let worker = expanded
2190 .nodes
2191 .iter()
2192 .find(|n| n.id.to_string() == "m.worker")
2193 .unwrap();
2194 assert_eq!(worker.inputs.len(), 1);
2196 assert!(
2197 worker
2198 .inputs
2199 .contains_key(&DataId::from("data".to_string()))
2200 );
2201 }
2202
2203 #[test]
2204 fn expand_rejects_module_input_not_declared_required_or_optional() {
2205 let tmp = TempDir::new().unwrap();
2206 let base = tmp.path();
2207
2208 write_file(
2209 base,
2210 "strict_inputs_module.yml",
2211 r#"
2212module:
2213 name: strict_inputs
2214 inputs: [required]
2215 inputs_optional: [config]
2216 outputs: [out]
2217
2218nodes:
2219 - id: worker
2220 path: worker.py
2221 inputs:
2222 data: _mod/required
2223 outputs:
2224 - out
2225"#,
2226 );
2227
2228 let desc = parse_descriptor(
2229 r#"
2230nodes:
2231 - id: src
2232 path: src.py
2233 outputs: [data, extra]
2234 - id: m
2235 module: strict_inputs_module.yml
2236 inputs:
2237 required: src/data
2238 typo: src/extra
2239"#,
2240 );
2241
2242 let result = expand_modules(&desc, base);
2243 assert!(result.is_err());
2244 let msg = result.unwrap_err().to_string();
2245 assert!(msg.contains("does not declare input"), "got: {msg}");
2246 assert!(msg.contains("typo"), "got: {msg}");
2247 assert!(msg.contains("required"), "got: {msg}");
2248 assert!(msg.contains("config"), "got: {msg}");
2249 }
2250
2251 #[test]
2254 fn expand_params_in_env() {
2255 let tmp = TempDir::new().unwrap();
2256 let base = tmp.path();
2257
2258 write_file(
2259 base,
2260 "param_module.yml",
2261 r#"
2262module:
2263 name: parameterized
2264 inputs: [data]
2265 outputs: [out]
2266
2267nodes:
2268 - id: proc
2269 path: proc.py
2270 inputs:
2271 data: _mod/data
2272 outputs:
2273 - out
2274"#,
2275 );
2276
2277 let desc = parse_descriptor(
2278 r#"
2279nodes:
2280 - id: src
2281 path: src.py
2282 outputs: [val]
2283 - id: m
2284 module: param_module.yml
2285 inputs:
2286 data: src/val
2287 params:
2288 speed: "1.5"
2289 mode: turbo
2290"#,
2291 );
2292
2293 let expanded = expand_modules(&desc, base).unwrap();
2294 let proc = expanded
2295 .nodes
2296 .iter()
2297 .find(|n| n.id.to_string() == "m.proc")
2298 .unwrap();
2299 let env = proc.env.as_ref().unwrap();
2300 assert_eq!(env["PARAM_SPEED"], EnvValue::String("1.5".to_string()));
2302 assert_eq!(env["PARAM_MODE"], EnvValue::String("turbo".to_string()));
2303 }
2304
2305 #[test]
2306 fn expand_params_in_args() {
2307 let tmp = TempDir::new().unwrap();
2308 let base = tmp.path();
2309
2310 write_file(
2311 base,
2312 "args_module.yml",
2313 r#"
2314module:
2315 name: with_args
2316 inputs: [data]
2317 outputs: [out]
2318
2319nodes:
2320 - id: proc
2321 path: proc.py
2322 inputs:
2323 data: _mod/data
2324 outputs:
2325 - out
2326 args: --speed ${_param.speed} --verbose
2327"#,
2328 );
2329
2330 let desc = parse_descriptor(
2331 r#"
2332nodes:
2333 - id: src
2334 path: src.py
2335 outputs: [val]
2336 - id: m
2337 module: args_module.yml
2338 inputs:
2339 data: src/val
2340 params:
2341 speed: "2.0"
2342"#,
2343 );
2344
2345 let expanded = expand_modules(&desc, base).unwrap();
2346 let proc = expanded
2347 .nodes
2348 .iter()
2349 .find(|n| n.id.to_string() == "m.proc")
2350 .unwrap();
2351 assert_eq!(proc.args.as_deref(), Some("--speed 2.0 --verbose"));
2352 }
2353
2354 #[test]
2355 fn expand_params_in_documented_env_style_args() {
2356 let tmp = TempDir::new().unwrap();
2357 let base = tmp.path();
2358
2359 write_file(
2360 base,
2361 "args_module.yml",
2362 r#"
2363module:
2364 name: with_args
2365 inputs: [data]
2366 outputs: [out]
2367
2368nodes:
2369 - id: proc
2370 path: proc.py
2371 inputs:
2372 data: _mod/data
2373 outputs:
2374 - out
2375 args: --speed $PARAM_SPEED --mode $PARAM_MODE
2376"#,
2377 );
2378
2379 let desc = parse_descriptor(
2380 r#"
2381nodes:
2382 - id: src
2383 path: src.py
2384 outputs: [val]
2385 - id: m
2386 module: args_module.yml
2387 inputs:
2388 data: src/val
2389 params:
2390 speed: "2.0"
2391 mode: turbo
2392"#,
2393 );
2394
2395 let expanded = expand_modules(&desc, base).unwrap();
2396 let proc = expanded
2397 .nodes
2398 .iter()
2399 .find(|n| n.id.to_string() == "m.proc")
2400 .unwrap();
2401 assert_eq!(proc.args.as_deref(), Some("--speed 2.0 --mode turbo"));
2402 }
2403
2404 #[test]
2405 fn expand_params_in_env_style_args_distinguishes_overlapping_keys() {
2406 let tmp = TempDir::new().unwrap();
2411 let base = tmp.path();
2412
2413 write_file(
2414 base,
2415 "args_module.yml",
2416 r#"
2417module:
2418 name: with_args
2419 inputs: [data]
2420 outputs: [out]
2421
2422nodes:
2423 - id: proc
2424 path: proc.py
2425 inputs:
2426 data: _mod/data
2427 outputs:
2428 - out
2429 args: --short $PARAM_SPEED --long $PARAM_SPEED_LIMIT
2430"#,
2431 );
2432
2433 let desc = parse_descriptor(
2434 r#"
2435nodes:
2436 - id: src
2437 path: src.py
2438 outputs: [val]
2439 - id: m
2440 module: args_module.yml
2441 inputs:
2442 data: src/val
2443 params:
2444 speed: "2.0"
2445 speed_limit: "4.5"
2446"#,
2447 );
2448
2449 let expanded = expand_modules(&desc, base).unwrap();
2450 let proc = expanded
2451 .nodes
2452 .iter()
2453 .find(|n| n.id.to_string() == "m.proc")
2454 .unwrap();
2455 assert_eq!(proc.args.as_deref(), Some("--short 2.0 --long 4.5"));
2456 }
2457
2458 #[test]
2459 fn substitute_params_basic_and_unknown() {
2460 let params = BTreeMap::from([
2461 ("speed".to_string(), "2.0".to_string()),
2462 ("name".to_string(), "robot".to_string()),
2463 ]);
2464 assert_eq!(
2465 substitute_params_in_str("--speed ${_param.speed} --name ${_param.name}", ¶ms),
2466 "--speed 2.0 --name robot"
2467 );
2468 assert_eq!(
2470 substitute_params_in_str("${_param.speed}/${_param.speed}", ¶ms),
2471 "2.0/2.0"
2472 );
2473 assert_eq!(
2475 substitute_params_in_str("${_param.missing}", ¶ms),
2476 "${_param.missing}"
2477 );
2478 assert_eq!(
2480 substitute_params_in_str("prefix ${_param.speed", ¶ms),
2481 "prefix ${_param.speed"
2482 );
2483 }
2484
2485 #[test]
2486 fn substitute_params_env_style_requires_an_identifier_boundary() {
2487 let params = BTreeMap::from([("speed".to_string(), "2.0".to_string())]);
2491 assert_eq!(
2492 substitute_params_in_str("--flag $PARAM_SPEED_LIMIT", ¶ms),
2493 "--flag $PARAM_SPEED_LIMIT"
2494 );
2495 assert_eq!(
2498 substitute_params_in_str("--flag $PARAM_SPEED --x", ¶ms),
2499 "--flag 2.0 --x"
2500 );
2501 assert_eq!(
2502 substitute_params_in_str("$PARAM_SPEED,$PARAM_SPEED", ¶ms),
2503 "2.0,2.0"
2504 );
2505 assert_eq!(
2507 substitute_params_in_str("cost $5 $PARAM_MISSING", ¶ms),
2508 "cost $5 $PARAM_MISSING"
2509 );
2510 }
2511
2512 #[test]
2513 fn substitute_params_env_style_is_not_re_expanded() {
2514 let params = BTreeMap::from([
2517 ("a".to_string(), "$PARAM_B".to_string()),
2518 ("b".to_string(), "x".to_string()),
2519 ]);
2520 assert_eq!(substitute_params_in_str("$PARAM_A", ¶ms), "$PARAM_B");
2521 }
2522
2523 #[test]
2524 fn substitute_params_is_order_independent_and_non_transitive() {
2525 let params = BTreeMap::from([
2530 ("a".to_string(), "${_param.b}".to_string()),
2531 ("b".to_string(), "x".to_string()),
2532 ]);
2533 assert_eq!(
2534 substitute_params_in_str("${_param.a}", ¶ms),
2535 "${_param.b}"
2536 );
2537 assert_eq!(substitute_params_in_str("${_param.b}", ¶ms), "x");
2538 }
2539
2540 #[test]
2541 fn expand_module_node_env_propagates_to_inner_nodes() {
2542 let tmp = TempDir::new().unwrap();
2543 let base = tmp.path();
2544
2545 write_file(
2546 base,
2547 "env_module.yml",
2548 r#"
2549module:
2550 name: env_module
2551 inputs: [data]
2552 outputs: [out]
2553
2554nodes:
2555 - id: worker
2556 path: worker.py
2557 env:
2558 INNER_ONLY: from-inner
2559 SHARED: inner
2560 inputs:
2561 data: _mod/data
2562 outputs:
2563 - out
2564"#,
2565 );
2566
2567 let desc = parse_descriptor(
2568 r#"
2569nodes:
2570 - id: src
2571 path: src.py
2572 outputs: [val]
2573 - id: m
2574 module: env_module.yml
2575 env:
2576 WRAPPER_ONLY: from-wrapper
2577 SHARED: wrapper
2578 inputs:
2579 data: src/val
2580"#,
2581 );
2582
2583 let expanded = expand_modules(&desc, base).unwrap();
2584 let worker = expanded
2585 .nodes
2586 .iter()
2587 .find(|n| n.id.to_string() == "m.worker")
2588 .unwrap();
2589 let env = worker.env.as_ref().unwrap();
2590 assert_eq!(
2591 env["INNER_ONLY"],
2592 EnvValue::String("from-inner".to_string())
2593 );
2594 assert_eq!(
2595 env["WRAPPER_ONLY"],
2596 EnvValue::String("from-wrapper".to_string())
2597 );
2598 assert_eq!(env["SHARED"], EnvValue::String("inner".to_string()));
2601 }
2602
2603 #[test]
2604 fn expand_module_node_with_empty_env_leaves_inner_env_unset() {
2605 let tmp = TempDir::new().unwrap();
2606 let base = tmp.path();
2607
2608 write_file(
2609 base,
2610 "empty_env_module.yml",
2611 r#"
2612module:
2613 name: empty_env_module
2614 inputs: [data]
2615 outputs: [out]
2616
2617nodes:
2618 - id: worker
2619 path: worker.py
2620 inputs:
2621 data: _mod/data
2622 outputs:
2623 - out
2624"#,
2625 );
2626
2627 let desc = parse_descriptor(
2628 r#"
2629nodes:
2630 - id: src
2631 path: src.py
2632 outputs: [val]
2633 - id: m
2634 module: empty_env_module.yml
2635 env: {}
2636 inputs:
2637 data: src/val
2638"#,
2639 );
2640
2641 let expanded = expand_modules(&desc, base).unwrap();
2642 let worker = expanded
2643 .nodes
2644 .iter()
2645 .find(|n| n.id.to_string() == "m.worker")
2646 .unwrap();
2647 assert_eq!(worker.env, None);
2648 }
2649
2650 #[test]
2651 fn expand_outer_params_reach_nested_module_inner_nodes_as_env() {
2652 let tmp = TempDir::new().unwrap();
2653 let base = tmp.path();
2654
2655 write_file(
2656 base,
2657 "inner.yml",
2658 r#"
2659module:
2660 name: inner
2661 inputs: [data]
2662 outputs: [out]
2663
2664nodes:
2665 - id: worker
2666 path: worker.py
2667 inputs:
2668 data: _mod/data
2669 outputs:
2670 - out
2671"#,
2672 );
2673
2674 write_file(
2675 base,
2676 "outer.yml",
2677 r#"
2678module:
2679 name: outer
2680 inputs: [data]
2681 outputs: [out]
2682
2683nodes:
2684 - id: inner
2685 module: inner.yml
2686 inputs:
2687 data: _mod/data
2688"#,
2689 );
2690
2691 let desc = parse_descriptor(
2692 r#"
2693nodes:
2694 - id: src
2695 path: src.py
2696 outputs: [val]
2697 - id: outer
2698 module: outer.yml
2699 inputs:
2700 data: src/val
2701 params:
2702 speed: "2.0"
2703"#,
2704 );
2705
2706 let expanded = expand_modules(&desc, base).unwrap();
2707 let worker = expanded
2708 .nodes
2709 .iter()
2710 .find(|n| n.id.to_string() == "outer.inner.worker")
2711 .unwrap();
2712 let env = worker.env.as_ref().unwrap();
2713 assert_eq!(env["PARAM_SPEED"], EnvValue::String("2.0".to_string()));
2714 }
2715
2716 #[test]
2722 fn expand_top_level_module_build_propagated() {
2723 let tmp = TempDir::new().unwrap();
2724 let base = tmp.path();
2725
2726 write_file(
2727 base,
2728 "leaf_module.yml",
2729 r#"
2730module:
2731 name: leaf
2732 inputs: [data]
2733 outputs: [out]
2734
2735nodes:
2736 - id: proc
2737 path: proc.py
2738 inputs:
2739 data: _mod/data
2740 outputs:
2741 - out
2742 build: python setup.py build
2743"#,
2744 );
2745
2746 let desc = parse_descriptor(
2747 r#"
2748nodes:
2749 - id: src
2750 path: src.py
2751 outputs: [val]
2752 - id: m
2753 module: leaf_module.yml
2754 build: pip install foo
2755 inputs:
2756 data: src/val
2757"#,
2758 );
2759
2760 let expanded = expand_modules(&desc, base).unwrap();
2761 let proc = expanded
2762 .nodes
2763 .iter()
2764 .find(|n| n.id.to_string() == "m.proc")
2765 .unwrap();
2766 let build = proc.build.as_deref().unwrap();
2767 assert!(
2768 build.starts_with("pip install foo"),
2769 "the module node's own build must be prepended; got: {build}"
2770 );
2771 assert!(build.contains("python setup.py build"), "{build}");
2772 }
2773
2774 #[test]
2778 fn nested_module_node_rejects_disallowed_field() {
2779 let tmp = TempDir::new().unwrap();
2780 let base = tmp.path();
2781
2782 write_file(
2783 base,
2784 "leaf_module.yml",
2785 r#"
2786module:
2787 name: leaf
2788 inputs: [x]
2789 outputs: [y]
2790
2791nodes:
2792 - id: worker
2793 path: worker.py
2794 inputs:
2795 x: _mod/x
2796 outputs:
2797 - y
2798"#,
2799 );
2800
2801 write_file(
2804 base,
2805 "outer_module.yml",
2806 r#"
2807module:
2808 name: outer
2809 inputs: [x]
2810 outputs: [y]
2811
2812nodes:
2813 - id: inner
2814 module: leaf_module.yml
2815 inputs:
2816 x: _mod/x
2817 outputs:
2818 - y
2819"#,
2820 );
2821
2822 let desc = parse_descriptor(
2823 r#"
2824nodes:
2825 - id: src
2826 path: src.py
2827 outputs: [val]
2828 - id: top
2829 module: outer_module.yml
2830 inputs:
2831 x: src/val
2832"#,
2833 );
2834
2835 let error = format!("{:#}", expand_modules(&desc, base).unwrap_err());
2836 assert!(
2837 error.contains("outputs") && error.contains("Module"),
2838 "nested module node with `outputs` should be rejected; got: {error}"
2839 );
2840 }
2841
2842 #[test]
2843 fn expand_module_build_prepended() {
2844 let tmp = TempDir::new().unwrap();
2845 let base = tmp.path();
2846
2847 write_file(
2848 base,
2849 "build_module.yml",
2850 r#"
2851module:
2852 name: buildable
2853 inputs: [data]
2854 outputs: [out]
2855
2856build: pip install -r requirements.txt
2857
2858nodes:
2859 - id: proc
2860 path: proc.py
2861 inputs:
2862 data: _mod/data
2863 outputs:
2864 - out
2865 build: python setup.py build
2866"#,
2867 );
2868
2869 let desc = parse_descriptor(
2870 r#"
2871nodes:
2872 - id: src
2873 path: src.py
2874 outputs: [val]
2875 - id: m
2876 module: build_module.yml
2877 inputs:
2878 data: src/val
2879"#,
2880 );
2881
2882 let expanded = expand_modules(&desc, base).unwrap();
2883 let proc = expanded
2884 .nodes
2885 .iter()
2886 .find(|n| n.id.to_string() == "m.proc")
2887 .unwrap();
2888 let build = proc.build.as_deref().unwrap();
2889 assert!(build.starts_with("pip install -r requirements.txt"));
2890 assert!(build.contains("python setup.py build"));
2891 }
2892
2893 #[test]
2894 fn expand_module_build_no_inner_build() {
2895 let tmp = TempDir::new().unwrap();
2896 let base = tmp.path();
2897
2898 write_file(
2899 base,
2900 "build_only_module.yml",
2901 r#"
2902module:
2903 name: build_only
2904 inputs: [data]
2905 outputs: [out]
2906
2907build: make all
2908
2909nodes:
2910 - id: proc
2911 path: proc.py
2912 inputs:
2913 data: _mod/data
2914 outputs:
2915 - out
2916"#,
2917 );
2918
2919 let desc = parse_descriptor(
2920 r#"
2921nodes:
2922 - id: src
2923 path: src.py
2924 outputs: [val]
2925 - id: m
2926 module: build_only_module.yml
2927 inputs:
2928 data: src/val
2929"#,
2930 );
2931
2932 let expanded = expand_modules(&desc, base).unwrap();
2933 let proc = expanded
2934 .nodes
2935 .iter()
2936 .find(|n| n.id.to_string() == "m.proc")
2937 .unwrap();
2938 assert_eq!(proc.build.as_deref(), Some("make all"));
2939 }
2940
2941 #[test]
2947 fn expand_module_build_prepended_to_git_and_hub_inner_nodes() {
2948 let tmp = TempDir::new().unwrap();
2949 let base = tmp.path();
2950
2951 write_file(
2952 base,
2953 "git_hub_module.yml",
2954 r#"
2955module:
2956 name: git_hub
2957 outputs: [from_git, from_hub]
2958
2959build: pip install -r requirements.txt
2960
2961nodes:
2962 - id: worker
2963 git: https://github.com/example/worker.git
2964 outputs:
2965 - from_git
2966 build: cargo build --release
2967 - id: fetched
2968 hub: example/fetched
2969 outputs:
2970 - from_hub
2971"#,
2972 );
2973
2974 let desc = parse_descriptor(
2975 r#"
2976nodes:
2977 - id: m
2978 module: git_hub_module.yml
2979"#,
2980 );
2981
2982 let expanded = expand_modules(&desc, base).unwrap();
2983
2984 let worker = expanded
2986 .nodes
2987 .iter()
2988 .find(|n| n.id.to_string() == "m.worker")
2989 .unwrap();
2990 assert_eq!(
2991 worker.build.as_deref(),
2992 Some("pip install -r requirements.txt\ncargo build --release"),
2993 );
2994
2995 let fetched = expanded
2997 .nodes
2998 .iter()
2999 .find(|n| n.id.to_string() == "m.fetched")
3000 .unwrap();
3001 assert_eq!(
3002 fetched.build.as_deref(),
3003 Some("pip install -r requirements.txt"),
3004 );
3005 }
3006
3007 #[test]
3012 fn module_build_keeps_operator_inner_nodes_resolvable() {
3013 let tmp = TempDir::new().unwrap();
3014 let base = tmp.path();
3015
3016 write_file(
3017 base,
3018 "kinds_module.yml",
3019 r#"
3020build: pip install shared
3021
3022module:
3023 name: kinds
3024 inputs: [data]
3025 outputs: [from_runtime, from_operator]
3026
3027nodes:
3028 - id: runtime
3029 operators:
3030 - id: proc
3031 shared-library: proc
3032 inputs:
3033 data: _mod/data
3034 outputs:
3035 - from_runtime
3036
3037 - id: single
3038 operator:
3039 python: single.py
3040 inputs:
3041 data: _mod/data
3042 outputs:
3043 - from_operator
3044"#,
3045 );
3046
3047 let desc = parse_descriptor(
3048 r#"
3049nodes:
3050 - id: src
3051 path: src.py
3052 outputs: [val]
3053 - id: m
3054 module: kinds_module.yml
3055 inputs:
3056 data: src/val
3057"#,
3058 );
3059
3060 let expanded = expand_modules(&desc, base).unwrap();
3061 let runtime = expanded
3064 .nodes
3065 .iter()
3066 .find(|n| n.id.to_string() == "m.runtime")
3067 .unwrap();
3068 assert_eq!(
3069 runtime.operators.as_ref().unwrap().operators[0]
3070 .config
3071 .build
3072 .as_deref(),
3073 Some("pip install shared"),
3074 );
3075 assert_eq!(runtime.build, None);
3078
3079 crate::descriptor::DescriptorExt::resolve_aliases_and_set_defaults(&expanded)
3080 .expect("a module-level build must not make operator nodes unresolvable");
3081 }
3082
3083 #[test]
3084 fn expand_module_build_prepended_to_operator_and_custom_builds() {
3085 let tmp = TempDir::new().unwrap();
3086 let base = tmp.path();
3087
3088 write_file(
3089 base,
3090 "inner_kind_build_module.yml",
3091 r#"
3092module:
3093 name: inner_kind_builds
3094 inputs: [data]
3095 outputs: [from_runtime, from_operator]
3096
3097build: pip install shared
3098
3099nodes:
3100 - id: runtime
3101 operators:
3102 - id: proc
3103 shared-library: libproc.so
3104 build: make proc
3105 inputs:
3106 data: _mod/data
3107 outputs:
3108 - from_runtime
3109
3110 - id: single
3111 operator:
3112 python: single.py
3113 build: pip install single
3114 inputs:
3115 data: _mod/data
3116 outputs:
3117 - from_operator
3118"#,
3119 );
3120
3121 let desc = parse_descriptor(
3122 r#"
3123nodes:
3124 - id: src
3125 path: src.py
3126 outputs: [val]
3127 - id: m
3128 module: inner_kind_build_module.yml
3129 inputs:
3130 data: src/val
3131"#,
3132 );
3133
3134 let expanded = expand_modules(&desc, base).unwrap();
3135
3136 let runtime = expanded
3137 .nodes
3138 .iter()
3139 .find(|n| n.id.to_string() == "m.runtime")
3140 .unwrap();
3141 let runtime_build = runtime.operators.as_ref().unwrap().operators[0]
3142 .config
3143 .build
3144 .as_deref()
3145 .unwrap();
3146 assert_eq!(runtime_build, "pip install shared\nmake proc");
3147
3148 let single = expanded
3149 .nodes
3150 .iter()
3151 .find(|n| n.id.to_string() == "m.single")
3152 .unwrap();
3153 let single_build = single.operator.as_ref().unwrap().config.build.as_deref();
3154 assert_eq!(single_build, Some("pip install shared\npip install single"));
3155 }
3156
3157 #[test]
3160 fn expand_returns_boundaries() {
3161 let tmp = TempDir::new().unwrap();
3162 let base = tmp.path();
3163
3164 write_file(
3165 base,
3166 "simple_module.yml",
3167 r#"
3168module:
3169 name: simple
3170 inputs: [x]
3171 outputs: [y]
3172
3173nodes:
3174 - id: a
3175 path: a.py
3176 inputs:
3177 x: _mod/x
3178 outputs: [mid]
3179 - id: b
3180 path: b.py
3181 inputs:
3182 mid: a/mid
3183 outputs: [y]
3184"#,
3185 );
3186
3187 let desc = parse_descriptor(
3188 r#"
3189nodes:
3190 - id: src
3191 path: src.py
3192 outputs: [val]
3193 - id: mod1
3194 module: simple_module.yml
3195 inputs:
3196 x: src/val
3197"#,
3198 );
3199
3200 let result = expand_modules_with_boundaries(&desc, base).unwrap();
3201 assert!(result.boundaries.modules.contains_key("mod1"));
3202 let members = &result.boundaries.modules["mod1"];
3203 assert!(members.contains(&"mod1.a".to_string()));
3204 assert!(members.contains(&"mod1.b".to_string()));
3205 }
3206
3207 #[test]
3210 fn check_module_file_valid() {
3211 let tmp = TempDir::new().unwrap();
3212 let path = write_file(
3213 tmp.path(),
3214 "valid_module.yml",
3215 r#"
3216module:
3217 name: valid
3218 inputs: [x]
3219 outputs: [y]
3220
3221nodes:
3222 - id: proc
3223 path: proc.py
3224 inputs:
3225 x: _mod/x
3226 outputs:
3227 - y
3228"#,
3229 );
3230
3231 check_module_file(&path).unwrap();
3232 }
3233
3234 #[test]
3235 fn check_module_file_bad_mod_ref() {
3236 let tmp = TempDir::new().unwrap();
3237 let path = write_file(
3238 tmp.path(),
3239 "bad_ref_module.yml",
3240 r#"
3241module:
3242 name: bad_ref
3243 inputs: [x]
3244 outputs: [y]
3245
3246nodes:
3247 - id: proc
3248 path: proc.py
3249 inputs:
3250 x: _mod/nonexistent
3251 outputs:
3252 - y
3253"#,
3254 );
3255
3256 let result = check_module_file(&path);
3257 assert!(result.is_err());
3258 assert!(result.unwrap_err().to_string().contains("nonexistent"));
3259 }
3260
3261 #[test]
3262 fn check_module_file_rejects_duplicate_header_ports() {
3263 let tmp = TempDir::new().unwrap();
3264
3265 let duplicate_inputs = write_file(
3266 tmp.path(),
3267 "duplicate_inputs.yml",
3268 r#"
3269module:
3270 name: duplicate_inputs
3271 inputs: [data, data]
3272 outputs: [out]
3273
3274nodes:
3275 - id: worker
3276 path: worker.py
3277 inputs:
3278 x: _mod/data
3279 outputs:
3280 - out
3281"#,
3282 );
3283 let msg = check_module_file(&duplicate_inputs)
3284 .unwrap_err()
3285 .to_string();
3286 assert!(msg.contains("duplicate"), "got: {msg}");
3287 assert!(msg.contains("inputs"), "got: {msg}");
3288 assert!(msg.contains("data"), "got: {msg}");
3289
3290 let duplicate_optional = write_file(
3291 tmp.path(),
3292 "duplicate_optional.yml",
3293 r#"
3294module:
3295 name: duplicate_optional
3296 inputs: [data]
3297 inputs_optional: [cfg, cfg]
3298 outputs: [out]
3299
3300nodes:
3301 - id: worker
3302 path: worker.py
3303 inputs:
3304 x: _mod/data
3305 outputs:
3306 - out
3307"#,
3308 );
3309 let msg = check_module_file(&duplicate_optional)
3310 .unwrap_err()
3311 .to_string();
3312 assert!(msg.contains("duplicate"), "got: {msg}");
3313 assert!(msg.contains("inputs_optional"), "got: {msg}");
3314 assert!(msg.contains("cfg"), "got: {msg}");
3315
3316 let duplicate_outputs = write_file(
3317 tmp.path(),
3318 "duplicate_outputs.yml",
3319 r#"
3320module:
3321 name: duplicate_outputs
3322 inputs: [data]
3323 outputs: [out, out]
3324
3325nodes:
3326 - id: worker
3327 path: worker.py
3328 inputs:
3329 x: _mod/data
3330 outputs:
3331 - out
3332"#,
3333 );
3334 let msg = check_module_file(&duplicate_outputs)
3335 .unwrap_err()
3336 .to_string();
3337 assert!(msg.contains("duplicate"), "got: {msg}");
3338 assert!(msg.contains("outputs"), "got: {msg}");
3339 assert!(msg.contains("out"), "got: {msg}");
3340 }
3341
3342 #[test]
3343 fn check_module_file_bad_output() {
3344 let tmp = TempDir::new().unwrap();
3345 let path = write_file(
3346 tmp.path(),
3347 "bad_out_module.yml",
3348 r#"
3349module:
3350 name: bad_out
3351 inputs: []
3352 outputs: [missing]
3353
3354nodes:
3355 - id: proc
3356 path: proc.py
3357 outputs:
3358 - other
3359"#,
3360 );
3361
3362 let result = check_module_file(&path);
3363 assert!(result.is_err());
3364 assert!(result.unwrap_err().to_string().contains("missing"));
3365 }
3366
3367 #[test]
3368 fn check_module_file_rejects_ambiguous_declared_output() {
3369 let tmp = TempDir::new().unwrap();
3370 let path = write_file(
3371 tmp.path(),
3372 "ambiguous_output.yml",
3373 r#"
3374module:
3375 name: ambiguous
3376 inputs: []
3377 outputs: [out]
3378
3379nodes:
3380 - id: first
3381 path: first.py
3382 outputs:
3383 - out
3384 - id: second
3385 path: second.py
3386 outputs:
3387 - out
3388"#,
3389 );
3390
3391 let result = check_module_file(&path);
3392 assert!(result.is_err());
3393 let msg = result.unwrap_err().to_string();
3394 assert!(msg.contains("out"), "got: {msg}");
3395 assert!(msg.contains("multiple"), "got: {msg}");
3396 assert!(msg.contains("first"), "got: {msg}");
3397 assert!(msg.contains("second"), "got: {msg}");
3398 }
3399
3400 #[test]
3401 fn check_module_file_rejects_unknown_module_header_field() {
3402 let tmp = TempDir::new().unwrap();
3403 let path = write_file(
3404 tmp.path(),
3405 "unknown_header_field.yml",
3406 r#"
3407module:
3408 name: bad
3409 inputz: [data]
3410 outputs: [out]
3411
3412nodes:
3413 - id: worker
3414 path: worker.py
3415 outputs:
3416 - out
3417"#,
3418 );
3419
3420 let result = check_module_file(&path);
3421 assert!(result.is_err());
3422 let msg = format!("{:?}", result.unwrap_err());
3423 assert!(msg.contains("inputz"), "got: {msg}");
3424 assert!(msg.contains("unknown field"), "got: {msg}");
3425 }
3426
3427 #[test]
3428 fn check_module_file_rejects_duplicate_inner_node_ids() {
3429 let tmp = TempDir::new().unwrap();
3430 let path = write_file(
3431 tmp.path(),
3432 "duplicate_inner_nodes.yml",
3433 r#"
3434module:
3435 name: duplicate_inner_nodes
3436 inputs: []
3437 outputs: [y]
3438
3439nodes:
3440 - id: proc
3441 path: a.py
3442 outputs:
3443 - y
3444 - id: proc
3445 path: b.py
3446 outputs:
3447 - z
3448"#,
3449 );
3450
3451 let err = check_module_file(&path).unwrap_err().to_string();
3452 assert!(err.contains("duplicate node ID"), "{err}");
3453 assert!(err.contains("proc"), "{err}");
3454 }
3455
3456 #[test]
3457 fn check_module_file_rejects_invalid_internal_wiring() {
3458 let tmp = TempDir::new().unwrap();
3459 let path = write_file(
3460 tmp.path(),
3461 "bad_internal_wiring.yml",
3462 r#"
3463module:
3464 name: bad_internal_wiring
3465 inputs: []
3466 outputs: [y]
3467
3468nodes:
3469 - id: preprocessor
3470 path: preprocessor.py
3471 outputs:
3472 - cleaned
3473 - id: producer
3474 path: producer.py
3475 inputs:
3476 data: preprocessor/cleaned
3477 outputs:
3478 - y
3479 - id: valid_consumer
3480 path: valid_consumer.py
3481 inputs:
3482 data: producer/y
3483 outputs:
3484 - z
3485 - id: consumer
3486 path: consumer.py
3487 inputs:
3488 data: producer/missing
3489 - id: independent
3490 path: independent.py
3491 outputs:
3492 - side
3493"#,
3494 );
3495
3496 let err = check_module_file(&path).unwrap_err().to_string();
3497 assert!(err.contains("producer/missing"), "{err}");
3498 }
3499
3500 #[test]
3501 fn check_module_file_rejects_nested_module_missing_required_input() {
3502 let tmp = TempDir::new().unwrap();
3503
3504 write_file(
3505 tmp.path(),
3506 "leaf.yml",
3507 r#"
3508module:
3509 name: leaf
3510 inputs: [data]
3511 outputs: [out]
3512
3513nodes:
3514 - id: worker
3515 path: worker.py
3516 inputs:
3517 x: _mod/data
3518 outputs:
3519 - out
3520"#,
3521 );
3522
3523 let path = write_file(
3524 tmp.path(),
3525 "outer.yml",
3526 r#"
3527module:
3528 name: outer
3529 inputs: []
3530 outputs: [out]
3531
3532nodes:
3533 - id: nested
3534 module: leaf.yml
3535"#,
3536 );
3537
3538 let result = check_module_file(&path);
3539 assert!(result.is_err());
3540 let msg = result.unwrap_err().to_string();
3541 assert!(msg.contains("leaf"), "got: {msg}");
3542 assert!(msg.contains("data"), "got: {msg}");
3543 assert!(msg.contains("nested"), "got: {msg}");
3544 }
3545
3546 #[test]
3547 fn check_module_file_rejects_invalid_nested_module() {
3548 let tmp = TempDir::new().unwrap();
3549
3550 write_file(
3551 tmp.path(),
3552 "leaf.yml",
3553 r#"
3554module:
3555 name: leaf
3556 inputs: []
3557 outputs: [out]
3558
3559nodes:
3560 - id: worker
3561 path: worker.py
3562 outputs:
3563 - other
3564"#,
3565 );
3566
3567 let path = write_file(
3568 tmp.path(),
3569 "outer.yml",
3570 r#"
3571module:
3572 name: outer
3573 inputs: []
3574 outputs: [out]
3575
3576nodes:
3577 - id: nested
3578 module: leaf.yml
3579"#,
3580 );
3581
3582 let result = check_module_file(&path);
3583 assert!(result.is_err());
3584 let msg = format!("{:#}", result.unwrap_err());
3585 assert!(msg.contains("leaf"), "got: {msg}");
3586 assert!(msg.contains("out"), "got: {msg}");
3587 assert!(msg.contains("no inner node produces it"), "got: {msg}");
3588 assert!(msg.contains("nested"), "got: {msg}");
3591 }
3592
3593 #[test]
3597 fn check_module_file_rejects_self_referencing_module() {
3598 let tmp = TempDir::new().unwrap();
3599 let path = write_file(
3600 tmp.path(),
3601 "self.yml",
3602 r#"
3603module:
3604 name: selfref
3605 inputs: []
3606 outputs: []
3607
3608nodes:
3609 - id: me
3610 module: self.yml
3611"#,
3612 );
3613
3614 let msg = format!("{:#}", check_module_file(&path).unwrap_err());
3615 assert!(msg.contains("circular module reference"), "got: {msg}");
3616 }
3617
3618 #[test]
3622 fn check_module_file_accepts_diamond_module_graph() {
3623 let tmp = TempDir::new().unwrap();
3624 let base = tmp.path();
3625
3626 write_file(
3627 base,
3628 "d.yml",
3629 r#"
3630module:
3631 name: d
3632 inputs: []
3633 outputs: [d_out]
3634
3635nodes:
3636 - id: leaf
3637 path: leaf.py
3638 outputs:
3639 - d_out
3640"#,
3641 );
3642 for name in ["b.yml", "c.yml"] {
3643 write_file(
3644 base,
3645 name,
3646 r#"
3647module:
3648 name: mid
3649 inputs: []
3650 outputs: [d_out]
3651
3652nodes:
3653 - id: node_d
3654 module: d.yml
3655"#,
3656 );
3657 }
3658 let path = write_file(
3659 base,
3660 "a.yml",
3661 r#"
3662module:
3663 name: a
3664 inputs: []
3665 outputs: []
3666
3667nodes:
3668 - id: node_b
3669 module: b.yml
3670 - id: node_c
3671 module: c.yml
3672"#,
3673 );
3674
3675 check_module_file(&path).unwrap();
3676 }
3677
3678 #[test]
3679 fn check_module_file_rejects_depth_limit() {
3680 let tmp = TempDir::new().unwrap();
3681 let base = tmp.path();
3682
3683 for i in 0..=MAX_MODULE_DEPTH {
3684 let next = if i < MAX_MODULE_DEPTH {
3685 format!(" - id: inner\n module: level{}_module.yml", i + 1)
3686 } else {
3687 " - id: worker\n path: worker.py\n outputs:\n - out".to_string()
3688 };
3689
3690 write_file(
3691 base,
3692 &format!("level{i}_module.yml"),
3693 &format!(
3694 r#"
3695module:
3696 name: level{i}
3697 inputs: []
3698 outputs: [out]
3699
3700nodes:
3701{next}
3702"#
3703 ),
3704 );
3705 }
3706
3707 let result = check_module_file(&base.join("level0_module.yml"));
3708 assert!(result.is_err());
3709 assert!(format!("{:#}", result.unwrap_err()).contains("nesting exceeds depth limit"));
3712 }
3713
3714 #[test]
3721 fn check_module_file_accepts_cross_directory_nested_ref() {
3722 let tmp = TempDir::new().unwrap();
3723
3724 write_file(
3726 tmp.path(),
3727 "modules/shared/base.yml",
3728 r#"
3729module:
3730 name: base
3731 inputs: []
3732 outputs: [y]
3733
3734nodes:
3735 - id: inner
3736 path: inner.py
3737 outputs:
3738 - y
3739"#,
3740 );
3741
3742 let path = write_file(
3744 tmp.path(),
3745 "modules/a/mod.yml",
3746 r#"
3747module:
3748 name: outer
3749 inputs: []
3750 outputs: [y]
3751
3752nodes:
3753 - id: nested
3754 module: ../shared/base.yml
3755"#,
3756 );
3757
3758 check_module_file(&path).unwrap();
3761 }
3762
3763 #[test]
3766 fn reject_absolute_module_path() {
3767 let desc = parse_descriptor(
3768 r#"
3769nodes:
3770 - id: evil
3771 module: /etc/passwd
3772"#,
3773 );
3774 let tmp = TempDir::new().unwrap();
3775 let result = expand_modules(&desc, tmp.path());
3776 assert!(result.is_err());
3777 let msg = result.unwrap_err().to_string();
3778 assert!(msg.contains("must be relative"), "got: {msg}");
3779 }
3780
3781 #[test]
3782 fn reject_path_traversal_module() {
3783 let tmp = TempDir::new().unwrap();
3784 let base = tmp.path();
3785
3786 let parent = base.parent().unwrap();
3788 write_file(parent, "escape_module.yml", "module:\n name: x\nnodes: []");
3789
3790 let desc = parse_descriptor(
3791 r#"
3792nodes:
3793 - id: evil
3794 module: ../escape_module.yml
3795"#,
3796 );
3797 let result = expand_modules(&desc, base);
3798 assert!(result.is_err());
3799 let msg = result.unwrap_err().to_string();
3800 assert!(msg.contains("escapes"), "got: {msg}");
3801 }
3802
3803 #[test]
3806 fn expand_modules_accepts_symlinked_module_dir() {
3807 let tmp = TempDir::new().unwrap();
3808 let shared_dir = tmp.path().join("shared_modules");
3809 std::fs::create_dir_all(&shared_dir).unwrap();
3810 write_file(
3811 &shared_dir,
3812 "shared.yml",
3813 r#"
3814module:
3815 name: shared
3816 inputs: [in_val]
3817 outputs: [out_val]
3818
3819nodes:
3820 - id: worker
3821 path: worker.py
3822 inputs:
3823 data: _mod/in_val
3824 outputs:
3825 - out_val
3826"#,
3827 );
3828
3829 let project_dir = tmp.path().join("project");
3830 std::fs::create_dir_all(&project_dir).unwrap();
3831 let symlink_path = project_dir.join("modules");
3832 #[cfg(unix)]
3833 std::os::unix::fs::symlink(&shared_dir, &symlink_path).unwrap();
3834 #[cfg(windows)]
3835 if let Err(e) = std::os::windows::fs::symlink_dir(&shared_dir, &symlink_path) {
3836 eprintln!("skipping symlink test: {e}");
3837 return;
3838 }
3839
3840 let desc = parse_descriptor(
3841 r#"
3842nodes:
3843 - id: m
3844 module: modules/shared.yml
3845 inputs:
3846 in_val: source/data
3847"#,
3848 );
3849
3850 let expanded = expand_modules(&desc, &project_dir).unwrap();
3851 assert_eq!(expanded.nodes.len(), 1);
3852 assert_eq!(expanded.nodes[0].id.to_string(), "m.worker");
3853 assert_eq!(
3854 Path::new(expanded.nodes[0].path.as_ref().unwrap()),
3855 Path::new("modules/worker.py")
3856 );
3857 }
3858
3859 #[test]
3860 fn expand_modules_accepts_in_tree_symlink_with_dotdot() {
3861 let tmp = TempDir::new().unwrap();
3862 let project_dir = tmp.path().join("project");
3863 let real_modules = project_dir.join("real_modules");
3864 std::fs::create_dir_all(&real_modules).unwrap();
3865
3866 write_file(
3867 &project_dir,
3868 "top.yml",
3869 r#"
3870module:
3871 name: top
3872 inputs: [in_val]
3873 outputs: [out_val]
3874
3875nodes:
3876 - id: worker
3877 path: worker.py
3878 inputs:
3879 data: _mod/in_val
3880 outputs:
3881 - out_val
3882"#,
3883 );
3884
3885 let symlink_path = project_dir.join("modules");
3886 #[cfg(unix)]
3887 std::os::unix::fs::symlink(&real_modules, &symlink_path).unwrap();
3888 #[cfg(windows)]
3889 if let Err(e) = std::os::windows::fs::symlink_dir(&real_modules, &symlink_path) {
3890 eprintln!("skipping symlink test: {e}");
3891 return;
3892 }
3893
3894 let desc = parse_descriptor(
3898 r#"
3899nodes:
3900 - id: m
3901 module: modules/../top.yml
3902 inputs:
3903 in_val: source/data
3904"#,
3905 );
3906
3907 let expanded = expand_modules(&desc, &project_dir).unwrap();
3908 assert_eq!(expanded.nodes.len(), 1);
3909 assert_eq!(expanded.nodes[0].id.to_string(), "m.worker");
3910 assert_eq!(
3911 Path::new(expanded.nodes[0].path.as_ref().unwrap()),
3912 Path::new("worker.py")
3913 );
3914 }
3915
3916 #[test]
3917 fn reject_symlinked_module_dir_divergent_lexical_and_physical() {
3918 let tmp = TempDir::new().unwrap();
3919 let external_base = tmp.path().join("external");
3920 let external_modules = external_base.join("modules");
3921 let external_shared = external_base.join("shared");
3922 std::fs::create_dir_all(&external_modules).unwrap();
3923 std::fs::create_dir_all(&external_shared).unwrap();
3924 write_file(
3925 &external_shared,
3926 "x.yml",
3927 "module:\n name: x\n inputs: []\n outputs: []\nnodes: []",
3928 );
3929
3930 let project_dir = tmp.path().join("project");
3931 std::fs::create_dir_all(&project_dir).unwrap();
3932
3933 let symlink_path = project_dir.join("modules");
3934 #[cfg(unix)]
3935 std::os::unix::fs::symlink(&external_modules, &symlink_path).unwrap();
3936 #[cfg(windows)]
3937 if let Err(e) = std::os::windows::fs::symlink_dir(&external_modules, &symlink_path) {
3938 eprintln!("skipping symlink test: {e}");
3939 return;
3940 }
3941
3942 let project_shared = project_dir.join("shared");
3944 std::fs::create_dir_all(&project_shared).unwrap();
3945 write_file(
3946 &project_shared,
3947 "x.yml",
3948 "module:\n name: project_x\n inputs: []\n outputs: []\nnodes: []",
3949 );
3950
3951 let desc = parse_descriptor(
3956 r#"
3957nodes:
3958 - id: m
3959 module: modules/../shared/x.yml
3960"#,
3961 );
3962
3963 let result = expand_modules(&desc, &project_dir);
3964 assert!(result.is_err(), "expected error but succeeded");
3965 let msg = result.unwrap_err().to_string();
3966 assert!(msg.contains("escapes"), "got: {msg}");
3967 }
3968
3969 #[test]
3970 fn reject_symlinked_module_dir_dotdot_escape() {
3971 let tmp = TempDir::new().unwrap();
3972 let shared_base = tmp.path().join("shared_base");
3973 let shared_dir = shared_base.join("shared_modules");
3974 std::fs::create_dir_all(&shared_dir).unwrap();
3975 write_file(
3976 &shared_dir,
3977 "shared.yml",
3978 "module:\n name: shared\n inputs: []\n outputs: []\nnodes: []",
3979 );
3980
3981 write_file(
3983 &shared_base,
3984 "escape.yml",
3985 "module:\n name: escape\n inputs: []\n outputs: []\nnodes: []",
3986 );
3987
3988 let project_dir = tmp.path().join("project");
3989 std::fs::create_dir_all(&project_dir).unwrap();
3990
3991 let symlink_path = project_dir.join("modules");
3992 #[cfg(unix)]
3993 std::os::unix::fs::symlink(&shared_dir, &symlink_path).unwrap();
3994 #[cfg(windows)]
3995 if let Err(e) = std::os::windows::fs::symlink_dir(&shared_dir, &symlink_path) {
3996 eprintln!("skipping symlink test: {e}");
3997 return;
3998 }
3999
4000 let desc = parse_descriptor(
4007 r#"
4008nodes:
4009 - id: m
4010 module: modules/../escape.yml
4011"#,
4012 );
4013
4014 let result = expand_modules(&desc, &project_dir);
4015 assert!(result.is_err(), "expected error but succeeded");
4016 let msg = result.unwrap_err().to_string();
4017 assert!(msg.contains("escapes"), "got: {msg}");
4018 }
4019
4020 #[test]
4021 fn expand_modules_accepts_dotdot_in_base_dir() {
4022 let tmp = TempDir::new().unwrap();
4023 let sub = tmp.path().join("sub");
4024 std::fs::create_dir_all(&sub).unwrap();
4025 let project_dir = sub.join("..").join("project");
4026 std::fs::create_dir_all(&project_dir).unwrap();
4027 write_file(
4028 &project_dir,
4029 "mod.yml",
4030 r#"
4031module:
4032 name: inner
4033 inputs: [in_val]
4034 outputs: [out_val]
4035
4036nodes:
4037 - id: worker
4038 path: worker.py
4039 inputs:
4040 data: _mod/in_val
4041 outputs:
4042 - out_val
4043"#,
4044 );
4045
4046 let desc = parse_descriptor(
4047 r#"
4048nodes:
4049 - id: m
4050 module: mod.yml
4051 inputs:
4052 in_val: source/data
4053"#,
4054 );
4055
4056 let expanded = expand_modules(&desc, &project_dir).unwrap();
4057 assert_eq!(expanded.nodes.len(), 1);
4058 assert_eq!(expanded.nodes[0].id.to_string(), "m.worker");
4059 assert_eq!(
4060 Path::new(expanded.nodes[0].path.as_ref().unwrap()),
4061 Path::new("worker.py")
4062 );
4063 }
4064
4065 #[test]
4069 fn expand_keeps_git_inner_node_path_relative_to_the_clone() {
4070 let tmp = TempDir::new().unwrap();
4071 let base = tmp.path();
4072 std::fs::create_dir_all(base.join("modules")).unwrap();
4073
4074 write_file(
4075 &base.join("modules"),
4076 "git_module.yml",
4077 r#"
4078module:
4079 name: git_module
4080 outputs: [from_git, local]
4081
4082nodes:
4083 - id: fromgit
4084 git: https://github.com/example/x.git
4085 path: ../bin/x
4086 outputs:
4087 - from_git
4088 - id: sibling
4089 path: worker.py
4090 outputs:
4091 - local
4092"#,
4093 );
4094
4095 let desc = parse_descriptor(
4096 r#"
4097nodes:
4098 - id: m
4099 module: modules/git_module.yml
4100"#,
4101 );
4102
4103 let expanded = expand_modules(&desc, base).unwrap();
4104 let path_of = |id: &str| {
4105 expanded
4106 .nodes
4107 .iter()
4108 .find(|n| n.id.to_string() == id)
4109 .and_then(|n| n.path.clone())
4110 .unwrap()
4111 };
4112 assert_eq!(path_of("m.fromgit"), "../bin/x");
4113 assert_eq!(
4115 Path::new(&path_of("m.sibling")),
4116 Path::new("modules").join("worker.py")
4117 );
4118 }
4119
4120 #[test]
4121 fn reject_inner_node_path_traversal_via_dotdot() {
4122 let tmp = TempDir::new().unwrap();
4123 let base = tmp.path();
4124
4125 write_file(
4128 base,
4129 "escape_node_module.yml",
4130 r#"
4131module:
4132 name: escape_node
4133 inputs: []
4134 outputs: []
4135
4136nodes:
4137 - id: evil
4138 path: ../../etc/evil-binary
4139"#,
4140 );
4141
4142 let desc = parse_descriptor(
4143 r#"
4144nodes:
4145 - id: m
4146 module: escape_node_module.yml
4147"#,
4148 );
4149
4150 let result = expand_modules(&desc, base);
4151 assert!(result.is_err(), "expected error but got success");
4152 let msg = result.unwrap_err().to_string();
4153 assert!(
4154 msg.contains("resolves outside the project directory"),
4155 "got: {msg}"
4156 );
4157 }
4158
4159 #[test]
4163 fn expand_prefixes_sibling_log_filter() {
4164 let tmp = TempDir::new().unwrap();
4165 let base = tmp.path();
4166
4167 write_file(
4168 base,
4169 "logs_module.yml",
4170 r#"
4171module:
4172 name: logs_module
4173 outputs: []
4174
4175nodes:
4176 - id: worker
4177 path: worker.py
4178 - id: sink
4179 path: sink.py
4180 inputs:
4181 sibling: dora/logs/info/worker
4182 external: dora/logs/warn/outside
4183 all: dora/logs/info
4184"#,
4185 );
4186
4187 let desc = parse_descriptor(
4188 r#"
4189nodes:
4190 - id: m
4191 module: logs_module.yml
4192 - id: outside
4193 path: outside.py
4194"#,
4195 );
4196
4197 let expanded = expand_modules(&desc, base).unwrap();
4198 let sink = expanded
4199 .nodes
4200 .iter()
4201 .find(|n| n.id.to_string() == "m.sink")
4202 .unwrap();
4203 let filter_of = |input: &str| match &sink.inputs[&DataId::from(input.to_string())].mapping {
4204 InputMapping::Logs(filter) => filter.clone(),
4205 other => panic!("expected a logs mapping, got {other:?}"),
4206 };
4207
4208 let sibling = filter_of("sibling");
4209 assert_eq!(sibling.node_filter.unwrap().to_string(), "m.worker");
4210 assert!(sibling.min_level.is_some(), "level must be preserved");
4211 assert_eq!(
4213 filter_of("external").node_filter.unwrap().to_string(),
4214 "outside"
4215 );
4216 assert!(filter_of("all").node_filter.is_none());
4217 }
4218
4219 #[test]
4220 fn reject_inner_operator_source_path_traversal_via_dotdot() {
4221 let tmp = TempDir::new().unwrap();
4222 let base = tmp.path();
4223
4224 write_file(
4225 base,
4226 "escape_operator_module.yml",
4227 r#"
4228module:
4229 name: escape_operator
4230 inputs: []
4231 outputs: []
4232
4233nodes:
4234 - id: runtime
4235 operators:
4236 - id: evil
4237 shared-library: ../../etc/evil-operator
4238"#,
4239 );
4240
4241 let desc = parse_descriptor(
4242 r#"
4243nodes:
4244 - id: m
4245 module: escape_operator_module.yml
4246"#,
4247 );
4248
4249 let result = expand_modules(&desc, base);
4250 assert!(result.is_err(), "expected error but got success");
4251 let msg = result.unwrap_err().to_string();
4252 assert!(
4253 msg.contains("resolves outside the project directory"),
4254 "got: {msg}"
4255 );
4256 }
4257
4258 #[test]
4259 fn reject_invalid_param_key() {
4260 let tmp = TempDir::new().unwrap();
4261 let base = tmp.path();
4262
4263 write_file(
4264 base,
4265 "param_mod.yml",
4266 r#"
4267module:
4268 name: p
4269 inputs: [x]
4270 outputs: [y]
4271
4272nodes:
4273 - id: n
4274 path: n.py
4275 inputs:
4276 x: _mod/x
4277 outputs: [y]
4278"#,
4279 );
4280
4281 let desc = parse_descriptor(
4282 r#"
4283nodes:
4284 - id: src
4285 path: src.py
4286 outputs: [v]
4287 - id: m
4288 module: param_mod.yml
4289 inputs:
4290 x: src/v
4291 params:
4292 "bad}key": value
4293"#,
4294 );
4295 let result = expand_modules(&desc, base);
4296 assert!(result.is_err());
4297 let msg = result.unwrap_err().to_string();
4298 assert!(msg.contains("invalid param key"), "got: {msg}");
4299 }
4300
4301 #[test]
4302 fn reject_case_colliding_param_keys() {
4303 let tmp = TempDir::new().unwrap();
4304 let base = tmp.path();
4305
4306 write_file(
4307 base,
4308 "param_mod.yml",
4309 r#"
4310module:
4311 name: p
4312 inputs: [x]
4313 outputs: [y]
4314
4315nodes:
4316 - id: n
4317 path: n.py
4318 inputs:
4319 x: _mod/x
4320 outputs: [y]
4321"#,
4322 );
4323
4324 let desc = parse_descriptor(
4327 r#"
4328nodes:
4329 - id: src
4330 path: src.py
4331 outputs: [v]
4332 - id: m
4333 module: param_mod.yml
4334 inputs:
4335 x: src/v
4336 params:
4337 mode: safe
4338 Mode: turbo
4339"#,
4340 );
4341 let result = expand_modules(&desc, base);
4342 assert!(result.is_err());
4343 let msg = result.unwrap_err().to_string();
4344 assert!(msg.contains("PARAM_MODE"), "got: {msg}");
4345 }
4346
4347 #[test]
4348 fn reject_duplicate_node_ids() {
4349 let tmp = TempDir::new().unwrap();
4350 let base = tmp.path();
4351
4352 write_file(
4353 base,
4354 "dup_module.yml",
4355 r#"
4356module:
4357 name: dup
4358 inputs: []
4359 outputs: [y]
4360
4361nodes:
4362 - id: inner
4363 path: inner.py
4364 outputs: [y]
4365"#,
4366 );
4367
4368 let desc = parse_descriptor(
4370 r#"
4371nodes:
4372 - id: m.inner
4373 path: other.py
4374 outputs: [z]
4375 - id: m
4376 module: dup_module.yml
4377"#,
4378 );
4379 let result = expand_modules(&desc, base);
4380 assert!(result.is_err());
4381 let msg = result.unwrap_err().to_string();
4382 assert!(msg.contains("duplicate node ID"), "got: {msg}");
4383 }
4384
4385 #[test]
4386 fn expand_nested_module_build_propagated() {
4387 let tmp = TempDir::new().unwrap();
4388 let base = tmp.path();
4389
4390 write_file(
4391 base,
4392 "leaf_module.yml",
4393 r#"
4394module:
4395 name: leaf
4396 inputs: [x]
4397 outputs: [y]
4398
4399nodes:
4400 - id: worker
4401 path: worker.py
4402 inputs:
4403 x: _mod/x
4404 outputs:
4405 - y
4406"#,
4407 );
4408
4409 write_file(
4410 base,
4411 "outer_module.yml",
4412 r#"
4413module:
4414 name: outer
4415 inputs: [x]
4416 outputs: [y]
4417
4418build: pip install outer-deps
4419
4420nodes:
4421 - id: inner
4422 module: leaf_module.yml
4423 inputs:
4424 x: _mod/x
4425"#,
4426 );
4427
4428 let desc = parse_descriptor(
4429 r#"
4430nodes:
4431 - id: src
4432 path: src.py
4433 outputs: [val]
4434 - id: top
4435 module: outer_module.yml
4436 inputs:
4437 x: src/val
4438"#,
4439 );
4440
4441 let expanded = expand_modules(&desc, base).unwrap();
4442 let worker = expanded
4443 .nodes
4444 .iter()
4445 .find(|n| n.id.to_string() == "top.inner.worker")
4446 .unwrap();
4447 let build = worker.build.as_deref().unwrap();
4448 assert!(
4449 build.contains("pip install outer-deps"),
4450 "outer module build must propagate through nested modules; got: {build}"
4451 );
4452 }
4453
4454 #[test]
4455 fn param_override_precedence() {
4456 let tmp = TempDir::new().unwrap();
4457 let base = tmp.path();
4458
4459 write_file(
4460 base,
4461 "override_mod.yml",
4462 r#"
4463module:
4464 name: override
4465 inputs: [x]
4466 outputs: [y]
4467
4468nodes:
4469 - id: proc
4470 path: proc.py
4471 inputs:
4472 x: _mod/x
4473 outputs: [y]
4474 env:
4475 PARAM_SPEED: "default_value"
4476"#,
4477 );
4478
4479 let desc = parse_descriptor(
4480 r#"
4481nodes:
4482 - id: src
4483 path: src.py
4484 outputs: [v]
4485 - id: m
4486 module: override_mod.yml
4487 inputs:
4488 x: src/v
4489 params:
4490 speed: "caller_value"
4491"#,
4492 );
4493
4494 let expanded = expand_modules(&desc, base).unwrap();
4495 let proc = expanded
4496 .nodes
4497 .iter()
4498 .find(|n| n.id.to_string() == "m.proc")
4499 .unwrap();
4500 let env = proc.env.as_ref().unwrap();
4501 assert_eq!(
4503 env["PARAM_SPEED"],
4504 EnvValue::String("caller_value".to_string())
4505 );
4506 }
4507
4508 #[test]
4513 fn expand_rewrites_operator_inputs() {
4514 let tmp = TempDir::new().unwrap();
4515 let base = tmp.path();
4516
4517 write_file(
4518 base,
4519 "runtime_module.yml",
4520 r#"
4521module:
4522 name: rt
4523 inputs: [data]
4524
4525nodes:
4526 - id: stage_a
4527 path: a.py
4528 outputs: [aux]
4529
4530 - id: runtime
4531 operators:
4532 - id: proc
4533 shared-library: proc.so
4534 inputs:
4535 x: _mod/data
4536 y: stage_a/aux
4537 outputs:
4538 - result
4539"#,
4540 );
4541
4542 let desc = parse_descriptor(
4543 r#"
4544nodes:
4545 - id: src
4546 path: src.py
4547 outputs: [val]
4548 - id: m
4549 module: runtime_module.yml
4550 inputs:
4551 data: src/val
4552"#,
4553 );
4554
4555 let expanded = expand_modules(&desc, base).unwrap();
4556
4557 let runtime = expanded
4558 .nodes
4559 .iter()
4560 .find(|n| n.id.to_string() == "m.runtime")
4561 .unwrap();
4562 let proc = runtime
4563 .operators
4564 .as_ref()
4565 .unwrap()
4566 .operators
4567 .iter()
4568 .find(|op| op.id.to_string() == "proc")
4569 .unwrap();
4570
4571 let x = &proc.config.inputs[&DataId::from("x".to_string())];
4572 match &x.mapping {
4573 InputMapping::User(m) => {
4574 assert_eq!(m.source.to_string(), "src");
4575 assert_eq!(m.output.to_string(), "val");
4576 }
4577 _ => panic!("expected user mapping"),
4578 }
4579
4580 let y = &proc.config.inputs[&DataId::from("y".to_string())];
4581 match &y.mapping {
4582 InputMapping::User(m) => {
4583 assert_eq!(m.source.to_string(), "m.stage_a");
4584 assert_eq!(m.output.to_string(), "aux");
4585 }
4586 _ => panic!("expected user mapping"),
4587 }
4588 }
4589
4590 #[test]
4593 fn check_module_file_rejects_invalid_mod_ref_in_operator_input() {
4594 let tmp = TempDir::new().unwrap();
4595 let base = tmp.path();
4596
4597 let path = write_file(
4598 base,
4599 "bad_operator_module.yml",
4600 r#"
4601module:
4602 name: bad
4603 inputs: [data]
4604 outputs: [result]
4605
4606nodes:
4607 - id: runtime
4608 operators:
4609 - id: proc
4610 shared-library: proc.so
4611 inputs:
4612 x: _mod/nonexistent
4613 outputs:
4614 - result
4615"#,
4616 );
4617
4618 let err = check_module_file(&path).unwrap_err();
4619 assert!(err.to_string().contains("_mod/nonexistent"));
4620 }
4621
4622 #[test]
4623 fn expand_resolves_inner_operator_and_node_sources_relative_to_module_file() {
4624 let tmp = TempDir::new().unwrap();
4625 let base = tmp.path();
4626
4627 write_file(
4628 base,
4629 "modules/nested/source_paths.yml",
4630 r#"
4631module:
4632 name: source_paths
4633 inputs: [data]
4634 outputs: [from_runtime, from_operator, from_path]
4635
4636nodes:
4637 - id: runtime
4638 operators:
4639 - id: proc
4640 shared-library: libproc.so
4641 inputs:
4642 x: _mod/data
4643 outputs:
4644 - from_runtime
4645
4646 - id: single
4647 operator:
4648 python: single.py
4649 inputs:
4650 x: _mod/data
4651 outputs:
4652 - from_operator
4653
4654 - id: runner
4655 path: runner.py
4656 inputs:
4657 x: _mod/data
4658 outputs:
4659 - from_path
4660"#,
4661 );
4662
4663 let desc = parse_descriptor(
4664 r#"
4665nodes:
4666 - id: src
4667 path: src.py
4668 outputs: [val]
4669 - id: m
4670 module: modules/nested/source_paths.yml
4671 inputs:
4672 data: src/val
4673"#,
4674 );
4675
4676 let expanded = expand_modules(&desc, base).unwrap();
4677 let runtime = expanded
4678 .nodes
4679 .iter()
4680 .find(|n| n.id.to_string() == "m.runtime")
4681 .unwrap();
4682 let proc_source = &runtime
4683 .operators
4684 .as_ref()
4685 .unwrap()
4686 .operators
4687 .first()
4688 .unwrap()
4689 .config
4690 .source;
4691 assert!(
4695 matches!(proc_source, dora_message::descriptor::OperatorSource::SharedLibrary(path) if path.replace('\\', "/") == "modules/nested/libproc.so"),
4696 "unexpected runtime operator source: {proc_source:?}"
4697 );
4698
4699 let single = expanded
4700 .nodes
4701 .iter()
4702 .find(|n| n.id.to_string() == "m.single")
4703 .unwrap();
4704 let single_source = &single.operator.as_ref().unwrap().config.source;
4705 assert!(
4706 matches!(single_source, dora_message::descriptor::OperatorSource::Python(source) if source.source.replace('\\', "/") == "modules/nested/single.py"),
4707 "unexpected single operator source: {single_source:?}"
4708 );
4709
4710 let runner = expanded
4711 .nodes
4712 .iter()
4713 .find(|n| n.id.to_string() == "m.runner")
4714 .unwrap();
4715 assert_eq!(
4716 runner
4717 .path
4718 .as_deref()
4719 .map(|p| p.replace('\\', "/"))
4720 .as_deref(),
4721 Some("modules/nested/runner.py")
4722 );
4723 }
4724
4725 #[test]
4730 fn expand_preserves_dynamic_source_sentinel() {
4731 let tmp = TempDir::new().unwrap();
4732 let base = tmp.path();
4733
4734 write_file(
4735 base,
4736 "modules/nested/dynamic_source.yml",
4737 r#"
4738module:
4739 name: dynamic_source
4740 inputs: [data]
4741 outputs: [result]
4742
4743nodes:
4744 - id: dyn
4745 path: dynamic
4746 inputs:
4747 x: _mod/data
4748 outputs:
4749 - result
4750"#,
4751 );
4752
4753 let desc = parse_descriptor(
4754 r#"
4755nodes:
4756 - id: src
4757 path: src.py
4758 outputs: [val]
4759 - id: m
4760 module: modules/nested/dynamic_source.yml
4761 inputs:
4762 data: src/val
4763"#,
4764 );
4765
4766 let expanded = expand_modules(&desc, base).unwrap();
4767 let dyn_node = expanded
4768 .nodes
4769 .iter()
4770 .find(|n| n.id.to_string() == "m.dyn")
4771 .unwrap();
4772
4773 assert_eq!(dyn_node.path.as_deref(), Some("dynamic"));
4774 }
4775
4776 #[test]
4779 fn expand_preserves_shell_source_sentinel() {
4780 let tmp = TempDir::new().unwrap();
4781 let base = tmp.path();
4782
4783 write_file(
4784 base,
4785 "modules/nested/shell_source.yml",
4786 r#"
4787module:
4788 name: shell_source
4789 inputs: [data]
4790 outputs: [result]
4791
4792nodes:
4793 - id: shell
4794 path: shell
4795 args: echo hi
4796 inputs:
4797 x: _mod/data
4798 outputs:
4799 - result
4800"#,
4801 );
4802
4803 let desc = parse_descriptor(
4804 r#"
4805nodes:
4806 - id: src
4807 path: src.py
4808 outputs: [val]
4809 - id: m
4810 module: modules/nested/shell_source.yml
4811 inputs:
4812 data: src/val
4813"#,
4814 );
4815
4816 let expanded = expand_modules(&desc, base).unwrap();
4817 let shell_node = expanded
4818 .nodes
4819 .iter()
4820 .find(|n| n.id.to_string() == "m.shell")
4821 .unwrap();
4822
4823 assert_eq!(shell_node.path.as_deref(), Some("shell"));
4824 }
4825
4826 fn expand_and_resolve_sink_input(base: &Path) -> UserInputMapping {
4832 let desc = parse_descriptor(
4833 r#"
4834nodes:
4835 - id: src
4836 path: src.py
4837 outputs: [val]
4838 - id: m
4839 module: mod.yml
4840 inputs:
4841 data: src/val
4842 - id: sink
4843 path: sink.py
4844 inputs:
4845 r: m/result
4846"#,
4847 );
4848
4849 let expanded = expand_modules(&desc, base).unwrap();
4850 crate::descriptor::validate::check_wiring(&expanded).unwrap();
4851
4852 let sink = expanded
4853 .nodes
4854 .iter()
4855 .find(|n| n.id.to_string() == "sink")
4856 .unwrap();
4857 match &sink.inputs[&DataId::from("r".to_string())].mapping {
4858 InputMapping::User(m) => m.clone(),
4859 _ => panic!("expected user mapping"),
4860 }
4861 }
4862
4863 #[test]
4868 fn expand_resolves_operator_produced_module_output() {
4869 let tmp = TempDir::new().unwrap();
4870 let base = tmp.path();
4871
4872 write_file(
4873 base,
4874 "mod.yml",
4875 r#"
4876module:
4877 name: rt
4878 inputs: [data]
4879 outputs: [result]
4880
4881nodes:
4882 - id: runtime
4883 operators:
4884 - id: proc
4885 shared-library: proc.so
4886 inputs:
4887 x: _mod/data
4888 outputs:
4889 - result
4890"#,
4891 );
4892
4893 let mapping = expand_and_resolve_sink_input(base);
4894 assert_eq!(mapping.source.to_string(), "m.runtime");
4895 assert_eq!(mapping.output.to_string(), "proc/result");
4896 }
4897
4898 #[test]
4902 fn expand_resolves_single_operator_produced_module_output() {
4903 let tmp = TempDir::new().unwrap();
4904 let base = tmp.path();
4905
4906 write_file(
4907 base,
4908 "mod.yml",
4909 r#"
4910module:
4911 name: rt
4912 inputs: [data]
4913 outputs: [result]
4914
4915nodes:
4916 - id: runtime
4917 operator:
4918 shared-library: proc.so
4919 inputs:
4920 x: _mod/data
4921 outputs:
4922 - result
4923"#,
4924 );
4925
4926 let mapping = expand_and_resolve_sink_input(base);
4927 assert_eq!(mapping.source.to_string(), "m.runtime");
4928 assert_eq!(mapping.output.to_string(), "result");
4929 }
4930
4931 #[test]
4932 fn expand_rejects_ambiguous_module_output() {
4933 let tmp = TempDir::new().unwrap();
4934 let base = tmp.path();
4935
4936 write_file(
4937 base,
4938 "mod.yml",
4939 r#"
4940module:
4941 name: ambiguous
4942 inputs: []
4943 outputs: [out]
4944
4945nodes:
4946 - id: first
4947 path: first.py
4948 outputs:
4949 - out
4950 - id: second
4951 path: second.py
4952 outputs:
4953 - out
4954"#,
4955 );
4956
4957 let desc = parse_descriptor(
4958 r#"
4959nodes:
4960 - id: m
4961 module: mod.yml
4962 - id: sink
4963 path: sink.py
4964 inputs:
4965 value: m/out
4966"#,
4967 );
4968
4969 let result = expand_modules(&desc, base);
4970 assert!(result.is_err());
4971 let msg = result.unwrap_err().to_string();
4972 assert!(msg.contains("out"), "got: {msg}");
4973 assert!(msg.contains("multiple"), "got: {msg}");
4974 assert!(msg.contains("m.first"), "got: {msg}");
4975 assert!(msg.contains("m.second"), "got: {msg}");
4976 }
4977
4978 #[test]
4979 fn expand_rejects_nested_module_private_output_export() {
4980 let tmp = TempDir::new().unwrap();
4981 let base = tmp.path();
4982
4983 write_file(
4984 base,
4985 "leaf.yml",
4986 r#"
4987module:
4988 name: leaf
4989 inputs: []
4990 outputs: [public]
4991
4992nodes:
4993 - id: worker
4994 path: worker.py
4995 outputs:
4996 - public
4997 - private
4998"#,
4999 );
5000
5001 write_file(
5002 base,
5003 "outer.yml",
5004 r#"
5005module:
5006 name: outer
5007 inputs: []
5008 outputs: [private]
5009
5010nodes:
5011 - id: nested
5012 module: leaf.yml
5013"#,
5014 );
5015
5016 let desc = parse_descriptor(
5017 r#"
5018nodes:
5019 - id: m
5020 module: outer.yml
5021 - id: sink
5022 path: sink.py
5023 inputs:
5024 value: m/private
5025"#,
5026 );
5027
5028 let result = expand_modules(&desc, base);
5029 assert!(result.is_err());
5030 let msg = result.unwrap_err().to_string();
5031 assert!(msg.contains("private"), "got: {msg}");
5032 assert!(msg.contains("no inner node produces it"), "got: {msg}");
5033 }
5034
5035 #[test]
5039 fn check_module_file_accepts_operator_produced_outputs() {
5040 let tmp = TempDir::new().unwrap();
5041 let base = tmp.path();
5042
5043 let path = write_file(
5044 base,
5045 "mixed_module.yml",
5046 r#"
5047module:
5048 name: mixed
5049 inputs: [data]
5050 outputs: [from_operator]
5051
5052nodes:
5053 - id: runtime
5054 operators:
5055 - id: proc
5056 shared-library: proc.so
5057 inputs:
5058 x: _mod/data
5059 outputs:
5060 - from_operator
5061"#,
5062 );
5063 check_module_file(&path).unwrap();
5064
5065 let missing = write_file(
5066 base,
5067 "missing_module.yml",
5068 r#"
5069module:
5070 name: missing
5071 outputs: [nobody_produces_this]
5072
5073nodes:
5074 - id: runtime
5075 operators:
5076 - id: proc
5077 shared-library: proc.so
5078 outputs:
5079 - something_else
5080"#,
5081 );
5082 let err = check_module_file(&missing).unwrap_err().to_string();
5083 assert!(err.contains("no inner node produces it"), "{err}");
5084 }
5085}