1use dora_message::{
2 config::{Input, InputMapping},
3 descriptor::{EnvValue, derive_port_id, single_topic_port_id},
4 id::{DataId, NodeId, OperatorId},
5};
6use eyre::{Context, OptionExt, Result, bail};
7use std::{
8 borrow::Cow,
9 collections::{BTreeMap, BTreeSet, HashMap},
10 env::consts::EXE_EXTENSION,
11 path::{Component, Path, PathBuf},
12 process::Command,
13};
14
15pub use dora_message::descriptor::{
17 CoreNodeKind, CustomNode, DYNAMIC_SOURCE, Descriptor, Node, OperatorConfig, OperatorDefinition,
18 OperatorSource, PythonSource, RUNTIME_PYTHON, RUNTIME_SHARED_LIBRARY, RUNTIME_WASM,
19 ResolvedNode, RmwZenohCompatibility, Ros2BridgeConfig, Ros2Direction, Ros2QosConfig,
20 Ros2TopicConfig, Ros2TransportConfig, RuntimeNode, SHELL_SOURCE, SingleOperatorDefinition,
21};
22pub use validate::ResolvedNodeExt;
23pub use visualize::collect_dora_timers;
24
25mod classify;
26pub(crate) fn normalize_path(path: &Path) -> PathBuf {
33 let mut out = PathBuf::new();
34 for component in path.components() {
35 match component {
36 Component::CurDir => {}
37 Component::ParentDir => {
38 out.pop();
39 }
40 other => out.push(other),
41 }
42 }
43 out
44}
45
46mod expand;
47pub mod validate;
48mod visualize;
49
50pub use expand::{
51 ExpandedDescriptor, ModuleBoundaries, check_module_file, expand_modules,
52 expand_modules_with_boundaries,
53};
54
55pub trait DescriptorExt {
89 fn resolve_aliases_and_set_defaults(&self) -> eyre::Result<BTreeMap<NodeId, ResolvedNode>>;
96 fn visualize_as_mermaid_with_boundaries(
99 &self,
100 boundaries: &ModuleBoundaries,
101 ) -> eyre::Result<String>;
102 fn apply_exit_when_nodes_finish(&mut self, over: Option<bool>);
115
116 fn blocking_read(path: &Path) -> eyre::Result<Descriptor>;
118 fn parse(buf: Vec<u8>) -> eyre::Result<Descriptor>;
123 fn check(&self, working_dir: &Path) -> eyre::Result<()>;
126 fn expand(&self, working_dir: &Path) -> eyre::Result<Descriptor>;
132 fn expand_with_boundaries(
135 &self,
136 working_dir: &Path,
137 ) -> eyre::Result<(Descriptor, ModuleBoundaries)>;
138}
139
140pub const SINGLE_OPERATOR_DEFAULT_ID: &str = "op";
141
142fn prefix_output_with_operator_id(op_name: &OperatorId, output: &DataId) -> eyre::Result<DataId> {
153 format!("{op_name}/{output}")
154 .parse::<DataId>()
155 .map_err(|e| {
156 eyre::eyre!(
157 "operator id `{op_name}` produces an invalid output id `{op_name}/{output}`: {e}"
158 )
159 })
160}
161
162pub fn resolve_aliases_and_set_defaults_in_topology(
183 desc: &Descriptor,
184 topology_nodes: &[Node],
185) -> eyre::Result<BTreeMap<NodeId, ResolvedNode>> {
186 let default_op_id = OperatorId::from(SINGLE_OPERATOR_DEFAULT_ID.to_string());
187
188 let single_operator_nodes: HashMap<_, _> = topology_nodes
189 .iter()
190 .chain(desc.nodes.iter())
191 .filter_map(|n| {
192 n.operator
193 .as_ref()
194 .map(|op| (&n.id, op.id.as_ref().unwrap_or(&default_op_id)))
195 })
196 .collect();
197
198 let mut resolved = BTreeMap::new();
199 for mut node in desc.nodes.clone() {
200 let node_class = classify::classify(&node)?;
202
203 if node.ros2.is_some() {
205 for input in node.inputs.values_mut() {
206 if let InputMapping::User(m) = &mut input.mapping
207 && let Some(op_name) = single_operator_nodes.get(&m.source).copied()
208 {
209 m.output = prefix_output_with_operator_id(op_name, &m.output)?;
210 }
211 }
212 }
213
214 let input_mappings: Vec<_> = match &node_class {
216 classify::NodeClass::Standard { .. } => node.inputs.values_mut().collect(),
217 classify::NodeClass::Runtime => node
218 .operators
219 .as_mut()
220 .ok_or_eyre("no operators")?
221 .operators
222 .iter_mut()
223 .flat_map(|op| op.config.inputs.values_mut())
224 .collect(),
225 classify::NodeClass::Operator => node
226 .operator
227 .as_mut()
228 .ok_or_eyre("no operator")?
229 .config
230 .inputs
231 .values_mut()
232 .collect(),
233 classify::NodeClass::Ros2Bridge => vec![],
234 };
235 for mapping in input_mappings
236 .into_iter()
237 .filter_map(|i| match &mut i.mapping {
238 InputMapping::Timer { .. } | InputMapping::Logs(_) => None,
239 InputMapping::User(m) => Some(m),
240 })
241 {
242 if let Some(op_name) = single_operator_nodes.get(&mapping.source).copied() {
243 mapping.output = prefix_output_with_operator_id(op_name, &mapping.output)?;
244 }
245 }
246
247 let kind = match node_class {
251 classify::NodeClass::Standard { source } => {
252 let path = node.path.take().ok_or_eyre("missing `path` attribute")?;
253 let mut custom = CustomNode::from_node(&mut node, path);
254 custom.source = source;
255 CoreNodeKind::Custom(custom)
256 }
257 classify::NodeClass::Runtime => {
258 let runtime = node.operators.take().ok_or_eyre("no operators")?;
263 CoreNodeKind::Runtime(runtime)
264 }
265 classify::NodeClass::Operator => {
266 let op = node.operator.take().ok_or_eyre("no operator")?;
270 CoreNodeKind::Runtime(RuntimeNode {
271 operators: vec![OperatorDefinition {
272 id: op.id.unwrap_or_else(|| default_op_id.clone()),
273 config: op.config,
274 }],
275 })
276 }
277 classify::NodeClass::Ros2Bridge => {
278 let config = node.ros2.as_ref().ok_or_eyre("no ros2")?;
279 let config =
280 resolve_ros2_single_topic(&node.id, config, &node.inputs, &node.outputs)?;
281 if let Some(topics) = &config.topics {
291 validate_ros2_topic_ports(&node.id, topics, &node.inputs, &node.outputs)?;
292 }
293 let bridge_config_json = serde_json::to_string(&config)
294 .context("failed to serialize ROS2 bridge config")?;
295
296 let mut envs = BTreeMap::new();
297 envs.insert(
298 "DORA_ROS2_BRIDGE_CONFIG".to_string(),
299 EnvValue::String(bridge_config_json),
300 );
301
302 let mut custom =
307 CustomNode::from_node(&mut node, "dora-ros2-bridge-node".to_string());
308 custom.envs = Some(envs);
309 CoreNodeKind::Custom(custom)
310 }
311 };
312
313 if resolved.contains_key(&node.id) {
314 eyre::bail!(
315 "duplicate node ID `{}` — each node must have a unique `id`",
316 node.id
317 );
318 }
319 let mut resolved_node = ResolvedNode::from_node(node, kind);
320 resolved_node.env = merge_env(desc.env.as_ref(), resolved_node.env.take());
324 resolved.insert(resolved_node.id.clone(), resolved_node);
325 }
326
327 Ok(resolved)
328}
329
330pub(crate) fn resolve_ros2_single_topic<'a>(
344 node_id: &NodeId,
345 config: &'a Ros2BridgeConfig,
346 node_inputs: &BTreeMap<DataId, Input>,
347 node_outputs: &BTreeSet<DataId>,
348) -> eyre::Result<Cow<'a, Ros2BridgeConfig>> {
349 let (Some(topic), Some(message_type), None) =
353 (&config.topic, &config.message_type, &config.topics)
354 else {
355 return Ok(Cow::Borrowed(config));
356 };
357 let (direction, port_kind, declared) = match config.direction {
358 Ros2Direction::Subscribe => (
359 "subscribe",
360 "output",
361 node_outputs
362 .iter()
363 .map(|id| id.as_str())
364 .collect::<Vec<_>>(),
365 ),
366 Ros2Direction::Publish => (
367 "publish",
368 "input",
369 node_inputs.keys().map(|id| id.as_str()).collect(),
370 ),
371 };
372 let Some(port) = single_topic_port_id(topic, &declared) else {
373 if declared.is_empty() {
374 bail!("node `{node_id}`: ros2 {direction} bridge requires at least one {port_kind}");
375 }
376 bail!(
377 "node `{node_id}`: ros2 {direction} topic `{topic}` has no unambiguous {port_kind} \
378 — single-topic mode binds to the node's declared {port_kind}, but it declares \
379 several: {}. Declare a single {port_kind}, name one of them after the topic \
380 (`{}`), or use `topics:` with an explicit `{port_kind}:`",
381 declared
382 .iter()
383 .map(|port| format!("`{port}`"))
384 .collect::<Vec<_>>()
385 .join(", "),
386 derive_port_id(topic),
387 );
388 };
389 let (output, input) = match config.direction {
390 Ros2Direction::Subscribe => (Some(port), None),
391 Ros2Direction::Publish => (None, Some(port)),
392 };
393 let mut resolved = config.clone();
394 resolved.topic = None;
395 resolved.message_type = None;
396 resolved.topics = Some(vec![Ros2TopicConfig {
397 topic: topic.clone(),
398 message_type: message_type.clone(),
399 direction: config.direction.clone(),
400 output,
401 input,
402 qos: None,
405 }]);
406 Ok(Cow::Owned(resolved))
407}
408
409pub(crate) fn validate_ros2_topic_ports(
429 node_id: &NodeId,
430 topics: &[Ros2TopicConfig],
431 node_inputs: &BTreeMap<DataId, Input>,
432 node_outputs: &BTreeSet<DataId>,
433) -> eyre::Result<()> {
434 for t in topics {
435 match &t.direction {
436 Ros2Direction::Subscribe => {
437 let output = t.output_port_id();
438 if !node_outputs.contains(output.as_str()) {
439 bail!(
440 "node `{node_id}`: ros2 subscribe topic `{}` maps to output \
441 `{output}`, which is not declared in the node's `outputs`",
442 t.topic
443 );
444 }
445 }
446 Ros2Direction::Publish => {
447 let input = t.input_port_id();
448 if !node_inputs.contains_key(input.as_str()) {
449 bail!(
450 "node `{node_id}`: ros2 publish topic `{}` maps to input \
451 `{input}`, which is not declared in the node's `inputs`",
452 t.topic
453 );
454 }
455 }
456 }
457 }
458 Ok(())
459}
460
461impl DescriptorExt for Descriptor {
462 fn resolve_aliases_and_set_defaults(&self) -> eyre::Result<BTreeMap<NodeId, ResolvedNode>> {
463 resolve_aliases_and_set_defaults_in_topology(self, &[])
464 }
465
466 fn visualize_as_mermaid_with_boundaries(
467 &self,
468 boundaries: &ModuleBoundaries,
469 ) -> eyre::Result<String> {
470 let resolved = self.resolve_aliases_and_set_defaults()?;
471 let flowchart = visualize::visualize_nodes_with_boundaries(&resolved, boundaries);
472 Ok(flowchart)
473 }
474
475 fn apply_exit_when_nodes_finish(&mut self, over: Option<bool>) {
476 if let Some(over) = over {
477 self.exit_when_nodes_finish = Some(over);
478 }
479 }
480
481 fn blocking_read(path: &Path) -> eyre::Result<Descriptor> {
482 let buf = std::fs::read(path).context("failed to open given file")?;
483 Descriptor::parse(buf)
484 }
485
486 fn parse(buf: Vec<u8>) -> eyre::Result<Descriptor> {
487 serde_yaml::from_slice(&buf).context("failed to parse given descriptor")
488 }
489
490 fn check(&self, working_dir: &Path) -> eyre::Result<()> {
491 let expanded = self.expand(working_dir)?;
492 validate::check_dataflow(&expanded, working_dir)
493 .wrap_err("Dataflow could not be validated.")
494 }
495
496 fn expand(&self, working_dir: &Path) -> eyre::Result<Descriptor> {
497 expand::expand_modules(self, working_dir)
498 }
499
500 fn expand_with_boundaries(
501 &self,
502 working_dir: &Path,
503 ) -> eyre::Result<(Descriptor, ModuleBoundaries)> {
504 let expanded = expand::expand_modules_with_boundaries(self, working_dir)?;
505 Ok((expanded.descriptor, expanded.boundaries))
506 }
507}
508
509fn merge_env(
516 global: Option<&BTreeMap<String, EnvValue>>,
517 node: Option<BTreeMap<String, EnvValue>>,
518) -> Option<BTreeMap<String, EnvValue>> {
519 let merged = match (global, node) {
520 (None, None) => return None,
521 (None, Some(node)) => node,
522 (Some(global), None) => global.clone(),
523 (Some(global), Some(node)) => {
524 let mut merged = global.clone();
525 merged.extend(node);
527 merged
528 }
529 };
530 (!merged.is_empty()).then_some(merged)
532}
533
534pub async fn read_as_descriptor(path: &Path) -> eyre::Result<Descriptor> {
540 let buf = tokio::fs::read(path)
541 .await
542 .context("failed to open given file")?;
543 Descriptor::parse(buf)
544}
545
546pub fn source_is_url(source: &str) -> bool {
567 source.starts_with("https://") || source.starts_with("http://")
568}
569
570pub fn resolve_path(source: &str, working_dir: &Path) -> Result<PathBuf> {
586 let path = Path::new(&source);
587 let path = if path.extension().is_none() {
588 path.with_extension(EXE_EXTENSION)
589 } else {
590 path.to_owned()
591 };
592
593 let joined = working_dir.join(&path);
595 if joined.exists() {
596 absolutize_preserving_symlinks(&joined)
597 } else if which::which("uv").is_ok() {
600 resolve_path_via_uv(&path)
601 } else if let Ok(abs_path) = which::which(&path) {
602 Ok(abs_path)
603 } else {
604 bail!("Could not find source path {}", path.display())
605 }
606}
607
608pub fn resolve_path_confined(
616 source: &str,
617 working_dir: &Path,
618 python_env_dir: Option<&Path>,
619) -> Result<PathBuf> {
620 let path = Path::new(&source);
621 let path = if path.extension().is_none() {
622 path.with_extension(EXE_EXTENSION)
623 } else {
624 path.to_owned()
625 };
626
627 if let Some(env_dir) = python_env_dir {
629 let bin_dir = env_dir.join(if cfg!(windows) { "Scripts" } else { "bin" });
630 let candidate = bin_dir.join(&path);
631 if candidate.is_file() {
632 return confine(&candidate, &bin_dir);
633 }
634 }
635
636 let candidate = working_dir.join(&path);
638 if candidate.exists() {
639 return confine(&candidate, working_dir);
640 }
641
642 bail!(
643 "could not find `{}` in the node's working directory `{}`{} — \
644 hub nodes resolve only within their own package (no $PATH fallback)",
645 path.display(),
646 working_dir.display(),
647 python_env_dir
648 .map(|env| format!(" or its managed environment `{}`", env.display()))
649 .unwrap_or_default(),
650 )
651}
652
653fn confine(candidate: &Path, root: &Path) -> Result<PathBuf> {
655 let resolved = candidate
656 .canonicalize()
657 .with_context(|| format!("failed to canonicalize `{}`", candidate.display()))?;
658 let root = root
659 .canonicalize()
660 .with_context(|| format!("failed to canonicalize `{}`", root.display()))?;
661 if !resolved.starts_with(&root) {
662 bail!(
663 "entrypoint `{}` resolves outside the node's directory `{}` \
664 (symlink escape?) — refusing to run it",
665 resolved.display(),
666 root.display()
667 );
668 }
669 Ok(resolved)
670}
671
672fn absolutize_preserving_symlinks(path: &Path) -> Result<PathBuf> {
688 if !path.exists() {
689 bail!("path {} does not exist", path.display());
690 }
691 std::path::absolute(path)
692 .with_context(|| format!("failed to make path {} absolute", path.display()))
693}
694
695fn resolve_path_via_uv(path: &Path) -> Result<PathBuf> {
704 let which = if cfg!(windows) { "where" } else { "which" };
705 let output = Command::new("uv")
706 .arg("run")
707 .arg(which)
708 .arg(path)
709 .output()
710 .with_context(|| format!("failed to run `uv run {which}`"))?;
711 if !output.status.success() {
712 bail!("Could not find source path {} within uv", path.display());
713 }
714 let stdout = String::from_utf8_lossy(&output.stdout);
717 let resolved = stdout
718 .lines()
719 .map(str::trim)
720 .find(|line| !line.is_empty())
721 .ok_or_else(|| eyre::eyre!("`uv run {which} {}` produced no output", path.display()))?;
722 absolutize_preserving_symlinks(Path::new(resolved))
723 .with_context(|| format!("uv-resolved path {resolved} is not usable"))
724}
725
726pub fn canonicalize_working_dir(
734 override_: Option<&Path>,
735 dataflow_path: &Path,
736) -> eyre::Result<PathBuf> {
737 match override_ {
738 Some(p) => dunce::canonicalize(p)
739 .with_context(|| format!("failed to canonicalize working_dir `{}`", p.display())),
740 None => {
741 let parent = dataflow_path
742 .parent()
743 .filter(|p| !p.as_os_str().is_empty())
744 .unwrap_or_else(|| Path::new("."));
745 dunce::canonicalize(parent).with_context(|| {
746 format!(
747 "failed to canonicalize dataflow parent directory `{}`",
748 parent.display()
749 )
750 })
751 }
752 }
753}
754
755pub trait NodeExt {
758 fn kind(&self) -> eyre::Result<NodeKind<'_>>;
766}
767
768impl NodeExt for Node {
769 fn kind(&self) -> eyre::Result<NodeKind<'_>> {
770 if self.hub.is_some() && self.path.is_none() {
771 eyre::bail!(
776 "node `{}` uses an unresolved `hub:` reference — run `dora build` \
777 first (`dora start` requires a prior build for hub nodes)",
778 self.id
779 );
780 }
781 match (
782 &self.path,
783 &self.operators,
784 &self.operator,
785 &self.ros2,
786 &self.module,
787 ) {
788 (None, None, None, None, None) => {
789 eyre::bail!(
790 "node `{}` requires a `path`, `operators`, `operator`, `ros2`, or `module` field",
791 self.id
792 )
793 }
794 (None, None, Some(operator), None, None) => Ok(NodeKind::Operator(operator)),
795 (None, Some(runtime), None, None, None) => Ok(NodeKind::Runtime(runtime)),
796 (Some(path), None, None, None, None) => Ok(NodeKind::Standard(path)),
797 (None, None, None, Some(ros2), None) => Ok(NodeKind::Ros2Bridge(ros2)),
798 (None, None, None, None, Some(module)) => Ok(NodeKind::Module(module)),
799 _ => {
800 eyre::bail!(
801 "node `{}` has multiple exclusive fields set, only one of `path`, `operators`, `operator`, `ros2`, and `module` is allowed",
802 self.id
803 )
804 }
805 }
806 }
807}
808
809#[derive(Debug)]
813pub enum NodeKind<'a> {
814 Standard(&'a String),
816 Runtime(&'a RuntimeNode),
818 Operator(&'a SingleOperatorDefinition),
820 Ros2Bridge(&'a Ros2BridgeConfig),
822 Module(&'a String),
824}
825
826#[cfg(test)]
827mod tests {
828 fn resolved_bridge_config(yaml: &str) -> Ros2BridgeConfig {
830 let descriptor: Descriptor = serde_yaml::from_str(yaml).expect("parse");
831 let resolved = descriptor
832 .resolve_aliases_and_set_defaults()
833 .expect("resolve");
834 let node = resolved.values().next().expect("one node");
835 let CoreNodeKind::Custom(custom) = &node.kind else {
836 panic!("ros2 bridge must resolve to a custom node");
837 };
838 let Some(EnvValue::String(json)) = custom
839 .envs
840 .as_ref()
841 .and_then(|envs| envs.get("DORA_ROS2_BRIDGE_CONFIG"))
842 else {
843 panic!("bridge node must carry DORA_ROS2_BRIDGE_CONFIG");
844 };
845 serde_json::from_str(json).expect("bridge config round-trips")
846 }
847
848 #[test]
852 fn single_topic_subscribe_binds_the_declared_output() {
853 let config = resolved_bridge_config(
854 "\
855nodes:
856 - id: pose_bridge
857 ros2:
858 topic: /turtle1/pose
859 message_type: turtlesim/Pose
860 direction: subscribe
861 outputs:
862 - pose
863",
864 );
865 let topics = config
866 .topics
867 .expect("single topic is resolved to a mapping");
868 assert_eq!(topics.len(), 1);
869 assert_eq!(topics[0].topic, "/turtle1/pose");
870 assert_eq!(topics[0].output.as_deref(), Some("pose"));
871 assert_eq!(config.topic, None, "the unresolved form must not survive");
872 }
873
874 #[test]
875 fn single_topic_publish_binds_the_declared_input() {
876 let config = resolved_bridge_config(
877 "\
878nodes:
879 - id: cmd_bridge
880 ros2:
881 topic: /turtle1/cmd_vel
882 message_type: geometry_msgs/Twist
883 direction: publish
884 inputs:
885 cmd_vel: planner/cmd_vel
886",
887 );
888 let topics = config
889 .topics
890 .expect("single topic is resolved to a mapping");
891 assert_eq!(topics[0].input.as_deref(), Some("cmd_vel"));
892 }
893
894 #[test]
896 fn multi_topic_config_is_left_alone() {
897 let config = resolved_bridge_config(
898 "\
899nodes:
900 - id: turtle_bridge
901 ros2:
902 topics:
903 - topic: /turtle1/pose
904 message_type: turtlesim/Pose
905 direction: subscribe
906 output: pose
907 outputs:
908 - pose
909",
910 );
911 let topics = config.topics.expect("topics survive resolution");
912 assert_eq!(topics[0].output.as_deref(), Some("pose"));
913 }
914
915 #[test]
920 fn ambiguous_single_topic_is_rejected_during_resolution() {
921 let descriptor: Descriptor = serde_yaml::from_str(
922 "\
923nodes:
924 - id: pose_bridge
925 ros2:
926 topic: /turtle1/pose
927 message_type: turtlesim/Pose
928 direction: subscribe
929 outputs:
930 - pose
931 - log
932",
933 )
934 .expect("parse");
935 let err = descriptor
936 .resolve_aliases_and_set_defaults()
937 .expect_err("an ambiguous port mapping must not resolve")
938 .to_string();
939 assert!(
940 err.contains("no unambiguous output") && err.contains("turtle1_pose"),
941 "error should explain the ambiguity, got: {err}"
942 );
943 }
944
945 #[test]
949 fn single_topic_rewrite_never_overwrites_an_explicit_topics_list() {
950 let config = resolved_bridge_config(
951 "\
952nodes:
953 - id: bridge
954 ros2:
955 topic: /turtle1/pose
956 message_type: turtlesim/Pose
957 direction: subscribe
958 topics:
959 - topic: /turtle1/other
960 message_type: turtlesim/Pose
961 direction: subscribe
962 output: pose
963 outputs:
964 - pose
965",
966 );
967 assert_eq!(config.topic.as_deref(), Some("/turtle1/pose"));
968 let topics = config.topics.expect("the explicit list survives");
969 assert_eq!(topics.len(), 1);
970 assert_eq!(topics[0].topic, "/turtle1/other");
971 }
972
973 #[test]
982 fn exit_when_nodes_finish_override_semantics() {
983 use super::DescriptorExt;
984
985 let parse = |yaml: &str| -> Descriptor { serde_yaml::from_str(yaml).expect("parse") };
986 let with_setting = "exit_when_nodes_finish: true\nnodes:\n - id: a\n path: ./a\n";
987 let without = "nodes:\n - id: a\n path: ./a\n";
988
989 let mut d = parse(with_setting);
991 d.apply_exit_when_nodes_finish(None);
992 assert_eq!(
993 d.exit_when_nodes_finish,
994 Some(true),
995 "omitting the flag must not silently disable a policy the \
996 dataflow file asked for"
997 );
998
999 let mut d = parse(without);
1000 d.apply_exit_when_nodes_finish(None);
1001 assert_eq!(d.exit_when_nodes_finish, None, "and must not invent one");
1002
1003 let mut d = parse(with_setting);
1005 d.apply_exit_when_nodes_finish(Some(false));
1006 assert_eq!(
1007 d.exit_when_nodes_finish,
1008 Some(false),
1009 "`--exit-when-nodes-finish=false` must be able to turn OFF a \
1010 policy the dataflow file turned on"
1011 );
1012
1013 let mut d = parse(without);
1014 d.apply_exit_when_nodes_finish(Some(true));
1015 assert_eq!(d.exit_when_nodes_finish, Some(true));
1016 }
1017
1018 use super::*;
1019 use dora_message::descriptor::{GitRepoRev, NodeSource};
1020 use std::collections::BTreeSet;
1021
1022 fn env(pairs: &[(&str, &str)]) -> BTreeMap<String, EnvValue> {
1023 pairs
1024 .iter()
1025 .map(|(k, v)| (k.to_string(), EnvValue::String(v.to_string())))
1026 .collect()
1027 }
1028
1029 #[test]
1030 fn merge_env_returns_none_when_both_absent() {
1031 assert!(merge_env(None, None).is_none());
1032 }
1033
1034 #[test]
1035 fn merge_env_keeps_per_node_when_no_global() {
1036 let node_env = env(&[("A", "1")]);
1037 let merged = merge_env(None, Some(node_env.clone())).unwrap();
1038 assert_eq!(merged, node_env);
1039 }
1040
1041 #[test]
1042 fn merge_env_keeps_global_when_no_per_node() {
1043 let global = env(&[("A", "1")]);
1044 let merged = merge_env(Some(&global), None).unwrap();
1045 assert_eq!(merged, global);
1046 }
1047
1048 #[test]
1049 fn merge_env_normalizes_empty_node_map_to_none() {
1050 assert!(merge_env(None, Some(env(&[]))).is_none());
1055 }
1056
1057 #[test]
1058 fn merge_env_normalizes_empty_global_and_node_maps_to_none() {
1059 assert!(merge_env(Some(&env(&[])), Some(env(&[]))).is_none());
1060 assert!(merge_env(Some(&env(&[])), None).is_none());
1061 }
1062
1063 #[test]
1064 fn merge_env_per_node_overrides_global_on_conflict() {
1065 let global = env(&[("A", "global"), ("B", "global")]);
1066 let node_env = env(&[("A", "node"), ("C", "node")]);
1067 let merged = merge_env(Some(&global), Some(node_env)).unwrap();
1068 assert_eq!(merged.get("A"), Some(&EnvValue::String("node".into())));
1069 assert_eq!(merged.get("B"), Some(&EnvValue::String("global".into())));
1070 assert_eq!(merged.get("C"), Some(&EnvValue::String("node".into())));
1071 }
1072
1073 fn resolved_input_mapping<'a>(
1074 resolved: &'a BTreeMap<NodeId, ResolvedNode>,
1075 node: &str,
1076 input: &str,
1077 ) -> &'a InputMapping {
1078 let node = resolved
1079 .get(&NodeId::from(node.to_string()))
1080 .expect("node resolved");
1081 let inputs = match &node.kind {
1082 CoreNodeKind::Custom(n) => &n.run_config.inputs,
1083 CoreNodeKind::Runtime(_) => panic!("expected custom node"),
1084 };
1085 &inputs
1086 .get(&DataId::from(input.to_string()))
1087 .expect("input present")
1088 .mapping
1089 }
1090
1091 #[test]
1092 fn add_node_prefixes_single_operator_producer_input_via_topology() {
1093 let topology: Descriptor = serde_yaml::from_str(
1100 "\
1101nodes:
1102 - id: producer
1103 operator:
1104 python: producer.py
1105 outputs:
1106 - result
1107",
1108 )
1109 .expect("parse topology");
1110
1111 let added: Descriptor = serde_yaml::from_str(
1112 "\
1113nodes:
1114 - id: consumer
1115 path: consumer
1116 inputs:
1117 reading: producer/result
1118",
1119 )
1120 .expect("parse added node");
1121
1122 let resolved = resolve_aliases_and_set_defaults_in_topology(&added, &topology.nodes)
1125 .expect("resolve in topology");
1126 match resolved_input_mapping(&resolved, "consumer", "reading") {
1127 InputMapping::User(m) => {
1128 assert_eq!(m.source, NodeId::from("producer".to_string()));
1129 assert_eq!(m.output, DataId::from("op/result".to_string()));
1130 }
1131 other => panic!("expected user mapping, got {other:?}"),
1132 }
1133
1134 let resolved_isolated = added
1139 .resolve_aliases_and_set_defaults()
1140 .expect("resolve isolated");
1141 match resolved_input_mapping(&resolved_isolated, "consumer", "reading") {
1142 InputMapping::User(m) => {
1143 assert_eq!(m.output, DataId::from("result".to_string()));
1144 }
1145 other => panic!("expected user mapping, got {other:?}"),
1146 }
1147 }
1148
1149 #[test]
1157 fn resolve_preserves_operator_subtree() {
1158 let desc: Descriptor = serde_yaml::from_str(
1159 "\
1160nodes:
1161 - id: runtime_node
1162 operators:
1163 - id: op_a
1164 python: a.py
1165 outputs:
1166 - out_a
1167 - id: operator_node
1168 operator:
1169 python: b.py
1170 outputs:
1171 - out_b
1172",
1173 )
1174 .expect("parse");
1175
1176 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
1177
1178 match &resolved[&NodeId::from("runtime_node".to_string())].kind {
1180 CoreNodeKind::Runtime(rt) => {
1181 let ids: Vec<_> = rt.operators.iter().map(|o| o.id.to_string()).collect();
1182 assert_eq!(ids, ["op_a"], "runtime operators must survive resolution");
1183 }
1184 other => panic!("expected Runtime kind, got {other:?}"),
1185 }
1186
1187 match &resolved[&NodeId::from("operator_node".to_string())].kind {
1190 CoreNodeKind::Runtime(rt) => {
1191 assert_eq!(
1192 rt.operators.len(),
1193 1,
1194 "the operator must survive resolution"
1195 );
1196 assert_eq!(rt.operators[0].id.to_string(), SINGLE_OPERATOR_DEFAULT_ID);
1197 }
1198 other => panic!("expected Runtime kind, got {other:?}"),
1199 }
1200 }
1201
1202 #[test]
1203 fn topology_lookup_prefers_the_node_being_added_over_a_same_id_topology_entry() {
1204 let topology: Descriptor = serde_yaml::from_str(
1210 "\
1211nodes:
1212 - id: producer
1213 operator:
1214 id: stale
1215 python: producer.py
1216 outputs:
1217 - result
1218",
1219 )
1220 .expect("parse topology");
1221
1222 let added: Descriptor = serde_yaml::from_str(
1223 "\
1224nodes:
1225 - id: producer
1226 operator:
1227 id: fresh
1228 python: producer.py
1229 outputs:
1230 - result
1231 - id: consumer
1232 path: consumer
1233 inputs:
1234 reading: producer/result
1235",
1236 )
1237 .expect("parse added nodes");
1238
1239 let resolved = resolve_aliases_and_set_defaults_in_topology(&added, &topology.nodes)
1240 .expect("resolve in topology");
1241 match resolved_input_mapping(&resolved, "consumer", "reading") {
1242 InputMapping::User(m) => {
1243 assert_eq!(m.output, DataId::from("fresh/result".to_string()));
1244 }
1245 other => panic!("expected user mapping, got {other:?}"),
1246 }
1247 }
1248
1249 #[test]
1250 fn descriptor_global_env_parses_from_yaml() {
1251 let yaml = r#"
1254env:
1255 RUST_LOG: info
1256 OTEL_ENDPOINT: http://collector:4317
1257nodes:
1258 - id: a
1259 path: ./a
1260 env:
1261 RUST_LOG: debug
1262 - id: b
1263 path: ./b
1264"#;
1265 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1266 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
1267
1268 let a = resolved.get(&NodeId::from("a".to_string())).unwrap();
1269 let a_env = a.env.as_ref().expect("node a inherits env");
1270 assert_eq!(
1272 a_env.get("RUST_LOG"),
1273 Some(&EnvValue::String("debug".into()))
1274 );
1275 assert_eq!(
1277 a_env.get("OTEL_ENDPOINT"),
1278 Some(&EnvValue::String("http://collector:4317".into()))
1279 );
1280
1281 let b = resolved.get(&NodeId::from("b".to_string())).unwrap();
1282 let b_env = b.env.as_ref().expect("node b inherits global env");
1283 assert_eq!(
1284 b_env.get("RUST_LOG"),
1285 Some(&EnvValue::String("info".into()))
1286 );
1287 assert_eq!(
1288 b_env.get("OTEL_ENDPOINT"),
1289 Some(&EnvValue::String("http://collector:4317".into()))
1290 );
1291 }
1292
1293 #[test]
1294 fn invalid_operator_id_prefix_errors_instead_of_panicking() {
1295 let yaml = r#"
1303nodes:
1304 - id: producer
1305 operator:
1306 id: "bad id"
1307 python: op.py
1308 outputs: [result]
1309 - id: consumer
1310 path: ./consumer
1311 inputs:
1312 x: producer/result
1313"#;
1314 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1315 let result = desc.resolve_aliases_and_set_defaults();
1316 assert!(result.is_err(), "expected a clean descriptor error, got Ok");
1317 }
1318
1319 #[test]
1320 fn resolve_path_errors_for_nonexistent_binary() {
1321 let working_dir = std::env::current_dir().expect("cwd");
1327 let result = resolve_path("dora_nonexistent_binary_2016_regression", &working_dir);
1328 assert!(
1329 result.is_err(),
1330 "expected Err for a binary that exists nowhere, got {result:?}"
1331 );
1332 }
1333
1334 #[test]
1344 #[cfg(unix)]
1345 fn resolve_path_preserves_symlinks() {
1346 let tmp = tempfile::tempdir().expect("tempdir");
1347 let target = tmp.path().join("base-interpreter.bin");
1348 std::fs::write(&target, b"x").unwrap();
1349 let link = tmp.path().join("venv-python.bin");
1350 std::os::unix::fs::symlink(&target, &link).unwrap();
1351
1352 let resolved = resolve_path("venv-python.bin", tmp.path()).unwrap();
1354 assert!(resolved.is_absolute());
1355 assert!(
1356 resolved.ends_with("venv-python.bin"),
1357 "resolve_path followed the symlink: {} — venv discovery \
1358 (pyvenv.cfg) is keyed off the symlink location, so execing \
1359 the target bypasses the venv",
1360 resolved.display()
1361 );
1362
1363 let resolved = resolve_path(link.to_str().unwrap(), Path::new("/")).unwrap();
1365 assert!(
1366 resolved.ends_with("venv-python.bin"),
1367 "absolute symlink path was canonicalized: {}",
1368 resolved.display()
1369 );
1370
1371 std::fs::create_dir(tmp.path().join("sub")).unwrap();
1376 let resolved = resolve_path("sub/../venv-python.bin", tmp.path()).unwrap();
1377 assert!(
1378 resolved.ends_with("sub/../venv-python.bin"),
1379 "`..` was normalized away: {}",
1380 resolved.display()
1381 );
1382 }
1383
1384 #[test]
1391 #[cfg(unix)]
1392 fn resolve_path_treats_dangling_symlink_as_missing() {
1393 let tmp = tempfile::tempdir().expect("tempdir");
1394 let link = tmp.path().join("dangling-2918-regression.bin");
1395 std::os::unix::fs::symlink(tmp.path().join("no-such-target"), &link).unwrap();
1396
1397 let result = resolve_path("dangling-2918-regression.bin", tmp.path());
1398 assert!(
1399 result.is_err(),
1400 "a dangling symlink must not resolve (nothing on uv/$PATH matches \
1401 this name either), got {result:?}"
1402 );
1403 }
1404
1405 #[test]
1406 fn resolve_path_confined_has_no_path_fallback() {
1407 let tmp = tempfile::tempdir().expect("tempdir");
1408 let result = resolve_path_confined("sh", tmp.path(), None);
1412 assert!(result.is_err(), "expected Err, got {result:?}");
1413
1414 let exe = if cfg!(windows) {
1416 "node.exe"
1417 } else {
1418 "node.bin"
1419 };
1420 std::fs::write(tmp.path().join(exe), b"x").unwrap();
1421 let resolved = resolve_path_confined(exe, tmp.path(), None).unwrap();
1422 assert!(resolved.is_absolute());
1423
1424 let env_dir = tmp.path().join("env");
1426 let bin_dir = env_dir.join(if cfg!(windows) { "Scripts" } else { "bin" });
1427 std::fs::create_dir_all(&bin_dir).unwrap();
1428 std::fs::write(bin_dir.join(exe), b"x").unwrap();
1429 let resolved =
1430 resolve_path_confined(exe, tmp.path().join("empty").as_path(), Some(&env_dir));
1431 assert!(resolved.is_ok(), "{resolved:?}");
1432 }
1433
1434 #[cfg(unix)]
1435 #[test]
1436 fn resolve_path_confined_rejects_symlink_escape() {
1437 let tmp = tempfile::tempdir().expect("tempdir");
1438 let working_dir = tmp.path().join("work");
1439 std::fs::create_dir_all(&working_dir).unwrap();
1440 let outside = tmp.path().join("outside.bin");
1441 std::fs::write(&outside, b"x").unwrap();
1442 std::os::unix::fs::symlink(&outside, working_dir.join("escape.bin")).unwrap();
1443 let result = resolve_path_confined("escape.bin", &working_dir, None);
1444 assert!(
1445 result.is_err(),
1446 "a symlink pointing outside the working dir must be rejected, got {result:?}"
1447 );
1448 let msg = format!("{:#}", result.unwrap_err());
1449 assert!(msg.contains("outside"), "{msg}");
1450 }
1451
1452 #[test]
1453 fn unresolved_hub_node_has_clear_kind_error() {
1454 let node: Node = serde_yaml::from_str("id: x\nhub: dora-yolo@^0.5\n").unwrap();
1455 let err = node.kind().unwrap_err();
1456 assert!(format!("{err}").contains("dora build"), "{err}");
1457 }
1458
1459 #[test]
1460 fn resolve_path_via_uv_errors_for_nonexistent_binary() {
1461 if which::which("uv").is_err() {
1469 return;
1470 }
1471 let path = Path::new("dora_nonexistent_binary_2016_regression");
1472 let result = resolve_path_via_uv(path);
1473 assert!(
1474 result.is_err(),
1475 "expected Err from `uv run which` for a missing binary, got {result:?}"
1476 );
1477 }
1478
1479 #[test]
1480 fn descriptor_without_global_env_preserves_per_node_env() {
1481 let yaml = r#"
1484nodes:
1485 - id: a
1486 path: ./a
1487 env:
1488 FOO: bar
1489 - id: b
1490 path: ./b
1491"#;
1492 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1493 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
1494 let a = resolved.get(&NodeId::from("a".to_string())).unwrap();
1495 assert_eq!(
1496 a.env.as_ref().and_then(|e| e.get("FOO")),
1497 Some(&EnvValue::String("bar".into()))
1498 );
1499 let b = resolved.get(&NodeId::from("b".to_string())).unwrap();
1500 assert!(b.env.is_none(), "node b has no env anywhere");
1501 }
1502
1503 #[test]
1504 fn duplicate_node_id_is_rejected() {
1505 let yaml = r#"
1508nodes:
1509 - id: my-node
1510 path: ./a
1511 - id: my-node
1512 path: ./b
1513"#;
1514 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1515 let err = desc
1516 .resolve_aliases_and_set_defaults()
1517 .expect_err("duplicate node ID must be rejected");
1518 let msg = format!("{err:#}");
1519 assert!(
1520 msg.contains("duplicate node ID") && msg.contains("my-node"),
1521 "unexpected error message: {msg}"
1522 );
1523 }
1524
1525 fn custom_node_field_names() -> BTreeSet<String> {
1532 let schema = schemars::schema_for!(dora_message::descriptor::CustomNode);
1533 let schema = serde_json::to_value(schema).expect("schema should serialize");
1534 schema
1535 .pointer("/$defs/CustomNode/properties")
1536 .or_else(|| schema.pointer("/definitions/CustomNode/properties"))
1537 .or_else(|| schema.pointer("/properties"))
1538 .and_then(serde_json::Value::as_object)
1539 .expect("CustomNode schema should expose properties")
1540 .keys()
1541 .cloned()
1542 .collect()
1543 }
1544
1545 const SHARED_CUSTOM_NODE_KEYS: &str = r#"
1549 args: --verbose
1550 send_stdout_as: stdout-topic
1551 send_logs_as: logs-topic
1552 min_log_level: debug
1553 max_log_size: 4MB
1554 max_rotated_files: 3
1555 restart_policy: always
1556 max_restarts: 7
1557 restart_delay: 1.5
1558 max_restart_delay: 9.5
1559 restart_window: 60.0
1560 health_check_timeout: 2.5
1561 startup_timeout: 4.5
1562 finish_grace_secs: 3.5
1563 shared_memory_pool_size: 8MB
1564 inputs:
1565 tick: dora/timer/millis/100
1566 outputs:
1567 - out
1568 output_types:
1569 out: arrow.int32
1570 output_framing:
1571 out: arrow-ipc
1572 input_types:
1573 tick: arrow.uint64
1574"#;
1575
1576 fn resolve_and_check_carried_through(yaml: &str, kind_specific: &[&str]) -> CustomNode {
1589 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1590 let declared = serde_json::to_value(&desc.nodes[0]).expect("serialize declared");
1591 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
1592 let node = resolved.values().next().expect("one node");
1593 let CoreNodeKind::Custom(custom) = &node.kind else {
1594 panic!("expected a custom node, got {:?}", node.kind);
1595 };
1596
1597 let actual = serde_json::to_value(custom).expect("serialize resolved");
1598 let default =
1601 serde_json::to_value(CustomNode::new("<unset>".to_owned())).expect("serialize default");
1602
1603 for field in custom_node_field_names() {
1604 if kind_specific.contains(&field.as_str()) {
1605 continue;
1606 }
1607 assert_ne!(
1608 actual.get(&field),
1609 default.get(&field),
1610 "`{field}` is still at its `CustomNode::new` default after \
1611 resolution — either the YAML does not set it (add it to \
1612 `SHARED_CUSTOM_NODE_KEYS`) or the key is parsed and then \
1613 dropped (carry it in `CustomNode::from_node`)."
1614 );
1615 assert_eq!(
1616 actual.get(&field),
1617 declared.get(&field),
1618 "`{field}` resolved to a different value than the YAML declared \
1619 — `CustomNode::from_node` copies it from the wrong `Node` field."
1620 );
1621 }
1622 custom.clone()
1623 }
1624
1625 #[test]
1630 fn every_custom_node_field_is_carried_through() {
1631 let yaml = format!(
1632 "nodes:\n - id: full\n path: ./full-node\n path_sha256: abc123\n \
1633 build: cargo build{SHARED_CUSTOM_NODE_KEYS}"
1634 );
1635 let custom = resolve_and_check_carried_through(&yaml, &["source", "envs"]);
1636 assert!(
1637 matches!(custom.source, NodeSource::Local),
1638 "{:?}",
1639 custom.source
1640 );
1641 assert!(custom.envs.is_none(), "{:?}", custom.envs);
1642 }
1643
1644 #[test]
1648 fn git_source_is_carried_through() {
1649 let yaml = format!(
1650 "nodes:\n - id: full\n path: node\n path_sha256: abc123\n \
1651 build: cargo build\n git: https://github.com/example/node.git\n \
1652 branch: main{SHARED_CUSTOM_NODE_KEYS}"
1653 );
1654 let custom = resolve_and_check_carried_through(&yaml, &["source", "envs"]);
1655 assert!(
1656 matches!(
1657 &custom.source,
1658 NodeSource::GitBranch { repo, rev: Some(GitRepoRev::Branch(branch)) }
1659 if repo == "https://github.com/example/node.git" && branch == "main"
1660 ),
1661 "{:?}",
1662 custom.source
1663 );
1664 assert!(custom.envs.is_none(), "{:?}", custom.envs);
1665 }
1666
1667 #[test]
1672 fn ros2_bridge_node_is_carried_through() {
1673 let yaml = format!(
1674 "nodes:\n - id: bridge\n ros2:\n topic: /odom\n \
1675 message_type: nav_msgs/msg/Odometry\n direction: subscribe\
1676 {SHARED_CUSTOM_NODE_KEYS}"
1677 );
1678 let custom = resolve_and_check_carried_through(
1679 &yaml,
1680 &["path", "source", "path_sha256", "build", "envs"],
1681 );
1682 assert_eq!(custom.path, "dora-ros2-bridge-node");
1683 assert!(
1684 matches!(custom.source, NodeSource::Local),
1685 "{:?}",
1686 custom.source
1687 );
1688 assert!(custom.path_sha256.is_none(), "{:?}", custom.path_sha256);
1689 assert!(custom.build.is_none(), "{:?}", custom.build);
1690 let envs = custom
1691 .envs
1692 .expect("the bridge is configured through its environment");
1693 let Some(EnvValue::String(config)) = envs.get("DORA_ROS2_BRIDGE_CONFIG") else {
1694 panic!("DORA_ROS2_BRIDGE_CONFIG missing from {envs:?}");
1695 };
1696 assert!(config.contains("/odom"), "{config}");
1697 }
1698
1699 #[test]
1706 fn node_level_keys_are_carried_through() {
1707 let yaml = r#"
1708env:
1709 RUST_LOG: info
1710 SHARED: global
1711nodes:
1712 - id: full
1713 name: Full Node
1714 description: Sets every node-level key
1715 path: ./full-node
1716 env:
1717 SHARED: per-node
1718 cpu_affinity: [0, 1]
1719 deploy:
1720 machine: gpu-box
1721"#;
1722 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1723 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
1724 let node = resolved.values().next().expect("one node");
1725
1726 assert_eq!(node.id.to_string(), "full");
1727 assert_eq!(node.name.as_deref(), Some("Full Node"));
1728 assert_eq!(
1729 node.description.as_deref(),
1730 Some("Sets every node-level key")
1731 );
1732 assert_eq!(
1733 node.env,
1734 Some(env(&[("RUST_LOG", "info"), ("SHARED", "per-node")]))
1735 );
1736 assert_eq!(node.cpu_affinity, Some(vec![0, 1]));
1737 assert_eq!(
1738 node.deploy.as_ref().and_then(|d| d.machine.as_deref()),
1739 Some("gpu-box")
1740 );
1741 }
1742
1743 #[test]
1750 fn runtime_node_subgraph_id_is_sanitized_for_dotted_ids() {
1751 let yaml = r#"
1752nodes:
1753 - id: camera.front
1754 operators:
1755 - id: detect
1756 python: detect.py
1757 inputs:
1758 tick: dora/timer/millis/100
1759 outputs:
1760 - bbox
1761 - id: track
1762 python: track.py
1763 inputs:
1764 bbox: camera.front/detect/bbox
1765 outputs:
1766 - tracks
1767"#;
1768 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1769 let resolved = desc.resolve_aliases_and_set_defaults().expect("resolve");
1770 let flowchart =
1771 visualize::visualize_nodes_with_boundaries(&resolved, &ModuleBoundaries::default());
1772
1773 assert!(
1774 flowchart.contains("subgraph camera_front [camera.front]"),
1775 "runtime-node subgraph id must be sanitized and labelled; got:\n{flowchart}"
1776 );
1777 assert!(
1778 !flowchart.contains("subgraph camera.front"),
1779 "an unsanitized dotted subgraph id is invalid Mermaid; got:\n{flowchart}"
1780 );
1781 }
1782
1783 #[test]
1789 fn resolve_rejects_undeclared_multi_topic_subscribe_output() {
1790 let yaml = r#"
1791nodes:
1792 - id: lidar_bridge
1793 outputs: [scan]
1794 ros2:
1795 topics:
1796 - topic: /scan
1797 message_type: sensor_msgs/LaserScan
1798 direction: subscribe
1799 output: scna
1800"#;
1801 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1802 let err = desc
1803 .resolve_aliases_and_set_defaults()
1804 .expect_err("resolution must reject a topic mapped to an undeclared output")
1805 .to_string();
1806 assert!(
1807 err.contains("scna") && err.contains("not declared"),
1808 "error should name the undeclared output, got: {err}"
1809 );
1810 }
1811
1812 #[test]
1815 fn resolve_rejects_undeclared_multi_topic_publish_input() {
1816 let yaml = r#"
1817nodes:
1818 - id: source
1819 path: ./source
1820 outputs: [cmd]
1821 - id: cmd_bridge
1822 inputs:
1823 cmd: source/cmd
1824 ros2:
1825 topics:
1826 - topic: /cmd_vel
1827 message_type: geometry_msgs/Twist
1828 direction: publish
1829 input: typo_in
1830"#;
1831 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1832 let err = desc
1833 .resolve_aliases_and_set_defaults()
1834 .expect_err("resolution must reject a topic mapped to an undeclared input")
1835 .to_string();
1836 assert!(
1837 err.contains("typo_in") && err.contains("not declared"),
1838 "error should name the undeclared input, got: {err}"
1839 );
1840 }
1841
1842 #[test]
1845 fn resolve_accepts_declared_multi_topic_ports() {
1846 let yaml = r#"
1847nodes:
1848 - id: bridge
1849 outputs: [scan, laser]
1850 ros2:
1851 topics:
1852 - topic: /scan
1853 message_type: sensor_msgs/LaserScan
1854 direction: subscribe
1855 output: scan
1856 - topic: /laser
1857 message_type: sensor_msgs/LaserScan
1858 direction: subscribe
1859"#;
1860 let desc: Descriptor = serde_yaml::from_str(yaml).expect("parse");
1861 desc.resolve_aliases_and_set_defaults().expect(
1862 "a multi-topic config mapping declared ports (explicit and derived) must resolve",
1863 );
1864 }
1865
1866 #[test]
1873 fn node_kind_error_lists_the_operator_field() {
1874 let descriptor: Descriptor =
1875 serde_yaml::from_str("nodes:\n - id: lonely\n").expect("parse");
1876 let node = descriptor.nodes.first().expect("one node");
1877 let err = node
1878 .kind()
1879 .expect_err("a node with no implementation field must be rejected");
1880 let msg = format!("{err}");
1881 assert!(
1882 msg.contains("`operator`"),
1883 "the error must list the single-`operator` kind, got: {msg}"
1884 );
1885 }
1886}