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