Skip to main content

dora_core/descriptor/
mod.rs

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
15// reexport for compatibility
16pub 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;
26/// Lexically normalize a path (collapse `.` and resolve `..`) without touching
27/// the filesystem — the executable may not be built yet when this is called.
28///
29/// Used to sanitize untrusted node paths before a containment check, so the
30/// two callers (module expansion and manifest injection) must collapse `..`
31/// identically; keeping a single implementation prevents them from drifting.
32pub(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
55/// Operations on a parsed [`Descriptor`].
56///
57/// These live on an extension trait rather than inherent methods because
58/// [`Descriptor`] is defined in `dora-message` (the wire-format crate), while
59/// the parsing, validation, resolution, and visualization logic belongs to
60/// `dora-core`. Import the trait to turn a descriptor into the resolved node
61/// map dora runs, validate it, or render it as a mermaid graph.
62///
63/// # Example
64///
65/// ```
66/// use dora_core::descriptor::{Descriptor, DescriptorExt};
67///
68/// let yaml = b"
69/// nodes:
70///   - id: source
71///     path: ./source
72///     outputs: [data]
73///   - id: sink
74///     path: ./sink
75///     inputs:
76///       value: source/data
77/// ";
78///
79/// // Parse the YAML into a descriptor...
80/// let descriptor = Descriptor::parse(yaml.to_vec())?;
81/// assert_eq!(descriptor.nodes.len(), 2);
82///
83/// // ...then resolve aliases and fill in defaults to get the node map dora runs.
84/// let resolved = descriptor.resolve_aliases_and_set_defaults()?;
85/// assert_eq!(resolved.len(), 2);
86/// # Ok::<(), eyre::Report>(())
87/// ```
88pub trait DescriptorExt {
89    /// Resolve the descriptor into the map of nodes dora runs.
90    ///
91    /// Fills in defaults, rewrites single-operator input references to the
92    /// operator-qualified output name, and returns the nodes keyed by id. The
93    /// descriptor must already be module-expanded (see [`expand`](Self::expand))
94    /// if it contains `module:` references.
95    fn resolve_aliases_and_set_defaults(&self) -> eyre::Result<BTreeMap<NodeId, ResolvedNode>>;
96    /// Render the resolved dataflow as a mermaid `flowchart`, annotated with the
97    /// given module `boundaries` (from [`expand_with_boundaries`](Self::expand_with_boundaries)).
98    fn visualize_as_mermaid_with_boundaries(
99        &self,
100        boundaries: &ModuleBoundaries,
101    ) -> eyre::Result<String>;
102    /// Apply a command-line override to the dataflow's completion policy.
103    ///
104    /// `None` leaves the descriptor's own `exit_when_nodes_finish`
105    /// alone, so a setting written in the YAML stands; `Some(v)`
106    /// overrides it in either direction, so `--exit-when-nodes-finish`
107    /// can force the policy on and `=false` can force it off for a
108    /// descriptor that asks for it (dora-rs/dora#2920).
109    ///
110    /// Shared rather than spelled out at each call site: `dora run`,
111    /// `dora start` and the daemon all have to agree, and three copies
112    /// of the same three lines is how one of them ends up not being
113    /// updated.
114    fn apply_exit_when_nodes_finish(&mut self, over: Option<bool>);
115
116    /// Read a descriptor from a YAML file at `path` and [`parse`](Self::parse) it.
117    fn blocking_read(path: &Path) -> eyre::Result<Descriptor>;
118    /// Parse a descriptor from raw YAML bytes.
119    ///
120    /// This only deserializes; it does not expand modules or validate the
121    /// dataflow — use [`check`](Self::check) for that.
122    fn parse(buf: Vec<u8>) -> eyre::Result<Descriptor>;
123    /// Expand modules and validate the whole dataflow (node ids, input wiring,
124    /// field combinations, …), resolving relative paths against `working_dir`.
125    fn check(&self, working_dir: &Path) -> eyre::Result<()>;
126    /// Expand all module references into flat nodes.
127    ///
128    /// Module nodes are replaced by the inner nodes defined in their module
129    /// file. Internal IDs are prefixed with `{module_id}.` and input/output
130    /// wiring is rewritten accordingly.
131    fn expand(&self, working_dir: &Path) -> eyre::Result<Descriptor>;
132    /// Like [`expand`](Self::expand) but also returns module boundary metadata
133    /// for visualization.
134    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
142/// Prefixes a single-operator node's output id with the operator id
143/// (e.g. `result` -> `op/result`) so downstream references resolve to the
144/// operator-qualified output.
145///
146/// Operator ids are *not* validated against the `DataId` character set when a
147/// descriptor is parsed (`OperatorId` stores its string verbatim), so build the
148/// qualified id fallibly: `DataId::from` panics on characters outside
149/// `[a-zA-Z0-9_./-]`, which would abort `dora check`/`graph`/`build` on an
150/// otherwise-parseable descriptor. Returning an `Err` surfaces it as a clean
151/// descriptor error instead.
152fn 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
162/// Like [`DescriptorExt::resolve_aliases_and_set_defaults`], but resolves
163/// `desc` as a node (or nodes) being added to an already-running dataflow
164/// whose current node set is `topology_nodes`.
165///
166/// Whole-descriptor resolution rewrites an input that references a
167/// single-`operator:` producer from the bare output name to the
168/// operator-qualified one (`result` -> `op/result`). The dynamic-topology
169/// `AddNode` path resolves the new node in isolation, so that producer is not
170/// present in the descriptor being resolved and the rewrite is skipped —
171/// leaving the added node subscribed to an output name nobody publishes
172/// (silent data loss, #2877). Supplying the surrounding `topology_nodes` makes
173/// those producers visible so the prefixing is applied.
174///
175/// `topology_nodes` contribute *only* to the single-operator output-prefixing
176/// lookup — they are never themselves resolved or emitted, and nothing else on
177/// the surrounding descriptor (`env`, `deploy`, …) is consulted. Callers that
178/// want the running dataflow's `env` merged in must set it on `desc` (the
179/// `AddNode` handler does, see #2919). `desc`'s own nodes take precedence in
180/// the lookup, so a node id present in both resolves against the copy being
181/// added.
182pub 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        // classify node: determine kind + validate fields against whitelist
201        let node_class = classify::classify(&node)?;
202
203        // adjust ROS2 bridge input mappings early
204        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        // adjust input mappings
215        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        // resolve nodes. The two custom-node arms drain the custom-node keys
248        // out of `node` with `CustomNode::from_node`; the node-level keys stay
249        // behind for `ResolvedNode::from_node` below.
250        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                // `node` is already an owned copy (see `desc.nodes.clone()`
259                // above) and `ResolvedNode::from_node` never reads
260                // `operators`, so move the operator subtree out instead of
261                // deep-cloning it a second time.
262                let runtime = node.operators.take().ok_or_eyre("no operators")?;
263                CoreNodeKind::Runtime(runtime)
264            }
265            classify::NodeClass::Operator => {
266                // Move the operator out of the owned `node` rather than
267                // cloning its (potentially large) config; `from_node` does
268                // not read `operator`.
269                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                // `resolve_ros2_single_topic` checks single-topic port mappings
282                // and rewrites them into a one-entry `topics:` list; a config
283                // that already uses `topics:` passes through unchanged. Either
284                // way, verify every resolved topic maps to a declared port here
285                // on the resolution path, so the coordinator's `dora start` path
286                // — which resolves without running the validator — rejects an
287                // undeclared multi-topic mapping instead of silently dropping
288                // every message at runtime (dora-rs/dora#3484). Service/action
289                // configs leave `topics` unset and are skipped.
290                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                // The bridge binary is fixed, so `path` is a constant and
303                // `source` stays at its default. `path_sha256` and `build` are
304                // not accepted on a `ros2:` node (classification rejects them),
305                // so `from_node` finds them unset.
306                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        // Merge the dataflow-level `env` into the per-node `env`. Per-node keys
321        // win on conflict so a node can override a shared default (e.g. global
322        // `RUST_LOG=info` with one verbose node setting `RUST_LOG=debug`).
323        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
330/// Fill a single-topic (`topic:`) ros2 bridge config's port mapping in, by
331/// rewriting it into the equivalent one-entry `topics:` form.
332///
333/// Single-topic mode has no `output:`/`input:` field, so without this the
334/// bridge falls back to the topic-derived id and binds to a port the node never
335/// declared — `/turtle1/pose` to `turtle1_pose` rather than to the documented
336/// `pose` (`docs/ros2-bridge.md`: "the node's declared `outputs` or `inputs` are
337/// used directly"), silently dropping every message.
338///
339/// This is also where an unresolvable mapping is *reported*, rather than in
340/// [`validate`] alone: `dora run` and `dora check` validate first, but the
341/// coordinator's `dora start` path resolves without validating, and there the
342/// only alternative to an error is the silent drop.
343pub(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    // A config that also sets `topics:` is rejected by the bridge itself
350    // ("exactly one of `topic` or `topics`"); overwriting the list here would
351    // turn that loud failure into a silent one.
352    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        // Per-topic QoS unset, so the topic inherits the bridge-level `qos`
403        // the single-topic form already carries.
404        qos: None,
405    }]);
406    Ok(Cow::Owned(resolved))
407}
408
409/// Verify that every entry of a multi-topic (`topics:`) ros2 bridge config maps
410/// to a port the node actually declares.
411///
412/// The bridge routes each topic to a dora port: a subscribe topic feeds an
413/// output, a publish topic consumes an input. When the mapping is not set
414/// explicitly the id is derived from the topic name
415/// ([`Ros2TopicConfig::output_port_id`] / [`input_port_id`]). Either way, the
416/// resulting id must be a declared port — otherwise data is silently dropped at
417/// runtime with no diagnostic: a subscribe `send_output` to an unknown id is
418/// ignored, and a publish topic bound to an unknown input never receives any
419/// data to publish.
420///
421/// Like [`resolve_ros2_single_topic`], this lives on the *resolution* path (and
422/// is delegated to from [`validate`]) rather than in the validator alone: `dora
423/// run` and `dora check` validate first, but the coordinator's `dora start` path
424/// resolves without validating, so a validator-only guard would still drop
425/// silently there (dora-rs/dora#3484).
426///
427/// [`input_port_id`]: Ros2TopicConfig::input_port_id
428pub(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
509/// Merge dataflow-level `env` into a node's `env`, with per-node keys winning
510/// on conflict. Returns `None` when the merged result is empty so the resolved
511/// node serializes cleanly (no empty `env: {}` map) whenever no env vars are
512/// effectively set — regardless of whether the emptiness comes from the global
513/// map, the node map, or both (e.g. a node that declares `env: {}` with no
514/// dataflow-level env).
515fn 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            // Per-node entries override global ones on key conflict.
526            merged.extend(node);
527            merged
528        }
529    };
530    // Normalize an empty result to `None` regardless of which side was empty.
531    (!merged.is_empty()).then_some(merged)
532}
533
534/// Async counterpart of [`DescriptorExt::blocking_read`]: read a descriptor
535/// from the YAML file at `path` and [`parse`](DescriptorExt::parse) it.
536///
537/// Like `blocking_read`, this only reads and deserializes — it neither expands
538/// modules nor validates the dataflow (use [`DescriptorExt::check`] for that).
539pub 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
546/// Returns `true` if `source` is an `http://` or `https://` URL.
547///
548/// This is the trust boundary that decides whether a node `path` is fetched as
549/// a remote download (and, for hub artifacts, checksum-verified) versus
550/// resolved as a local filesystem path. The match is on the literal scheme
551/// prefix and is **case-sensitive**: an upper-cased scheme such as `HTTPS://`
552/// is treated as a path, not a URL. Schemes other than HTTP(S) (e.g. `ftp://`,
553/// `s3://`, `file://`) are likewise not considered URLs here.
554///
555/// ```
556/// use dora_core::descriptor::source_is_url;
557///
558/// assert!(source_is_url("https://example.com/node"));
559/// assert!(source_is_url("http://example.com/node"));
560///
561/// assert!(!source_is_url("./build/my_node"));
562/// assert!(!source_is_url("/usr/bin/my_node"));
563/// assert!(!source_is_url("s3://bucket/key"));
564/// assert!(!source_is_url("HTTPS://example.com/node")); // case-sensitive
565/// ```
566pub fn source_is_url(source: &str) -> bool {
567    source.starts_with("https://") || source.starts_with("http://")
568}
569
570/// Resolve a node's executable `source` to an absolute path.
571///
572/// An extensionless `source` gets the platform executable extension. The path
573/// is then searched, in order:
574///
575/// 1. under `working_dir` (the dataflow's directory),
576/// 2. the `uv`-managed environment, when `uv` is on the host,
577/// 3. the ambient system `$PATH`.
578///
579/// The resolved path is absolutized but symlinks are **not** resolved (the
580/// binary is spawned at the path it was found). Unlike
581/// [`resolve_path_confined`], this has an ambient-`$PATH` fallback and does not
582/// confine the result to any root — it is the resolution used for local,
583/// trusted dataflow files, whereas confined resolution is for untrusted `hub:`
584/// nodes.
585pub 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    // Search path within current working directory.
594    let joined = working_dir.join(&path);
595    if joined.exists() {
596        absolutize_preserving_symlinks(&joined)
597    // Otherwise resolve against the `uv`-managed environment first (when `uv`
598    // is available), then fall back to the system `$PATH`.
599    } 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
608/// Resolve a hub node's entrypoint under **confined** rules (spec §11): the
609/// executable may only come from the node's own working directory
610/// (`<clone>/<subdir>`) or its managed Python environment. There is no
611/// ambient-`$PATH` fallback — a typo or missing console script fails loudly
612/// instead of silently running a host binary — and the resolved path is
613/// canonicalized and checked to stay inside those roots, so a symlink
614/// escaping the working dir is rejected rather than followed.
615pub 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    // a console script installed into the node's managed env
628    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    // a file within the node's working dir (e.g. target/release/<bin>)
637    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
653/// Canonicalize `candidate` and require it to stay under `root`.
654fn 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
672/// Make an existing executable path absolute WITHOUT following symlinks.
673///
674/// Deliberately not `canonicalize()`: that resolves symlinks, and a
675/// virtualenv's `bin/python` is a symlink whose *location* is what
676/// CPython uses to discover `pyvenv.cfg`. Resolving it before exec runs
677/// the base interpreter with no venv, so imports that work in a shell
678/// fail under dora (dora-rs/dora#2918). `path::absolute` only prepends
679/// the cwd and drops `.` components; symlinks and `..` are left for the
680/// kernel to resolve at exec time, which matches shell behavior.
681///
682/// Errors if the path does not exist (`exists()` traverses symlinks, so
683/// a dangling link counts as missing — same outcome canonicalize gave).
684/// Shared by every [`resolve_path`] branch so the no-symlink-resolution
685/// contract cannot regress in one branch while the tests exercise
686/// another.
687fn 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
695/// Resolve `path` against the `uv`-managed environment by running
696/// `uv run which <path>`, returning an absolute path.
697///
698/// Unlike a fire-and-forget spawn, this waits for the child, checks its
699/// exit status (so a missing binary surfaces as an error), and verifies
700/// the captured location exists — without resolving symlinks, which
701/// would reintroduce the venv bypass fixed for the working-dir branch
702/// (dora-rs/dora#2918).
703fn 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    // `which`/`where` may emit multiple matches; the first line is the
715    // resolved binary.
716    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
726/// Canonicalize the working dir for a dataflow, preferring an explicit
727/// override over the dataflow file's parent directory.
728///
729/// When falling back to the dataflow's parent directory, returns the lexical parent
730/// directory of the dataflow path as given, canonicalized — symlinks in the file
731/// are not resolved, so the project root matches where the entrypoint was invoked
732/// rather than the target's location.
733pub 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
755/// Classification of a [`Node`] by which of its mutually exclusive
756/// implementation fields is set.
757pub trait NodeExt {
758    /// Determine the node's [`NodeKind`].
759    ///
760    /// A node must set **exactly one** of `path`, `operators`, `operator`,
761    /// `ros2`, or `module`; this returns an error if none or more than one is
762    /// set. A node carrying an unresolved `hub:` reference is also rejected —
763    /// `hub:` is desugared into a concrete node earlier in the build, so
764    /// reaching `kind` with one still present means resolution was skipped.
765    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            // `hub:` is desugared into a concrete git node by `dora build` /
772            // `dora run` / `dora validate` before any kind dispatch — an
773            // unresolved reference reaching this point means a flow that
774            // skipped resolution
775            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/// The implementation kind of a [`Node`], returned by [`NodeExt::kind`].
810///
811/// Each variant borrows the one exclusive field that was set on the node.
812#[derive(Debug)]
813pub enum NodeKind<'a> {
814    /// A custom node run from an executable `path`.
815    Standard(&'a String),
816    /// A dora runtime node hosting one or more in-process `operators`.
817    Runtime(&'a RuntimeNode),
818    /// A node defined by a single inline `operator`.
819    Operator(&'a SingleOperatorDefinition),
820    /// A ROS2 bridge node.
821    Ros2Bridge(&'a Ros2BridgeConfig),
822    /// A `module` (sub-dataflow) reference — must be expanded before resolution.
823    Module(&'a String),
824}
825
826#[cfg(test)]
827mod tests {
828    /// The `DORA_ROS2_BRIDGE_CONFIG` a resolved bridge node is spawned with.
829    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    /// A single-topic subscribe bridge must bind to the declared `pose`, not to
849    /// the topic-derived `turtle1_pose` the bridge would otherwise fall back to
850    /// — nothing is wired to that id, so every message would be dropped.
851    #[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    /// Multi-topic configs already carry their mapping and must pass through.
895    #[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    /// Resolution, not just validation, has to reject an unresolvable mapping:
916    /// `dora start` resolves through the coordinator without running
917    /// `check_dataflow`, so a guard that lives only in the validator would let
918    /// the bridge bind to a port nothing is wired to.
919    #[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    /// Setting both `topic:` and `topics:` is rejected downstream by the bridge
946    /// ("exactly one of `topic` or `topics`"). Resolution must leave the list
947    /// alone rather than overwrite it and turn that into a silent drop.
948    #[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    /// dora-rs/dora#2920: the command-line flag beats the descriptor in
974    /// BOTH directions, and its absence beats neither.
975    ///
976    /// The third state matters: if "flag omitted" meant `false`, a YAML
977    /// `exit_when_nodes_finish: true` would be overridden on every
978    /// invocation, and since `dora run` and `dora start` are the only
979    /// ways to start a dataflow, the descriptor field could never take
980    /// effect at all.
981    #[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        // Omitted: the descriptor decides, either way.
990        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        // Given: it wins, including against an opposite descriptor value.
1004        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        // A node that declares `env: {}` with no dataflow-level env must
1051        // resolve to `None`, not `Some({})`, matching the documented contract
1052        // (and the `(Some(empty), None)` arm) so the resolved node serializes
1053        // without an empty `env:` map.
1054        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        // A node added to a running dataflow via `AddNode` is resolved against a
1094        // single-node descriptor. If its input references an existing
1095        // single-`operator:` producer, the `op/` output prefix that
1096        // whole-descriptor resolution applies must still be added — sourced
1097        // from the surrounding topology — or the node subscribes to an output
1098        // name nobody publishes and silently receives no data (#2877).
1099        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        // With the topology the input is prefixed to the operator-qualified
1123        // output name the runtime actually publishes under (`op/result`).
1124        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        // Contrast: with no topology the producer is not in scope at all, so
1135        // there is nothing to key the rewrite off and the name stays bare.
1136        // That is the correct answer for the inputs given — which is exactly
1137        // why the `AddNode` path had to stop resolving in isolation.
1138        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    /// The operator subtree must survive resolution. The `Runtime`/`Operator`
1150    /// arms move `node.operators` / `node.operator` out with `take()`, which
1151    /// is only sound because `ResolvedNode::from_node` never reads those
1152    /// fields. Pin that invariant: a regression that consumed the subtree
1153    /// before building the `CoreNodeKind`, or a new `ResolvedNode` field
1154    /// sourced from `node.operator` after the take (which would compile and
1155    /// silently read `None`), surfaces here as a missing operator.
1156    #[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        // A `runtime:` (operators) node keeps every declared operator.
1179        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        // A single-`operator:` node resolves to a one-operator runtime,
1188        // defaulting the operator id to `op`.
1189        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        // The lookup chains topology nodes *before* `desc`'s own, so a node id
1205        // present in both resolves against the copy being added rather than a
1206        // stale topology entry. Unreachable through `AddNode` today (duplicate
1207        // ids are rejected up front and `RemoveNode` prunes the stored
1208        // descriptor), but the precedence should not depend on that.
1209        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        // Verify the new top-level `env:` field parses and the resolver
1252        // hands merged envs to every node with per-node keys winning.
1253        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        // Per-node RUST_LOG=debug wins over global RUST_LOG=info.
1271        assert_eq!(
1272            a_env.get("RUST_LOG"),
1273            Some(&EnvValue::String("debug".into()))
1274        );
1275        // Global key not overridden on node a is still visible.
1276        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        // A single-operator node whose `operator.id` contains a character
1296        // outside the `DataId` set (here a space) is accepted at parse time
1297        // (`OperatorId` is unvalidated). When another node references its
1298        // output, the resolver prefixes the output with the operator id. That
1299        // used to build the qualified id via `DataId::from`, which panics on
1300        // invalid characters and aborted `dora check`/`graph`/`build`. It must
1301        // now surface as a clean `Err` instead.
1302        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        // Regression for #2016: the `uv` fallback previously spawned
1322        // `uv run which <path>` fire-and-forget and returned the original
1323        // (relative) path even when the binary did not exist. A missing
1324        // binary must surface as an `Err`, and any successful resolution
1325        // must be an absolute path.
1326        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    /// dora-rs/dora#2918: resolving a node path must NOT follow symlinks.
1335    ///
1336    /// A virtualenv's `bin/python` is a symlink to the base interpreter,
1337    /// and CPython's venv discovery hinges on that: it looks for
1338    /// `pyvenv.cfg` relative to the path it was *invoked* as, not the
1339    /// symlink's target. Canonicalizing before exec therefore runs the
1340    /// base interpreter with no venv — imports that work in a shell
1341    /// (`.venv/bin/python -c "import numpy"`) fail under dora with
1342    /// `ModuleNotFoundError`.
1343    #[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        // relative source resolved against the working dir
1353        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        // absolute source (the shape from the issue: `path: /…/.venv/bin/python`)
1364        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        // `..` components survive too: they are resolved by the kernel at
1372        // exec time, AFTER any symlinked directories — which is the
1373        // shell-matching semantic. A lexical "cleanup" that collapses
1374        // them would resolve differently through symlinked dirs.
1375        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    /// A dangling symlink is "missing": `exists()` traverses the link, so
1385    /// resolution falls through to the uv/$PATH branches and ultimately
1386    /// errors — the same outcome the old `canonicalize()` failure gave.
1387    /// Pins the branch boundary so a switch to `symlink_metadata()`
1388    /// (which would treat the dangling link as present and exec a
1389    /// guaranteed-ENOENT path) doesn't slip in silently.
1390    #[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        // `sh` exists on $PATH everywhere on unix — confined resolution must
1409        // NOT find it (spec §11: a typo or missing console script fails, it
1410        // never silently runs a host binary)
1411        let result = resolve_path_confined("sh", tmp.path(), None);
1412        assert!(result.is_err(), "expected Err, got {result:?}");
1413
1414        // a real file in the working dir resolves
1415        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        // a managed-env console script resolves through the env's bin dir
1425        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        // Pins the #2016 root cause directly on the `uv` branch. The buggy
1462        // code spawned `uv run which <path>` fire-and-forget and returned
1463        // `Ok(<relative path>)` regardless of the child's exit status, so a
1464        // missing binary was silently accepted. This branch only runs when
1465        // `uv` is installed (the only environment where the bug manifested),
1466        // so guard on its presence to keep the test meaningful where it can
1467        // actually discriminate the fix.
1468        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        // Regression guard: no top-level `env:` must leave per-node env
1482        // semantics unchanged.
1483        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        // Regression for #2393: a plain dataflow with two nodes sharing the
1506        // same `id` must return an error instead of silently discarding one.
1507        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    /// Every `CustomNode` field name, taken from its JSON schema.
1526    ///
1527    /// A field marked `#[schemars(skip)]` would never appear in `properties`
1528    /// and would slip past the carried-through tests below. `Node::deploy` is
1529    /// exactly such a field, and the classify test compensates with a
1530    /// hardcoded insert — do the same here if `CustomNode` ever gains one.
1531    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    /// The per-node keys every custom-node kind resolves identically, each set
1546    /// to a value that differs from `CustomNode::new`'s default — a YAML
1547    /// fragment to append to a `- id:` entry.
1548    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    /// Resolve the single node in `yaml` and assert that every `CustomNode`
1577    /// key outside `kind_specific` arrived carrying the value the YAML
1578    /// declared — and that the YAML did set it to something other than
1579    /// `CustomNode::new`'s default, so the comparison is never a trivial
1580    /// `None == None`. Returns the resolved node for the kind-specific checks.
1581    ///
1582    /// `CustomNode::from_node` is a struct literal inside `dora-message`, so a
1583    /// key added to `CustomNode` is already a compile error there. This is the
1584    /// value-level half: it catches a key wired to the wrong `Node` field, or
1585    /// left at its default. When it fails for a newly added key, carry the key
1586    /// in `from_node` and give it a non-default value in
1587    /// `SHARED_CUSTOM_NODE_KEYS`.
1588    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        // A sentinel `path` that no YAML here uses, so `path` itself is checked
1599        // like every other field rather than comparing equal to it.
1600        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    /// A `path:` node: every shared key and the standard-only `path_sha256` /
1626    /// `build` arrive. `source` resolves to `Local`, which is already the
1627    /// default, and `envs` is set only by the ROS2-bridge arm — the two tests
1628    /// below cover those.
1629    #[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    /// The `source` a `git:` node classifies to must reach the resolved node:
1645    /// it is the one assignment in the standard arm that `from_node` does not
1646    /// cover, and dropping it would turn every git node into a local one.
1647    #[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    /// The ROS2-bridge arm: `path` is the bridge binary, `envs` carries the
1668    /// bridge config the binary reads at startup, and every shared key still
1669    /// arrives. `path_sha256` and `build` are rejected on a `ros2:` node by
1670    /// classification, so they must stay unset.
1671    #[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    /// The node-level keys — the ones `ResolvedNode::from_node` consumes once
1700    /// `CustomNode::from_node` has drained the rest — must reach the resolved
1701    /// node. `from_node` is a struct literal inside `dora-message`, so a new
1702    /// `ResolvedNode` field is a compile error there; this checks the values,
1703    /// including that the dataflow-level `env` is merged in with the per-node
1704    /// key winning.
1705    #[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    /// A multi-operator runtime node whose id contains a `.` must emit a
1744    /// Mermaid `subgraph` with a sanitized id (dots are invalid in subgraph
1745    /// ids), mirroring the module-subgraph path. Node ids legally contain `.`,
1746    /// and module expansion prefixes inner node ids with `{module_id}.`, so the
1747    /// unsanitized `subgraph camera.front` this used to emit was an invalid
1748    /// Mermaid document.
1749    #[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    /// A multi-topic (`topics:`) ros2 bridge that maps a topic to an undeclared
1784    /// port must be rejected on the *resolution* path, not just by the
1785    /// validator. The coordinator's `dora start` path resolves without running
1786    /// the validator, so before dora-rs/dora#3484 an undeclared multi-topic
1787    /// mapping passed here and silently dropped every message at runtime.
1788    #[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    /// The publish counterpart: a topic bound to an undeclared input is rejected
1813    /// on the resolution path too.
1814    #[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    /// Guard the other way: a multi-topic config whose mappings all reference
1843    /// declared ports (explicit and topic-derived) still resolves.
1844    #[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    /// A node that sets none of its mutually exclusive implementation fields is
1867    /// rejected, and the error must list every valid kind — including the
1868    /// single-`operator` form. The message previously named only `path`,
1869    /// `operators`, `ros2`, and `module`, silently omitting `operator` and so
1870    /// steering a user who mistyped an `operator:` key away from the correct
1871    /// fix (the sibling "multiple fields" error and the trait doc both list it).
1872    #[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}