Skip to main content

dora_core/descriptor/
validate.rs

1use crate::{
2    adjust_shared_library_path,
3    descriptor::{self, source_is_url},
4    get_python_path,
5};
6
7use dora_message::{
8    config::{Input, InputMapping, UserInputMapping, parse_byte_count},
9    descriptor::{CoreNodeKind, DYNAMIC_SOURCE, OperatorSource, ResolvedNode, SHELL_SOURCE},
10    id::{DataId, NodeId, OperatorId},
11};
12use eyre::{Context, bail, eyre};
13use std::{
14    collections::{BTreeMap, BTreeSet},
15    path::Path,
16    process::Command,
17};
18use tracing::info;
19
20use super::{Descriptor, DescriptorExt, resolve_path};
21const VERSION: &str = env!("CARGO_PKG_VERSION");
22
23/// Validate input/output wiring without checking path existence.
24///
25/// This is safe to run before building nodes. It verifies every input
26/// reference points to a declared output on the source node.
27pub fn check_wiring(dataflow: &Descriptor) -> eyre::Result<()> {
28    let nodes = dataflow.resolve_aliases_and_set_defaults()?;
29    check_wiring_resolved(&nodes)
30}
31
32/// [`check_wiring`] on an already-resolved node map, so callers that have
33/// resolved the descriptor can avoid re-resolving it.
34fn check_wiring_resolved(nodes: &BTreeMap<NodeId, ResolvedNode>) -> eyre::Result<()> {
35    for node in nodes.values() {
36        match &node.kind {
37            descriptor::CoreNodeKind::Custom(custom_node) => {
38                for (input_id, input) in &custom_node.run_config.inputs {
39                    check_input(input, nodes, &format!("{}/{input_id}", node.id))?;
40                }
41            }
42            descriptor::CoreNodeKind::Runtime(runtime_node) => {
43                for operator_definition in &runtime_node.operators {
44                    for (input_id, input) in &operator_definition.config.inputs {
45                        check_input(
46                            input,
47                            nodes,
48                            &format!("{}/{}/{input_id}", node.id, operator_definition.id),
49                        )?;
50                    }
51                }
52            }
53        };
54    }
55
56    Ok(())
57}
58
59/// Static (filesystem-independent) descriptor checks: ROS2 bridge configs,
60/// timing fields, input/output wiring, and log-output configuration.
61///
62/// This is the subset of [`check_dataflow`] that `dora validate` can run
63/// before any build has happened. [`check_dataflow`] runs it as its first
64/// step and adds source-path existence and Python runtime checks on top.
65pub fn check_dataflow_static(dataflow: &Descriptor) -> eyre::Result<()> {
66    // validate ROS2 bridge configs before resolution
67    validate_ros2_configs(dataflow)?;
68
69    let nodes = dataflow.resolve_aliases_and_set_defaults()?;
70    check_dataflow_static_resolved(dataflow, &nodes)
71}
72
73/// Validate all ROS2 bridge configs on the *unresolved* descriptor.
74///
75/// Run before [`Descriptor::resolve_aliases_and_set_defaults`] so a ROS2
76/// misconfiguration is reported ahead of any alias-resolution error.
77fn validate_ros2_configs(dataflow: &Descriptor) -> eyre::Result<()> {
78    for node in &dataflow.nodes {
79        if let Some(ros2) = &node.ros2 {
80            validate_ros2_config(&node.id, ros2, &node.inputs, &node.outputs)?;
81        }
82    }
83    Ok(())
84}
85
86/// The resolution-dependent part of [`check_dataflow_static`], operating on an
87/// already-resolved node map so callers don't re-resolve the descriptor.
88fn check_dataflow_static_resolved(
89    dataflow: &Descriptor,
90    nodes: &BTreeMap<NodeId, ResolvedNode>,
91) -> eyre::Result<()> {
92    for node in nodes.values() {
93        check_node_timing(node)?;
94    }
95    // dataflow-level `health_check_interval` reaches `Duration::from_secs_f64`
96    // in the same way (`binaries/daemon/src/lib.rs`). A zero interval must also
97    // be rejected: it is fed to `tokio::time::interval`, which panics on a zero
98    // period.
99    check_seconds_field(
100        "dataflow",
101        "health_check_interval",
102        dataflow.health_check_interval,
103        false,
104    )?;
105
106    // check that all inputs mappings point to an existing output
107    check_wiring_resolved(nodes)?;
108
109    // Check that nodes can resolve `send_stdout_as`, `send_logs_as`, `min_log_level`
110    for node in nodes.values() {
111        node.send_stdout_as()
112            .context("Could not resolve `send_stdout_as` configuration")?;
113        node.send_logs_as()
114            .context("Could not resolve `send_logs_as` configuration")?;
115        node.min_log_level()
116            .context("Could not resolve `min_log_level` configuration")?;
117        node.max_log_size()
118            .context("Could not resolve `max_log_size` configuration")?;
119        node.max_rotated_files()
120            .context("Could not resolve `max_rotated_files` configuration")?;
121    }
122
123    Ok(())
124}
125
126pub fn check_dataflow(dataflow: &Descriptor, working_dir: &Path) -> eyre::Result<()> {
127    // Resolve the descriptor once and share the result across every check
128    // (static validation + path/runtime existence) instead of re-resolving it
129    // for each, which clones the whole node topology on every call.
130    validate_ros2_configs(dataflow)?;
131    let nodes = dataflow.resolve_aliases_and_set_defaults()?;
132    check_dataflow_static_resolved(dataflow, &nodes)?;
133
134    let mut has_python_operator = false;
135
136    // check that nodes and operators exist
137    for node in nodes.values() {
138        match &node.kind {
139            descriptor::CoreNodeKind::Custom(custom) => match &custom.source {
140                dora_message::descriptor::NodeSource::Local => match custom.path.as_str() {
141                    SHELL_SOURCE => (),
142                    DYNAMIC_SOURCE => (),
143                    source => {
144                        if source_is_url(source) {
145                            info!("{source} is a URL."); // TODO: Implement url check.
146                        } else if custom.build.is_some() {
147                            info!("skipping path check for node with build command");
148                        } else {
149                            resolve_path(source, working_dir).wrap_err_with(|| {
150                                format!("Could not find source path `{source}`")
151                            })?;
152                        };
153                    }
154                },
155                dora_message::descriptor::NodeSource::GitBranch { .. } => {
156                    info!("skipping check for node with git source");
157                }
158            },
159            descriptor::CoreNodeKind::Runtime(node) => {
160                for operator_definition in &node.operators {
161                    match &operator_definition.config.source {
162                        OperatorSource::SharedLibrary(path) => {
163                            if source_is_url(path) {
164                                info!("{path} is a URL."); // TODO: Implement url check.
165                            } else if operator_definition.config.build.is_some() {
166                                info!("skipping path check for operator with build command");
167                            } else {
168                                let path = adjust_shared_library_path(Path::new(&path))?;
169                                if !working_dir.join(&path).exists() {
170                                    bail!("no shared library at `{}`", path.display());
171                                }
172                            }
173                        }
174                        OperatorSource::Python(python_source) => {
175                            has_python_operator = true;
176                            let path = &python_source.source;
177                            if source_is_url(path) {
178                                info!("{path} is a URL."); // TODO: Implement url check.
179                            } else if operator_definition.config.build.is_some() {
180                                info!("skipping path check for operator with build command");
181                            } else if !working_dir.join(path).exists() {
182                                bail!("no Python library at `{path}`");
183                            }
184                        }
185                        OperatorSource::Wasm(path) => {
186                            if source_is_url(path) {
187                                info!("{path} is a URL."); // TODO: Implement url check.
188                            } else if operator_definition.config.build.is_some() {
189                                info!("skipping path check for operator with build command");
190                            } else if !working_dir.join(path).exists() {
191                                bail!("no WASM library at `{path}`");
192                            }
193                        }
194                    }
195                }
196            }
197        }
198    }
199
200    if has_python_operator {
201        check_python_runtime()?;
202    }
203
204    Ok(())
205}
206
207/// Validate the second-valued timing fields of one resolved node: the custom
208/// node's own timeouts and restart delays, plus every input's
209/// `input_timeout`.
210///
211/// The daemon feeds these to `Duration::from_secs_f64`, which panics on
212/// negative, non-finite, or overflowing values. [`check_dataflow_static`]
213/// runs this for every node; call it directly for a node that joins a running
214/// dataflow (`dora node add` / `dora node replace`), which never goes through
215/// whole-dataflow validation.
216///
217/// This also enforces the `startup_timeout` rule for dynamic nodes, which is
218/// a descriptor-policy check rather than a panic guard. The spawn path of an
219/// existing dataflow (`dora start`) must not apply that rule to a descriptor
220/// that started fine before, so it calls [`check_node_timing_values`] instead.
221pub fn check_node_timing(node: &ResolvedNode) -> eyre::Result<()> {
222    check_node_timing_values(node)?;
223    if let descriptor::CoreNodeKind::Custom(custom) = &node.kind
224        && custom.path.as_str() == DYNAMIC_SOURCE
225        && custom.startup_timeout.is_some()
226    {
227        bail!(
228            "dynamic node `{}` cannot specify `startup_timeout` (dynamic nodes connect out-of-band and are not managed by the startup watchdog)",
229            node.id
230        );
231    }
232    Ok(())
233}
234
235/// The panic-guard half of [`check_node_timing`]: reject only the values the
236/// daemon would panic on in `Duration::from_secs_f64`, with no
237/// descriptor-policy rules on top.
238///
239/// `dora start` never ran whole-dataflow validation before, so applying a
240/// policy rule there would reject descriptors that start fine on 1.0.x and
241/// never panicked -- a dynamic node carrying an (ignored) `startup_timeout`,
242/// for one. The spawn path therefore guards against the panic only, and
243/// leaves the policy rules to `dora run` / `dora validate`
244/// ([`check_dataflow_static`]) and to the node-join path
245/// ([`check_node_timing`]).
246pub fn check_node_timing_values(node: &ResolvedNode) -> eyre::Result<()> {
247    if let descriptor::CoreNodeKind::Custom(custom) = &node.kind {
248        check_timing_fields(&node.id, custom)?;
249    }
250    // `input_timeout` is a second-valued `f64` that the daemon also feeds
251    // to `Duration::from_secs_f64`, on both the initial-spawn and the
252    // reconnect paths.
253    for (input_id, input) in node_inputs(node) {
254        check_seconds_field(
255            &format!("input `{input_id}` of node `{}`", node.id),
256            "input_timeout",
257            input.input_timeout,
258            true,
259        )?;
260    }
261    Ok(())
262}
263
264/// Reject negative / non-finite / overflowing second-valued timing fields on a
265/// custom node.
266///
267/// The daemon converts these to `Duration` via `Duration::from_secs_f64`, which
268/// panics on negative, NaN, infinite, or overflowing (> `Duration::MAX`) input.
269/// Catching them here surfaces a clean descriptor error instead of crashing the
270/// daemon on node spawn.
271fn check_timing_fields(
272    node_id: &NodeId,
273    custom: &dora_message::descriptor::CustomNode,
274) -> eyre::Result<()> {
275    let owner = format!("node `{node_id}`");
276    for (field, value) in [
277        ("finish_grace_secs", custom.finish_grace_secs),
278        ("health_check_timeout", custom.health_check_timeout),
279        ("startup_timeout", custom.startup_timeout),
280        ("restart_delay", custom.restart_delay),
281        ("max_restart_delay", custom.max_restart_delay),
282        ("restart_window", custom.restart_window),
283    ] {
284        check_seconds_field(&owner, field, value, true)?;
285    }
286    Ok(())
287}
288
289/// Reject a negative / non-finite / overflowing second-valued field before it
290/// reaches the daemon, where `Duration::from_secs_f64` would panic on such
291/// input.
292///
293/// `Duration::from_secs_f64` panics on negative, NaN, infinite, *and* finite
294/// values too large to fit in a `Duration` (> `Duration::MAX`, ≈ 1.8e19 s). We
295/// probe the exact same boundary with its non-panicking twin
296/// `try_from_secs_f64`, so a value accepted here can never panic the daemon.
297///
298/// When `allow_zero` is `false`, any value that produces a zero-length
299/// `Duration` is also rejected. This is required for fields that reach
300/// `tokio::time::interval` (e.g. `health_check_interval`), which panics on a
301/// zero period. Checking the resulting `Duration` -- not just the literal
302/// `0.0` -- also rejects a tiny-but-positive value such as `1e-10`, which
303/// `Duration::from_secs_f64` rounds down to `Duration::ZERO`. This mirrors the
304/// timer parser's `interval.is_zero()` guard in `dora-message`.
305fn check_seconds_field(
306    owner: &str,
307    field: &str,
308    value: Option<f64>,
309    allow_zero: bool,
310) -> eyre::Result<()> {
311    if let Some(value) = value {
312        // A negative / non-finite / overflowing value fails to convert; a
313        // tiny-but-positive value (e.g. `1e-10`) converts to `Duration::ZERO`,
314        // which must also be rejected for interval fields (`allow_zero ==
315        // false`). Inspect the resulting `Duration`, not the literal `0.0`.
316        let duration = std::time::Duration::try_from_secs_f64(value);
317        let is_zero = duration.as_ref().is_ok_and(|d| d.is_zero());
318        if !allow_zero && is_zero {
319            // Distinct message: a value like `1e-10` *is* positive, so calling it
320            // "not positive" would misdirect the user -- the real reason is that
321            // it rounds down to a zero-length duration.
322            bail!(
323                "{owner} has invalid `{field}`: {value} \
324                 (must be a positive number of seconds; this value is zero or \
325                 rounds down to a zero-length duration)"
326            );
327        }
328        if duration.is_err() {
329            let requirement = if allow_zero {
330                "non-negative"
331            } else {
332                "positive"
333            };
334            bail!(
335                "{owner} has invalid `{field}`: {value} \
336                 (must be a finite, {requirement} number of seconds smaller than {})",
337                std::time::Duration::MAX.as_secs_f64()
338            );
339        }
340    }
341    Ok(())
342}
343
344/// All `(input_id, input)` pairs declared on a resolved node: a custom node's
345/// own inputs, or the merged inputs of every operator of a runtime node.
346fn node_inputs(node: &ResolvedNode) -> Vec<(&DataId, &Input)> {
347    match &node.kind {
348        CoreNodeKind::Custom(custom) => custom.run_config.inputs.iter().collect(),
349        CoreNodeKind::Runtime(runtime) => runtime
350            .operators
351            .iter()
352            .flat_map(|op| op.config.inputs.iter())
353            .collect(),
354    }
355}
356
357pub trait ResolvedNodeExt {
358    fn send_stdout_as(&self) -> eyre::Result<Option<String>>;
359    fn send_logs_as(&self) -> eyre::Result<Option<String>>;
360    fn min_log_level(&self) -> eyre::Result<Option<dora_message::common::LogLevelOrStdout>>;
361    fn max_log_size(&self) -> eyre::Result<Option<u64>>;
362    fn max_rotated_files(&self) -> eyre::Result<Option<u32>>;
363}
364
365/// Check that a resolved `send_stdout_as` / `send_logs_as` name is a valid
366/// [`DataId`]. The daemon's per-node log task turns it into one for every log
367/// line, and an invalid name (empty, a space, `op/` from an empty operator
368/// entry, ...) would otherwise panic that task on the node's first line and
369/// silently drop all of the node's later logs.
370fn checked_output_name(field: &str, name: String) -> eyre::Result<String> {
371    name.parse::<DataId>()
372        .map_err(|err| eyre!("`{field}: {name}` is not a valid output id: {err}"))?;
373    Ok(name)
374}
375
376impl ResolvedNodeExt for ResolvedNode {
377    fn send_stdout_as(&self) -> eyre::Result<Option<String>> {
378        let name = match &self.kind {
379            // TODO: Split stdout between operators
380            CoreNodeKind::Runtime(n) => {
381                let count = n
382                    .operators
383                    .iter()
384                    .filter(|op| op.config.send_stdout_as.is_some())
385                    .count();
386                if count == 1 && n.operators.len() > 1 {
387                    tracing::warn!(
388                        "All stdout from all operators of a runtime are going to be sent in the selected `send_stdout_as` operator."
389                    )
390                } else if count > 1 {
391                    return Err(eyre!(
392                        "More than one `send_stdout_as` entries for a runtime node. Please only use one `send_stdout_as` per runtime."
393                    ));
394                }
395                n.operators.iter().find_map(|op| {
396                    op.config
397                        .send_stdout_as
398                        .as_ref()
399                        .map(|stdout| format!("{}/{}", op.id, stdout))
400                })
401            }
402            CoreNodeKind::Custom(n) => n.send_stdout_as.clone(),
403        };
404        name.map(|name| checked_output_name("send_stdout_as", name))
405            .transpose()
406    }
407
408    fn send_logs_as(&self) -> eyre::Result<Option<String>> {
409        let name = match &self.kind {
410            CoreNodeKind::Runtime(n) => {
411                let count = n
412                    .operators
413                    .iter()
414                    .filter(|op| op.config.send_logs_as.is_some())
415                    .count();
416                if count > 1 {
417                    return Err(eyre!(
418                        "More than one `send_logs_as` entries for a runtime node. Please only use one `send_logs_as` per runtime."
419                    ));
420                }
421                n.operators.iter().find_map(|op| {
422                    op.config
423                        .send_logs_as
424                        .as_ref()
425                        .map(|logs| format!("{}/{}", op.id, logs))
426                })
427            }
428            CoreNodeKind::Custom(n) => n.send_logs_as.clone(),
429        };
430        name.map(|name| checked_output_name("send_logs_as", name))
431            .transpose()
432    }
433
434    fn min_log_level(&self) -> eyre::Result<Option<dora_message::common::LogLevelOrStdout>> {
435        let level_str = match &self.kind {
436            CoreNodeKind::Runtime(n) => {
437                // Use the first operator's min_log_level (only one allowed)
438                let levels: Vec<_> = n
439                    .operators
440                    .iter()
441                    .filter_map(|op| op.config.min_log_level.as_deref())
442                    .collect();
443                if levels.len() > 1 {
444                    return Err(eyre!(
445                        "More than one `min_log_level` entries for a runtime node. Please only use one `min_log_level` per runtime."
446                    ));
447                }
448                levels.first().map(|s| s.to_string())
449            }
450            CoreNodeKind::Custom(n) => n.min_log_level.clone(),
451        };
452        match level_str {
453            None => Ok(None),
454            Some(s) => {
455                let level = parse_log_level(&s)?;
456                Ok(Some(level))
457            }
458        }
459    }
460
461    fn max_log_size(&self) -> eyre::Result<Option<u64>> {
462        let size_str = match &self.kind {
463            CoreNodeKind::Runtime(n) => {
464                let sizes: Vec<_> = n
465                    .operators
466                    .iter()
467                    .filter_map(|op| op.config.max_log_size.as_deref())
468                    .collect();
469                if sizes.len() > 1 {
470                    return Err(eyre!(
471                        "More than one `max_log_size` entries for a runtime node. Please only use one `max_log_size` per runtime."
472                    ));
473                }
474                sizes.first().map(|s| s.to_string())
475            }
476            CoreNodeKind::Custom(n) => n.max_log_size.clone(),
477        };
478        match size_str {
479            None => Ok(None),
480            Some(s) => {
481                let bytes = parse_byte_size(&s)?;
482                Ok(Some(bytes))
483            }
484        }
485    }
486
487    fn max_rotated_files(&self) -> eyre::Result<Option<u32>> {
488        let value = match &self.kind {
489            CoreNodeKind::Runtime(n) => {
490                let values: Vec<_> = n
491                    .operators
492                    .iter()
493                    .filter_map(|op| op.config.max_rotated_files)
494                    .collect();
495                if values.len() > 1 {
496                    return Err(eyre!(
497                        "More than one `max_rotated_files` entries for a runtime node. Please only use one `max_rotated_files` per runtime."
498                    ));
499                }
500                values.first().copied()
501            }
502            CoreNodeKind::Custom(n) => n.max_rotated_files,
503        };
504        if let Some(n) = value {
505            // 0 is meaningful: keep the active log only, rotating the previous
506            // one away rather than retaining it. That matches the documented
507            // disk bound `max_log_size * (1 + max_rotated_files)`, which at 0
508            // is one active file.
509            if n > 100 {
510                bail!("`max_rotated_files` must not exceed 100");
511            }
512        }
513        Ok(value)
514    }
515}
516
517fn parse_byte_size(s: &str) -> eyre::Result<u64> {
518    // Share `dora_message::config::ByteSize`'s grammar, but parse in `u64`:
519    // `ByteSize` is `usize`-based, which would reject log sizes of 4 GiB and
520    // above on 32-bit targets (#3604).
521    parse_byte_count(s).map_err(|e| eyre!("{e}"))
522}
523
524fn parse_log_level(s: &str) -> eyre::Result<dora_message::common::LogLevelOrStdout> {
525    match s.to_lowercase().as_str() {
526        "error" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
527            log::Level::Error,
528        )),
529        "warn" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
530            log::Level::Warn,
531        )),
532        "info" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
533            log::Level::Info,
534        )),
535        "debug" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
536            log::Level::Debug,
537        )),
538        "trace" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
539            log::Level::Trace,
540        )),
541        "stdout" => Ok(dora_message::common::LogLevelOrStdout::Stdout),
542        _ => bail!(
543            "invalid min_log_level: '{s}', expected one of: error, warn, info, debug, trace, stdout"
544        ),
545    }
546}
547
548fn check_input(
549    input: &Input,
550    nodes: &BTreeMap<NodeId, super::ResolvedNode>,
551    input_id_str: &str,
552) -> Result<(), eyre::ErrReport> {
553    match &input.mapping {
554        InputMapping::Timer { interval: _ } | InputMapping::Logs(_) => {}
555        InputMapping::User(UserInputMapping { source, output }) => {
556            // `nodes` is keyed by each node's own id (see
557            // `resolve_aliases_and_set_defaults`), so look the source up
558            // directly instead of scanning every node — this runs once per
559            // input of every node, so a linear scan makes wiring validation
560            // quadratic in the node count.
561            let source_node = nodes.get(source).ok_or_else(|| {
562                eyre!("source node `{source}` mapped to input `{input_id_str}` does not exist",)
563            })?;
564            match &source_node.kind {
565                CoreNodeKind::Custom(custom_node) => {
566                    if !custom_node.run_config.outputs.contains(output) {
567                        bail!(
568                            "output `{source}/{output}` mapped to \
569                            input `{input_id_str}` does not exist",
570                        );
571                    }
572                }
573                CoreNodeKind::Runtime(runtime) => {
574                    // Outputs of runtime nodes are referenced as
575                    // `<node>/<operator>/<output>`. Without the operator
576                    // segment, falling back to an empty operator id would
577                    // produce a misleading "operator `` does not exist" error.
578                    let Some((operator_id, output)) = output.split_once('/') else {
579                        bail!(
580                            "input `{input_id_str}` references output `{output}` of node \
581                            `{source}`, which is a runtime node; runtime node outputs \
582                            must include the operator id \
583                            (expected format: `{source}/<operator_id>/<output_id>`)"
584                        );
585                    };
586                    let operator_id = OperatorId::from(operator_id.to_owned());
587                    let output = DataId::from(output.to_owned());
588
589                    let operator = runtime
590                        .operators
591                        .iter()
592                        .find(|o| o.id == operator_id)
593                        .ok_or_else(|| {
594                            eyre!(
595                                "source operator `{source}/{operator_id}` used \
596                                for input `{input_id_str}` does not exist",
597                            )
598                        })?;
599
600                    if !operator.config.outputs.contains(&output) {
601                        bail!(
602                            "output `{source}/{operator_id}/{output}` mapped to \
603                            input `{input_id_str}` does not exist",
604                        );
605                    }
606                }
607            }
608        }
609    };
610    Ok(())
611}
612
613fn check_python_runtime() -> eyre::Result<()> {
614    // Check if python dora-rs is installed and match cli version
615    let reinstall_command =
616        format!("Please reinstall it with: `pip install dora-rs=={VERSION} --force`");
617    let mut command = Command::new(get_python_path().context("Could not get python binary")?);
618    command.args([
619        "-c",
620        &format!(
621            "
622import dora;
623assert dora.__version__=='{VERSION}',  'Python dora-rs should be {VERSION}, but current version is %s. {reinstall_command}' % (dora.__version__)
624        "
625        ),
626    ]);
627    let mut result = command
628        .spawn()
629        .wrap_err("Could not spawn python dora-rs command.")?;
630    let status = result
631        .wait()
632        .wrap_err("Could not get exit status when checking python dora-rs")?;
633
634    if !status.success() {
635        bail!("Something went wrong with Python dora-rs. {reinstall_command}")
636    }
637
638    Ok(())
639}
640
641fn validate_ros2_config(
642    node_id: &NodeId,
643    config: &dora_message::descriptor::Ros2BridgeConfig,
644    node_inputs: &BTreeMap<DataId, Input>,
645    node_outputs: &BTreeSet<DataId>,
646) -> eyre::Result<()> {
647    use dora_message::descriptor::{Ros2Role, Ros2TransportConfig};
648
649    if let Ros2TransportConfig::Zenoh {
650        config_uri: Some(uri),
651        ..
652    } = &config.transport
653        && uri.as_os_str().is_empty()
654    {
655        bail!("node `{node_id}`: ros2 Zenoh config_uri must not be empty");
656    }
657
658    // Exactly one of topic, topics, service, action must be set
659    let mode_count = [
660        config.topic.is_some(),
661        config.topics.is_some(),
662        config.service.is_some(),
663        config.action.is_some(),
664    ]
665    .iter()
666    .filter(|&&v| v)
667    .count();
668    if mode_count == 0 {
669        bail!(
670            "node `{node_id}`: ros2 config requires one of \
671             `topic`, `topics`, `service`, or `action`"
672        );
673    }
674    if mode_count > 1 {
675        bail!(
676            "node `{node_id}`: ros2 config has multiple of \
677             `topic`, `topics`, `service`, `action` - only one is allowed"
678        );
679    }
680
681    if let Some(topic) = &config.topic {
682        validate_ros2_name(node_id, "topic", topic)?;
683        let message_type = config.message_type.as_ref().ok_or_else(|| {
684            eyre!("node `{node_id}`: ros2 config with `topic` requires `message_type`")
685        })?;
686        validate_ros2_type_format(node_id, topic, message_type)?;
687
688        // Single-topic mode has no explicit `output:`/`input:` field, so the
689        // bridge binds to the node's declared port. Resolution is what picks
690        // that port and what rejects a node whose declarations leave the choice
691        // ambiguous, so call it here rather than restating the rule: the two
692        // must not be able to disagree.
693        super::resolve_ros2_single_topic(node_id, config, node_inputs, node_outputs)?;
694    } else if let Some(topics) = &config.topics {
695        if topics.is_empty() {
696            bail!("node `{node_id}`: ros2 `topics` list must not be empty");
697        }
698        if topics.len() > 64 {
699            bail!(
700                "node `{node_id}`: ros2 `topics` list has {} entries, maximum is 64",
701                topics.len()
702            );
703        }
704        for t in topics {
705            validate_ros2_name(node_id, "topic", &t.topic)?;
706            validate_ros2_type_format(node_id, &t.topic, &t.message_type)?;
707        }
708        // The per-topic port-existence check lives on the resolution path
709        // (`validate_ros2_topic_ports`) so the coordinator's `dora start` path,
710        // which resolves without validating, enforces it too. Delegate here
711        // rather than restating the rule, keeping validation and `dora start` in
712        // lockstep by construction (dora-rs/dora#3484).
713        super::validate_ros2_topic_ports(node_id, topics, node_inputs, node_outputs)?;
714    } else if let Some(service) = &config.service {
715        validate_ros2_name(node_id, "service", service)?;
716        let service_type = config.service_type.as_ref().ok_or_else(|| {
717            eyre!("node `{node_id}`: ros2 config with `service` requires `service_type`")
718        })?;
719        validate_ros2_type_format(node_id, service, service_type)?;
720        let role = config.role.as_ref().ok_or_else(|| {
721            eyre!("node `{node_id}`: ros2 service bridge requires `role` (client or server)")
722        })?;
723        match role {
724            Ros2Role::Client => {
725                if node_inputs.is_empty() {
726                    bail!(
727                        "node `{node_id}`: ros2 service client requires at least one input (request)"
728                    );
729                }
730                if node_outputs.is_empty() {
731                    bail!(
732                        "node `{node_id}`: ros2 service client requires at least one output (response)"
733                    );
734                }
735            }
736            Ros2Role::Server => {
737                if node_inputs.is_empty() {
738                    bail!(
739                        "node `{node_id}`: ros2 service server requires at least one input (response)"
740                    );
741                }
742                if node_outputs.is_empty() {
743                    bail!(
744                        "node `{node_id}`: ros2 service server requires at least one output (request)"
745                    );
746                }
747            }
748        }
749    } else if let Some(action) = &config.action {
750        validate_ros2_name(node_id, "action", action)?;
751        let action_type = config.action_type.as_ref().ok_or_else(|| {
752            eyre!("node `{node_id}`: ros2 config with `action` requires `action_type`")
753        })?;
754        validate_ros2_type_format(node_id, action, action_type)?;
755        let role = config
756            .role
757            .as_ref()
758            .ok_or_else(|| eyre!("node `{node_id}`: ros2 action bridge requires `role`"))?;
759        match role {
760            Ros2Role::Client => {
761                if node_inputs.is_empty() {
762                    bail!(
763                        "node `{node_id}`: ros2 action client requires at least one input (goal)"
764                    );
765                }
766                if node_outputs.is_empty() {
767                    bail!(
768                        "node `{node_id}`: ros2 action client requires at least one output \
769                         (feedback/result)"
770                    );
771                }
772            }
773            Ros2Role::Server => {
774                if node_inputs.is_empty() {
775                    bail!(
776                        "node `{node_id}`: ros2 action server requires at least one input \
777                         (feedback/result)"
778                    );
779                }
780                if node_outputs.is_empty() {
781                    bail!(
782                        "node `{node_id}`: ros2 action server requires at least one output (goal)"
783                    );
784                }
785            }
786        }
787    }
788
789    // Validate QoS config
790    validate_ros2_qos(node_id, &config.qos)?;
791    if let Some(topics) = &config.topics {
792        for t in topics {
793            if let Some(qos) = &t.qos {
794                validate_ros2_qos(node_id, qos)?;
795            }
796        }
797    }
798
799    Ok(())
800}
801
802fn validate_ros2_qos(
803    node_id: &NodeId,
804    qos: &dora_message::descriptor::Ros2QosConfig,
805) -> eyre::Result<()> {
806    if let Some(d) = &qos.durability {
807        match d.as_str() {
808            "volatile" | "transient_local" => {}
809            _ => bail!(
810                "node `{node_id}`: invalid QoS durability `{d}`, \
811                 expected \"volatile\" or \"transient_local\""
812            ),
813        }
814    }
815    if let Some(l) = &qos.liveliness {
816        match l.as_str() {
817            "automatic" | "manual_by_participant" | "manual_by_topic" => {}
818            _ => bail!(
819                "node `{node_id}`: invalid QoS liveliness `{l}`, \
820                 expected \"automatic\", \"manual_by_participant\", or \"manual_by_topic\""
821            ),
822        }
823    }
824    if let Some(depth) = qos.keep_last
825        && !(1..=10_000).contains(&depth)
826    {
827        bail!(
828            "node `{node_id}`: QoS keep_last depth {depth} out of range, \
829                 must be between 1 and 10000"
830        );
831    }
832    // Both fields reach `Duration::from_secs_f64` in the ROS2 bridge QoS
833    // conversion and panic on a finite-but-overflowing value (e.g. `1e300`),
834    // not just on NaN/infinite/negative. `check_seconds_field` probes that
835    // exact boundary with `try_from_secs_f64`, matching every other
836    // second-valued field here, and keeps validating both fields whenever set
837    // (as the prior check did). `allow_zero = true`: `max_blocking_time`
838    // defaults to `0.0` and a zero lease is harmless.
839    let owner = format!("node `{node_id}`");
840    check_seconds_field(&owner, "QoS max_blocking_time", qos.max_blocking_time, true)?;
841    check_seconds_field(&owner, "QoS lease_duration", qos.lease_duration, true)?;
842    Ok(())
843}
844
845/// Validate a ROS2 name (topic, service, or action) follows ROS2 naming rules.
846///
847/// ROS2 names allow ASCII alphanumeric, underscore, and forward slash characters,
848/// and must additionally be structurally valid: no consecutive slashes, no trailing
849/// slash, not a bare `/`, and each `/`-delimited token must not start with a digit.
850/// Tokens may begin with an underscore (hidden topic/service convention in ROS2).
851/// See <https://design.ros2.org/articles/topic_and_service_names.html>.
852fn validate_ros2_name(node_id: &NodeId, field: &str, name: &str) -> eyre::Result<()> {
853    if name.is_empty() {
854        bail!("node `{node_id}`: `{field}` must not be empty");
855    }
856    if !name
857        .chars()
858        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '/')
859    {
860        bail!(
861            "node `{node_id}`: invalid `{field}` name `{name}`, \
862             only ASCII alphanumeric, underscore, and '/' characters allowed"
863        );
864    }
865    if name == "/" {
866        bail!("node `{node_id}`: invalid `{field}` name, must not be a bare '/'");
867    }
868    if name.contains("//") {
869        bail!(
870            "node `{node_id}`: invalid `{field}` name `{name}`, \
871             consecutive slashes ('//') are not allowed"
872        );
873    }
874    if name.ends_with('/') {
875        bail!(
876            "node `{node_id}`: invalid `{field}` name `{name}`, \
877             name must not end with '/'"
878        );
879    }
880    // Each '/'-delimited token must not start with a digit. A leading '/'
881    // (absolute name) produces an empty first token, which is allowed and skipped.
882    // Leading underscores are valid — they denote hidden topics/services in ROS2.
883    for (i, token) in name.split('/').enumerate() {
884        if i == 0 && token.is_empty() {
885            continue;
886        }
887        if token.starts_with(|c: char| c.is_ascii_digit()) {
888            bail!(
889                "node `{node_id}`: invalid `{field}` name `{name}`, \
890                 a token must not start with a digit (offending token `{token}`)"
891            );
892        }
893    }
894    Ok(())
895}
896
897/// A non-fatal warning about type annotations in the dataflow.
898#[derive(Debug)]
899pub struct TypeWarning {
900    /// Node that produced the warning.
901    pub node_id: String,
902    /// Human-readable warning message.
903    pub message: String,
904}
905
906impl std::fmt::Display for TypeWarning {
907    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
908        write!(f, "node \"{}\": {}", self.node_id, self.message)
909    }
910}
911
912/// A type inferred from an upstream annotated output (Phase 3).
913#[derive(Debug)]
914pub struct TypeInference {
915    /// Node that received the inferred type.
916    pub node_id: String,
917    /// Port that received the inferred type.
918    pub port_id: String,
919    /// The inferred type URN.
920    pub inferred_urn: String,
921    /// Source of the inference (e.g. "sensor/reading").
922    pub source: String,
923}
924
925impl std::fmt::Display for TypeInference {
926    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
927        write!(
928            f,
929            "inferred {} on {}/{} (from {})",
930            self.inferred_urn, self.node_id, self.port_id, self.source
931        )
932    }
933}
934
935/// Result of type checking a dataflow.
936pub struct TypeCheckResult {
937    /// Type warnings found.
938    pub warnings: Vec<TypeWarning>,
939    /// Types inferred from upstream annotations (Phase 3).
940    pub inferences: Vec<TypeInference>,
941}
942
943/// Timer nodes auto-inject this type.
944const TIMER_TYPE: &str = "std/core/v1/UInt64";
945
946/// Register one declared output type under `key` (a `(node_id, output_ref)`
947/// pair). The port is *always* recorded in `annotated` so a typo'd or
948/// otherwise unresolvable URN still suppresses the strict-mode "upstream has
949/// no type annotation" warning; it is additionally recorded in
950/// `output_type_map` (the map used for cross-edge type matching) only when its
951/// URN resolves. Keeping both writes behind one call makes the two structures
952/// impossible to desync — the invariant the strict-mode check relies on.
953fn register_output_type(
954    output_type_map: &mut BTreeMap<(String, String), String>,
955    annotated: &mut BTreeSet<(String, String)>,
956    key: (String, String),
957    urn: &str,
958    registry: &crate::types::TypeRegistry,
959) {
960    annotated.insert(key.clone());
961    if registry.resolve(urn).is_some() {
962        output_type_map.insert(key, urn.to_string());
963    }
964}
965
966/// Check type annotations in a dataflow, with strict mode support.
967///
968/// Returns the collected warnings plus any inferred edge types.
969///
970/// Checks:
971/// 1. `output_types` keys exist in `outputs`
972/// 2. `input_types` keys exist in `inputs`
973/// 3. All type URNs resolve in the registry
974/// 4. Connected edges have compatible types (using compatibility graph)
975/// 5. Type inference across edges (Phase 3)
976/// 6. Metadata annotation validation (Phase 5)
977/// 7. Arrow schema validation (Phase 6)
978/// 8. Strict mode: warn on unannotated ports connected to annotated ports
979pub fn check_type_annotations_full(
980    dataflow: &super::Descriptor,
981    registry: &crate::types::TypeRegistry,
982    strict: bool,
983) -> TypeCheckResult {
984    use crate::types::{CompatibilityGraph, TypeRule};
985
986    let mut warnings = Vec::new();
987    let mut inferences = Vec::new();
988
989    // Build compatibility graph from user rules
990    let user_rules: Vec<TypeRule> = dataflow
991        .type_rules
992        .iter()
993        .map(|r| TypeRule {
994            from: r.from.clone(),
995            to: r.to.clone(),
996        })
997        .collect();
998    let compat = CompatibilityGraph::new(&user_rules);
999
1000    // Map of (source_node_id, output_ref) -> type_urn for cross-edge checking.
1001    // Only outputs whose declared URN *resolves* are registered here.
1002    let mut output_type_map: BTreeMap<(String, String), String> = BTreeMap::new();
1003
1004    // Every (source_node_id, output_ref) that carries *some* output-type
1005    // annotation, including one whose URN does not resolve (a typo). Used to
1006    // suppress the strict-mode "upstream has no type annotation" warning on a
1007    // port that *is* annotated but with an unknown URN — that URN is already
1008    // reported by `check_port_types`, so the extra "no annotation" warning
1009    // would be factually wrong and misdirect the user.
1010    let mut annotated_output_ports: BTreeSet<(String, String)> = BTreeSet::new();
1011
1012    for node in &dataflow.nodes {
1013        let nid = node.id.to_string();
1014
1015        // Check node-level output_types
1016        check_port_types(
1017            &nid,
1018            &node.output_types,
1019            |id| node.outputs.contains(id),
1020            "output",
1021            registry,
1022            &mut warnings,
1023        );
1024        // Register node outputs for cross-edge checking
1025        for (output_id, urn) in &node.output_types {
1026            register_output_type(
1027                &mut output_type_map,
1028                &mut annotated_output_ports,
1029                (nid.clone(), output_id.to_string()),
1030                urn,
1031                registry,
1032            );
1033        }
1034
1035        // Check node-level input_types
1036        check_port_types(
1037            &nid,
1038            &node.input_types,
1039            |id| node.inputs.contains_key(id),
1040            "input",
1041            registry,
1042            &mut warnings,
1043        );
1044
1045        // Check metadata annotations (Phase 5)
1046        check_metadata_annotations(
1047            &nid,
1048            &node.output_metadata,
1049            &node.pattern,
1050            &node.outputs,
1051            &mut warnings,
1052        );
1053
1054        // Check operator output/input types
1055        if let Some(op) = &node.operator {
1056            let op_id = op
1057                .id
1058                .as_ref()
1059                .map(|id| id.to_string())
1060                .unwrap_or_else(|| super::SINGLE_OPERATOR_DEFAULT_ID.to_string());
1061            check_port_types(
1062                &nid,
1063                &op.config.output_types,
1064                |id| op.config.outputs.contains(id),
1065                "output",
1066                registry,
1067                &mut warnings,
1068            );
1069            for (output_id, urn) in &op.config.output_types {
1070                // A consumer of a single-`operator:` node references its output
1071                // in short form (`node/output`) in the source YAML; the `op/`
1072                // prefix is only injected later by
1073                // `resolve_aliases_and_set_defaults`, which this pre-build check
1074                // never runs. Register both the prefixed key and the bare
1075                // `output_id` so short-form edges resolve here too — otherwise
1076                // the upstream type is invisible, silently dropping genuine
1077                // mismatches and, under `strict`, emitting a false "upstream has
1078                // no type annotation" warning.
1079                register_output_type(
1080                    &mut output_type_map,
1081                    &mut annotated_output_ports,
1082                    (nid.clone(), format!("{op_id}/{output_id}")),
1083                    urn,
1084                    registry,
1085                );
1086                register_output_type(
1087                    &mut output_type_map,
1088                    &mut annotated_output_ports,
1089                    (nid.clone(), output_id.to_string()),
1090                    urn,
1091                    registry,
1092                );
1093            }
1094            check_port_types(
1095                &nid,
1096                &op.config.input_types,
1097                |id| op.config.inputs.contains_key(id),
1098                "input",
1099                registry,
1100                &mut warnings,
1101            );
1102            check_metadata_annotations(
1103                &nid,
1104                &op.config.output_metadata,
1105                &op.config.pattern,
1106                &op.config.outputs,
1107                &mut warnings,
1108            );
1109        }
1110        if let Some(runtime) = &node.operators {
1111            for op in &runtime.operators {
1112                let label = format!("{nid}/{}", op.id);
1113                check_port_types(
1114                    &label,
1115                    &op.config.output_types,
1116                    |id| op.config.outputs.contains(id),
1117                    "output",
1118                    registry,
1119                    &mut warnings,
1120                );
1121                for (output_id, urn) in &op.config.output_types {
1122                    register_output_type(
1123                        &mut output_type_map,
1124                        &mut annotated_output_ports,
1125                        (nid.clone(), format!("{}/{output_id}", op.id)),
1126                        urn,
1127                        registry,
1128                    );
1129                }
1130                check_port_types(
1131                    &label,
1132                    &op.config.input_types,
1133                    |id| op.config.inputs.contains_key(id),
1134                    "input",
1135                    registry,
1136                    &mut warnings,
1137                );
1138                check_metadata_annotations(
1139                    &label,
1140                    &op.config.output_metadata,
1141                    &op.config.pattern,
1142                    &op.config.outputs,
1143                    &mut warnings,
1144                );
1145            }
1146        }
1147    }
1148
1149    // Cross-edge type checking + inference (Phase 3 + 4)
1150    // Also check timer inputs against input_types annotations.
1151    for node in &dataflow.nodes {
1152        let nid = node.id.to_string();
1153        let timer_types = timer_input_types(&node.inputs);
1154        check_edge_mismatches_with_compat(
1155            &nid,
1156            &node.input_types,
1157            &node.inputs,
1158            &output_type_map,
1159            &annotated_output_ports,
1160            &timer_types,
1161            &compat,
1162            registry,
1163            strict,
1164            &mut warnings,
1165            &mut inferences,
1166        );
1167
1168        if let Some(op) = &node.operator {
1169            let op_timer = timer_input_types(&op.config.inputs);
1170            check_edge_mismatches_with_compat(
1171                &nid,
1172                &op.config.input_types,
1173                &op.config.inputs,
1174                &output_type_map,
1175                &annotated_output_ports,
1176                &op_timer,
1177                &compat,
1178                registry,
1179                strict,
1180                &mut warnings,
1181                &mut inferences,
1182            );
1183        }
1184        if let Some(runtime) = &node.operators {
1185            for op in &runtime.operators {
1186                let label = format!("{nid}/{}", op.id);
1187                let op_timer = timer_input_types(&op.config.inputs);
1188                check_edge_mismatches_with_compat(
1189                    &label,
1190                    &op.config.input_types,
1191                    &op.config.inputs,
1192                    &output_type_map,
1193                    &annotated_output_ports,
1194                    &op_timer,
1195                    &compat,
1196                    registry,
1197                    strict,
1198                    &mut warnings,
1199                    &mut inferences,
1200                );
1201            }
1202        }
1203    }
1204
1205    TypeCheckResult {
1206        warnings,
1207        inferences,
1208    }
1209}
1210
1211/// Build a map of (input_id -> timer_type) for Timer-mapped inputs.
1212fn timer_input_types(inputs: &BTreeMap<DataId, Input>) -> BTreeMap<DataId, String> {
1213    // Quick check: skip allocation if no timer inputs
1214    if !inputs
1215        .values()
1216        .any(|i| matches!(i.mapping, InputMapping::Timer { .. }))
1217    {
1218        return BTreeMap::new();
1219    }
1220    let mut result = BTreeMap::new();
1221    for (input_id, input) in inputs {
1222        if matches!(input.mapping, InputMapping::Timer { .. }) {
1223            result.insert(input_id.clone(), TIMER_TYPE.to_string());
1224        }
1225    }
1226    result
1227}
1228
1229/// Validate that port type keys exist in the port list and URNs resolve.
1230fn check_port_types(
1231    node_id: &str,
1232    type_map: &BTreeMap<DataId, String>,
1233    contains: impl Fn(&DataId) -> bool,
1234    port_kind: &str,
1235    registry: &crate::types::TypeRegistry,
1236    warnings: &mut Vec<TypeWarning>,
1237) {
1238    for (port_id, urn) in type_map {
1239        if !contains(port_id) {
1240            warnings.push(TypeWarning {
1241                node_id: node_id.to_string(),
1242                message: format!(
1243                    "{port_kind}_types key \"{port_id}\" not found in {port_kind}s list"
1244                ),
1245            });
1246        }
1247        if registry.resolve(urn).is_none() {
1248            let hint = registry
1249                .suggest(urn)
1250                .map(|s| format!(" (did you mean \"{s}\"?)"))
1251                .unwrap_or_default();
1252            warnings.push(TypeWarning {
1253                node_id: node_id.to_string(),
1254                message: format!("unknown type \"{urn}\" on {port_kind} \"{port_id}\"{hint}"),
1255            });
1256        }
1257    }
1258}
1259
1260/// Check metadata annotations on outputs (Phase 5).
1261fn check_metadata_annotations(
1262    node_id: &str,
1263    output_metadata: &BTreeMap<DataId, Vec<String>>,
1264    pattern: &Option<String>,
1265    outputs: &BTreeSet<DataId>,
1266    warnings: &mut Vec<TypeWarning>,
1267) {
1268    // Check explicit output_metadata keys exist in outputs
1269    for output_id in output_metadata.keys() {
1270        if !outputs.contains(output_id) {
1271            warnings.push(TypeWarning {
1272                node_id: node_id.to_string(),
1273                message: format!("output_metadata key \"{output_id}\" not found in outputs list"),
1274            });
1275        }
1276    }
1277
1278    // Validate pattern shorthand
1279    if let Some(pat) = pattern
1280        && crate::types::pattern_metadata_keys(pat).is_none()
1281    {
1282        warnings.push(TypeWarning {
1283            node_id: node_id.to_string(),
1284            message: format!(
1285                "unknown pattern \"{pat}\", expected one of: \
1286                     service-server, service-client, action-server, action-client"
1287            ),
1288        });
1289    }
1290}
1291
1292/// Check cross-edge type mismatches using compatibility graph.
1293///
1294/// Also performs type inference (Phase 3) and strict-mode unannotated checks.
1295/// `timer_types` maps input IDs to auto-injected timer type URNs.
1296#[allow(clippy::too_many_arguments)]
1297fn check_edge_mismatches_with_compat(
1298    node_id: &str,
1299    input_types: &BTreeMap<DataId, String>,
1300    inputs: &BTreeMap<DataId, Input>,
1301    output_type_map: &BTreeMap<(String, String), String>,
1302    annotated_output_ports: &BTreeSet<(String, String)>,
1303    timer_types: &BTreeMap<DataId, String>,
1304    compat: &crate::types::CompatibilityGraph,
1305    registry: &crate::types::TypeRegistry,
1306    strict: bool,
1307    warnings: &mut Vec<TypeWarning>,
1308    inferences: &mut Vec<TypeInference>,
1309) {
1310    for (input_id, input) in inputs {
1311        match &input.mapping {
1312            InputMapping::User(mapping) => {
1313                let key = (mapping.source.to_string(), mapping.output.to_string());
1314                let upstream_urn = output_type_map.get(&key);
1315                let downstream_urn = input_types.get(input_id);
1316
1317                match (upstream_urn, downstream_urn) {
1318                    (Some(out_urn), Some(in_urn)) if !compat.is_compatible(out_urn, in_urn) => {
1319                        let schema_detail = check_schema_compat(out_urn, in_urn, registry);
1320                        let detail = schema_detail.map(|d| format!(" ({d})")).unwrap_or_default();
1321                        warnings.push(TypeWarning {
1322                            node_id: node_id.to_string(),
1323                            message: format!(
1324                                "type mismatch on input \"{input_id}\": \
1325                                 upstream {}/{} declares \"{out_urn}\", \
1326                                 but expected \"{in_urn}\"{detail}",
1327                                mapping.source, mapping.output,
1328                            ),
1329                        });
1330                    }
1331                    (Some(out_urn), None) => {
1332                        inferences.push(TypeInference {
1333                            node_id: node_id.to_string(),
1334                            port_id: input_id.to_string(),
1335                            inferred_urn: out_urn.clone(),
1336                            source: format!("{}/{}", mapping.source, mapping.output),
1337                        });
1338                    }
1339                    // Strict mode, and the upstream has no *resolvable* type.
1340                    // The guard also excludes an upstream that *is* annotated
1341                    // but with an unresolvable URN (a typo): such a port is
1342                    // absent from `output_type_map` yet present in
1343                    // `annotated_output_ports`, so it falls through to `_` with
1344                    // no warning here. `check_port_types` already reports the
1345                    // unknown URN, so a second "has no type annotation" warning
1346                    // would be factually wrong and send the user chasing a
1347                    // missing annotation instead of the typo.
1348                    (None, Some(in_urn)) if strict && !annotated_output_ports.contains(&key) => {
1349                        warnings.push(TypeWarning {
1350                            node_id: node_id.to_string(),
1351                            message: format!(
1352                                "input \"{input_id}\" expects type \"{in_urn}\" but upstream \
1353                                 {}/{} has no type annotation",
1354                                mapping.source, mapping.output,
1355                            ),
1356                        });
1357                    }
1358                    _ => {}
1359                }
1360            }
1361            InputMapping::Timer { .. } => {
1362                // Timer inputs auto-inject std/core/v1/UInt64
1363                if let Some(expected_urn) = input_types.get(input_id) {
1364                    let timer_urn = timer_types
1365                        .get(input_id)
1366                        .map(|s| s.as_str())
1367                        .unwrap_or(TIMER_TYPE);
1368                    if !compat.is_compatible(timer_urn, expected_urn) {
1369                        warnings.push(TypeWarning {
1370                            node_id: node_id.to_string(),
1371                            message: format!(
1372                                "type mismatch on input \"{input_id}\": \
1373                                 timer provides \"{timer_urn}\", \
1374                                 but expected \"{expected_urn}\"",
1375                            ),
1376                        });
1377                    }
1378                }
1379            }
1380            // Log subscriptions deliver JSON strings — skip type checking.
1381            InputMapping::Logs(_) => {}
1382        }
1383    }
1384}
1385
1386/// Check schema-level compatibility for struct types (Phase 6).
1387/// Returns an optional detail string for the error message.
1388fn check_schema_compat(
1389    out_urn: &str,
1390    in_urn: &str,
1391    registry: &crate::types::TypeRegistry,
1392) -> Option<String> {
1393    let out_def = registry.resolve(out_urn)?;
1394    let in_def = registry.resolve(in_urn)?;
1395    let out_schema = out_def.to_arrow_schema_with_registry(registry)?;
1396    let in_schema = in_def.to_arrow_schema_with_registry(registry)?;
1397    // in_schema = expected (consumer), out_schema = actual (producer)
1398    match crate::types::schema_compatible(&in_schema, &out_schema) {
1399        Ok(()) => None,
1400        Err(e) => Some(e.to_string()),
1401    }
1402}
1403
1404fn validate_ros2_type_format(node_id: &NodeId, name: &str, type_str: &str) -> eyre::Result<()> {
1405    // type must be "package/TypeName" format with alphanumeric+underscore parts
1406    let parts: Vec<&str> = type_str.split('/').collect();
1407    if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
1408        bail!(
1409            "node `{node_id}`: invalid type `{type_str}` for `{name}`, \
1410             expected format `package/TypeName` (e.g. `sensor_msgs/Image`)"
1411        );
1412    }
1413    for (label, part) in [("package", parts[0]), ("type name", parts[1])] {
1414        if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1415            bail!(
1416                "node `{node_id}`: invalid {label} `{part}` in type `{type_str}` for `{name}`, \
1417                 only ASCII alphanumeric and underscore characters allowed"
1418            );
1419        }
1420    }
1421    Ok(())
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426    use super::*;
1427    use crate::types::TypeRegistry;
1428    use dora_message::config::{Input, InputMapping};
1429    use dora_message::descriptor::{
1430        Descriptor, RmwZenohCompatibility, Ros2BridgeConfig, Ros2Direction, Ros2Role,
1431        Ros2TopicConfig, Ros2TransportConfig,
1432    };
1433    use std::{path::PathBuf, time::Duration};
1434
1435    fn dummy_input() -> Input {
1436        Input {
1437            mapping: InputMapping::Timer {
1438                interval: Duration::from_secs(1),
1439            },
1440            queue_size: None,
1441            input_timeout: None,
1442            queue_policy: None,
1443        }
1444    }
1445
1446    fn service_config(role: Ros2Role) -> Ros2BridgeConfig {
1447        Ros2BridgeConfig {
1448            service: Some("/add_two_ints".into()),
1449            service_type: Some("example_interfaces/AddTwoInts".into()),
1450            role: Some(role),
1451            ..Default::default()
1452        }
1453    }
1454
1455    fn action_config(role: Ros2Role) -> Ros2BridgeConfig {
1456        Ros2BridgeConfig {
1457            action: Some("/navigate".into()),
1458            action_type: Some("nav2_msgs/NavigateToPose".into()),
1459            role: Some(role),
1460            ..Default::default()
1461        }
1462    }
1463
1464    fn runtime_node() -> ResolvedNode {
1465        serde_yaml::from_str(
1466            r#"
1467id: runtime-node
1468operators:
1469  - id: op1
1470    python: op.py
1471    outputs:
1472      - out
1473"#,
1474        )
1475        .unwrap()
1476    }
1477
1478    fn custom_node() -> dora_message::descriptor::CustomNode {
1479        dora_message::descriptor::CustomNode::new("node".to_string())
1480    }
1481
1482    #[test]
1483    fn timing_fields_accept_finite_non_negative_and_none() {
1484        let id = NodeId::from("n".to_owned());
1485        let mut node = custom_node();
1486        // unset is fine
1487        check_timing_fields(&id, &node).unwrap();
1488        // a large finite grace is fine
1489        node.finish_grace_secs = Some(3600.0);
1490        node.health_check_timeout = Some(0.0);
1491        node.startup_timeout = Some(5.0);
1492        check_timing_fields(&id, &node).unwrap();
1493    }
1494
1495    #[test]
1496    fn timing_fields_reject_negative_finish_grace_secs() {
1497        let id = NodeId::from("n".to_owned());
1498        let mut node = custom_node();
1499        node.finish_grace_secs = Some(-1.0);
1500        let err = check_timing_fields(&id, &node).unwrap_err().to_string();
1501        assert!(
1502            err.contains("finish_grace_secs") && err.contains("non-negative"),
1503            "error should name the field and the constraint, got: {err}"
1504        );
1505    }
1506
1507    #[test]
1508    fn timing_fields_reject_negative_startup_timeout() {
1509        let id = NodeId::from("n".to_owned());
1510        let mut node = custom_node();
1511        node.startup_timeout = Some(-0.5);
1512        let err = check_timing_fields(&id, &node).unwrap_err().to_string();
1513        assert!(
1514            err.contains("startup_timeout") && err.contains("non-negative"),
1515            "error should name the field and the constraint, got: {err}"
1516        );
1517    }
1518
1519    #[test]
1520    fn dynamic_node_rejects_startup_timeout() {
1521        let descriptor: Descriptor = serde_yaml::from_str(
1522            r#"
1523nodes:
1524  - id: dyn
1525    path: dynamic
1526    startup_timeout: 5.0
1527"#,
1528        )
1529        .unwrap();
1530        let err = check_dataflow_static(&descriptor).unwrap_err().to_string();
1531        assert!(
1532            err.contains("dynamic node `dyn` cannot specify `startup_timeout`"),
1533            "error should explain dynamic node startup_timeout rejection, got: {err}"
1534        );
1535    }
1536
1537    /// `check_node_timing_values` is the panic guard the `dora start` path
1538    /// uses, so it must stop at the values `Duration::from_secs_f64` panics
1539    /// on and leave the dynamic-node `startup_timeout` policy rule to
1540    /// `check_node_timing` (`dora node add` / `replace`) and
1541    /// `check_dataflow_static` (`dora run` / `validate`). Were the policy
1542    /// rule in both, `dora start` would reject a descriptor that starts fine
1543    /// on 1.0.x.
1544    #[test]
1545    fn timing_values_check_skips_the_dynamic_node_policy_rule() {
1546        let descriptor: Descriptor = serde_yaml::from_str(
1547            r#"
1548nodes:
1549  - id: dyn
1550    path: dynamic
1551    startup_timeout: 5.0
1552"#,
1553        )
1554        .unwrap();
1555        let nodes = descriptor.resolve_aliases_and_set_defaults().unwrap();
1556        let node = nodes.values().next().unwrap();
1557
1558        check_node_timing_values(node)
1559            .expect("an ignored startup_timeout is not a panic risk, so the spawn path allows it");
1560        let err = check_node_timing(node).unwrap_err().to_string();
1561        assert!(
1562            err.contains("dynamic node `dyn` cannot specify `startup_timeout`"),
1563            "the node-join path keeps the policy rule, got: {err}"
1564        );
1565
1566        // The panic guard itself is unchanged by the split.
1567        let bad: Descriptor = serde_yaml::from_str(
1568            r#"
1569nodes:
1570  - id: dyn
1571    path: dynamic
1572    restart_delay: -1.0
1573"#,
1574        )
1575        .unwrap();
1576        let bad_nodes = bad.resolve_aliases_and_set_defaults().unwrap();
1577        let err = check_node_timing_values(bad_nodes.values().next().unwrap())
1578            .unwrap_err()
1579            .to_string();
1580        assert!(
1581            err.contains("restart_delay"),
1582            "the spawn path still rejects a panicking value, got: {err}"
1583        );
1584    }
1585
1586    #[test]
1587    fn timing_fields_reject_non_finite_values() {
1588        let id = NodeId::from("n".to_owned());
1589        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1590            let mut node = custom_node();
1591            node.health_check_timeout = Some(bad);
1592            let err = check_timing_fields(&id, &node).unwrap_err().to_string();
1593            assert!(
1594                err.contains("health_check_timeout"),
1595                "non-finite {bad} should be rejected, got: {err}"
1596            );
1597        }
1598    }
1599
1600    #[test]
1601    fn seconds_field_accepts_none_zero_and_positive() {
1602        check_seconds_field("owner", "field", None, true).unwrap();
1603        check_seconds_field("owner", "field", Some(0.0), true).unwrap();
1604        check_seconds_field("owner", "field", Some(3600.0), true).unwrap();
1605    }
1606
1607    #[test]
1608    fn seconds_field_rejects_negative_and_non_finite() {
1609        for bad in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1610            let err = check_seconds_field("owner", "field", Some(bad), true)
1611                .unwrap_err()
1612                .to_string();
1613            assert!(
1614                err.contains("field") && err.contains("non-negative"),
1615                "{bad} should be rejected with a field/constraint message, got: {err}"
1616            );
1617        }
1618    }
1619
1620    // Finite, non-negative, but too large for `Duration::from_secs_f64`, which
1621    // would otherwise panic on daemon node spawn (see #2470). `Duration::MAX`'s
1622    // own `as_secs_f64()` rounds *up* past the representable max, so the exact
1623    // boundary value must be rejected too — a `value > MAX` guard would wrongly
1624    // let it through.
1625    #[test]
1626    fn seconds_field_rejects_values_that_overflow_duration() {
1627        for bad in [1e20, Duration::MAX.as_secs_f64()] {
1628            // Confirms the value genuinely trips the daemon's conversion.
1629            assert!(Duration::try_from_secs_f64(bad).is_err());
1630            let err = check_seconds_field("owner", "field", Some(bad), true)
1631                .unwrap_err()
1632                .to_string();
1633            assert!(
1634                err.contains("field") && err.contains("smaller than"),
1635                "overflowing {bad} should be rejected with a field/bound message, got: {err}"
1636            );
1637        }
1638    }
1639
1640    // A large but representable value (well under `Duration::MAX`) stays valid.
1641    #[test]
1642    fn seconds_field_accepts_large_representable_value() {
1643        assert!(Duration::try_from_secs_f64(1e18).is_ok());
1644        check_seconds_field("owner", "field", Some(1e18), true).unwrap();
1645    }
1646
1647    // With `allow_zero: false`, `0.0` must be rejected (in addition to the
1648    // negative / non-finite cases) because such a field reaches
1649    // `tokio::time::interval`, which panics on a zero period.
1650    #[test]
1651    fn seconds_field_rejects_zero_when_positive_required() {
1652        check_seconds_field("owner", "field", None, false).unwrap();
1653        check_seconds_field("owner", "field", Some(3600.0), false).unwrap();
1654        // `1e-10` is finite and positive but `Duration::from_secs_f64` rounds
1655        // it down to `Duration::ZERO`, which would panic `tokio::time::interval`
1656        // just like a literal `0.0`, so it must be rejected too.
1657        for bad in [0.0, 1e-10, -1.0, f64::NAN, f64::INFINITY] {
1658            let err = check_seconds_field("owner", "field", Some(bad), false)
1659                .unwrap_err()
1660                .to_string();
1661            assert!(
1662                err.contains("field") && err.contains("positive"),
1663                "{bad} should be rejected with a field/constraint message, got: {err}"
1664            );
1665        }
1666        // A tiny-but-positive value is fine when zero is allowed (it does not
1667        // reach `tokio::time::interval`).
1668        check_seconds_field("owner", "field", Some(1e-10), true).unwrap();
1669    }
1670
1671    // `health_check_interval` (dataflow-level) reaches `Duration::from_secs_f64`
1672    // and then `tokio::time::interval` in the daemon, so `check_dataflow` must
1673    // reject a non-finite / negative / zero value rather than let the daemon
1674    // panic. A zero interval additionally panics `tokio::time::interval`, so it
1675    // is rejected with a `positive` constraint.
1676    #[test]
1677    fn check_dataflow_rejects_negative_health_check_interval() {
1678        let dataflow = parse_dataflow(
1679            "\
1680health_check_interval: -1.0
1681nodes:
1682  - id: a
1683    path: node_a
1684    build: cargo build
1685    outputs:
1686      - out
1687",
1688        );
1689        let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1690            .unwrap_err()
1691            .to_string();
1692        assert!(
1693            err.contains("health_check_interval") && err.contains("positive"),
1694            "error should name the field and constraint, got: {err}"
1695        );
1696    }
1697
1698    // A zero `health_check_interval` passes `Duration::from_secs_f64` (yielding
1699    // `Duration::ZERO`) but then panics `tokio::time::interval`, so it must be
1700    // rejected up front (regression test for #2752).
1701    #[test]
1702    fn check_dataflow_rejects_zero_health_check_interval() {
1703        let dataflow = parse_dataflow(
1704            "\
1705health_check_interval: 0.0
1706nodes:
1707  - id: a
1708    path: node_a
1709    build: cargo build
1710    outputs:
1711      - out
1712",
1713        );
1714        let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1715            .unwrap_err()
1716            .to_string();
1717        assert!(
1718            err.contains("health_check_interval") && err.contains("positive"),
1719            "error should name the field and constraint, got: {err}"
1720        );
1721    }
1722
1723    // A tiny-but-positive `health_check_interval` (e.g. `1e-10`) is finite and
1724    // non-zero as an `f64`, so a literal `value == 0.0` check would let it
1725    // through -- but `Duration::from_secs_f64` rounds it down to
1726    // `Duration::ZERO`, which panics `tokio::time::interval`. It must be
1727    // rejected up front just like `0.0`.
1728    #[test]
1729    fn check_dataflow_rejects_subnanosecond_health_check_interval() {
1730        let dataflow = parse_dataflow(
1731            "\
1732health_check_interval: 0.0000000001
1733nodes:
1734  - id: a
1735    path: node_a
1736    build: cargo build
1737    outputs:
1738      - out
1739",
1740        );
1741        let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1742            .unwrap_err()
1743            .to_string();
1744        assert!(
1745            err.contains("health_check_interval") && err.contains("positive"),
1746            "error should name the field and constraint, got: {err}"
1747        );
1748    }
1749
1750    // Same guarantee for the per-input `input_timeout`, which the daemon feeds
1751    // to `Duration::from_secs_f64` on both the spawn and reconnect paths.
1752    #[test]
1753    fn check_dataflow_rejects_non_finite_input_timeout() {
1754        let dataflow = parse_dataflow(
1755            "\
1756nodes:
1757  - id: a
1758    path: node_a
1759    build: cargo build
1760    outputs:
1761      - out
1762  - id: b
1763    path: node_b
1764    build: cargo build
1765    inputs:
1766      x:
1767        source: a/out
1768        input_timeout: .inf
1769",
1770        );
1771        let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1772            .unwrap_err()
1773            .to_string();
1774        assert!(
1775            err.contains("input_timeout") && err.contains('x'),
1776            "error should name the offending input and field, got: {err}"
1777        );
1778    }
1779
1780    // A finite `input_timeout` too large for `Duration` must be rejected too:
1781    // `Duration::from_secs_f64` panics on overflow just as it does on `.inf`.
1782    #[test]
1783    fn check_dataflow_rejects_overflowing_input_timeout() {
1784        let dataflow = parse_dataflow(
1785            "\
1786nodes:
1787  - id: a
1788    path: node_a
1789    build: cargo build
1790    outputs:
1791      - out
1792  - id: b
1793    path: node_b
1794    build: cargo build
1795    inputs:
1796      x:
1797        source: a/out
1798        input_timeout: 1e20
1799",
1800        );
1801        let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1802            .unwrap_err()
1803            .to_string();
1804        assert!(
1805            err.contains("input_timeout") && err.contains('x'),
1806            "error should name the offending input and field, got: {err}"
1807        );
1808    }
1809
1810    #[test]
1811    fn check_dataflow_accepts_valid_interval_and_timeout() {
1812        let dataflow = parse_dataflow(
1813            "\
1814health_check_interval: 2.5
1815nodes:
1816  - id: a
1817    path: node_a
1818    build: cargo build
1819    outputs:
1820      - out
1821  - id: b
1822    path: node_b
1823    build: cargo build
1824    inputs:
1825      x:
1826        source: a/out
1827        input_timeout: 0.5
1828",
1829        );
1830        check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test")).unwrap();
1831    }
1832
1833    fn user_input(source: &str, output: &str) -> Input {
1834        Input {
1835            mapping: InputMapping::User(UserInputMapping {
1836                source: NodeId::from(source.to_owned()),
1837                output: DataId::from(output.to_owned()),
1838            }),
1839            queue_size: None,
1840            input_timeout: None,
1841            queue_policy: None,
1842        }
1843    }
1844
1845    #[test]
1846    fn runtime_input_with_operator_segment_is_accepted() {
1847        let node = runtime_node();
1848        let nodes = BTreeMap::from([(node.id.clone(), node)]);
1849        check_input(&user_input("runtime-node", "op1/out"), &nodes, "sink/in").unwrap();
1850    }
1851
1852    #[test]
1853    fn runtime_input_without_operator_segment_reports_expected_format() {
1854        // Mapping `runtime-node/out` (instead of `runtime-node/op1/out`) used
1855        // to fall back to an empty operator id and report that operator ``
1856        // does not exist, hiding the actual mistake.
1857        let node = runtime_node();
1858        let nodes = BTreeMap::from([(node.id.clone(), node)]);
1859        let err = check_input(&user_input("runtime-node", "out"), &nodes, "sink/in")
1860            .unwrap_err()
1861            .to_string();
1862        assert!(
1863            err.contains("runtime-node/<operator_id>/<output_id>"),
1864            "error should explain the expected format, got: {err}"
1865        );
1866    }
1867
1868    // A runtime operator whose artifact is produced by a `build:` command must
1869    // not fail the on-disk source check before the build has run — mirroring the
1870    // custom-node and shared-library behavior. Exercised via the WASM arm so the
1871    // test does not require a Python `dora-rs` runtime (the Python arm carries an
1872    // identical guard).
1873    #[test]
1874    fn operator_with_build_command_skips_missing_source_check() {
1875        let dataflow = parse_dataflow(
1876            "\
1877nodes:
1878  - id: runtime-node
1879    operators:
1880      - id: op1
1881        wasm: does/not/exist.wasm
1882        build: cargo build
1883        outputs:
1884          - out
1885",
1886        );
1887        check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test")).unwrap();
1888    }
1889
1890    #[test]
1891    fn operator_without_build_command_rejects_missing_source() {
1892        let dataflow = parse_dataflow(
1893            "\
1894nodes:
1895  - id: runtime-node
1896    operators:
1897      - id: op1
1898        wasm: does/not/exist.wasm
1899        outputs:
1900          - out
1901",
1902        );
1903        let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1904            .unwrap_err()
1905            .to_string();
1906        assert!(
1907            err.contains("no WASM library"),
1908            "missing source without a build command should still be rejected, got: {err}"
1909        );
1910    }
1911
1912    #[test]
1913    fn validate_no_mode_set() {
1914        let config = Ros2BridgeConfig::default();
1915        let err = validate_ros2_config(
1916            &NodeId::from("n".to_owned()),
1917            &config,
1918            &BTreeMap::new(),
1919            &BTreeSet::new(),
1920        )
1921        .unwrap_err();
1922        assert!(err.to_string().contains("requires one of"));
1923    }
1924
1925    #[test]
1926    fn validate_rejects_empty_ros2_zenoh_config_uri() {
1927        let config = Ros2BridgeConfig {
1928            transport: Ros2TransportConfig::Zenoh {
1929                compatibility: RmwZenohCompatibility::Humble,
1930                config_uri: Some(PathBuf::new()),
1931            },
1932            topic: Some("/t".into()),
1933            message_type: Some("a/B".into()),
1934            ..Default::default()
1935        };
1936        let err = validate_ros2_config(
1937            &NodeId::from("n".to_owned()),
1938            &config,
1939            &BTreeMap::new(),
1940            &BTreeSet::from([DataId::from("out".to_owned())]),
1941        )
1942        .unwrap_err();
1943        assert!(err.to_string().contains("config_uri must not be empty"));
1944    }
1945
1946    fn single_topic_config(topic: &str, direction: Ros2Direction) -> Ros2BridgeConfig {
1947        Ros2BridgeConfig {
1948            topic: Some(topic.into()),
1949            message_type: Some("turtlesim/Pose".into()),
1950            direction,
1951            ..Default::default()
1952        }
1953    }
1954
1955    /// Single-topic mode binds to the node's declared port, so a name that has
1956    /// nothing to do with the topic is correct and must be accepted.
1957    #[test]
1958    fn validate_single_topic_accepts_the_sole_declared_port() {
1959        let config = single_topic_config("/turtle1/pose", Ros2Direction::Subscribe);
1960        validate_ros2_config(
1961            &NodeId::from("n".to_owned()),
1962            &config,
1963            &BTreeMap::new(),
1964            &BTreeSet::from([DataId::from("pose".to_owned())]),
1965        )
1966        .unwrap();
1967    }
1968
1969    /// Several declared outputs leave the binding ambiguous, which the bridge
1970    /// would resolve by silently dropping every message.
1971    #[test]
1972    fn validate_single_topic_rejects_ambiguous_subscribe_output() {
1973        let config = single_topic_config("/turtle1/pose", Ros2Direction::Subscribe);
1974        let err = validate_ros2_config(
1975            &NodeId::from("n".to_owned()),
1976            &config,
1977            &BTreeMap::new(),
1978            &BTreeSet::from([
1979                DataId::from("pose".to_owned()),
1980                DataId::from("log".to_owned()),
1981            ]),
1982        )
1983        .unwrap_err()
1984        .to_string();
1985        assert!(
1986            err.contains("no unambiguous output")
1987                && err.contains("`pose`")
1988                && err.contains("`log`")
1989                && err.contains("turtle1_pose"),
1990            "error should list the candidates and the topic-derived id, got: {err}"
1991        );
1992    }
1993
1994    #[test]
1995    fn validate_multi_topic_rejects_undeclared_subscribe_output() {
1996        // A subscribe topic mapped to an output the node never declares would
1997        // pass validation and then silently drop every message at runtime
1998        // (`DoraNode::send_output` ignores unknown output ids).
1999        let config = Ros2BridgeConfig {
2000            topics: Some(vec![Ros2TopicConfig {
2001                topic: "/scan".into(),
2002                message_type: "sensor_msgs/LaserScan".into(),
2003                direction: Ros2Direction::Subscribe,
2004                output: Some("typo_out".into()),
2005                input: None,
2006                qos: None,
2007            }]),
2008            ..Default::default()
2009        };
2010        let err = validate_ros2_config(
2011            &NodeId::from("n".to_owned()),
2012            &config,
2013            &BTreeMap::new(),
2014            &BTreeSet::from([DataId::from("scan".to_owned())]),
2015        )
2016        .unwrap_err()
2017        .to_string();
2018        assert!(
2019            err.contains("typo_out") && err.contains("not declared"),
2020            "error should name the undeclared output, got: {err}"
2021        );
2022    }
2023
2024    #[test]
2025    fn validate_multi_topic_rejects_undeclared_publish_input() {
2026        let config = Ros2BridgeConfig {
2027            topics: Some(vec![Ros2TopicConfig {
2028                topic: "/cmd_vel".into(),
2029                message_type: "geometry_msgs/Twist".into(),
2030                direction: Ros2Direction::Publish,
2031                output: None,
2032                input: Some("typo_in".into()),
2033                qos: None,
2034            }]),
2035            ..Default::default()
2036        };
2037        let err = validate_ros2_config(
2038            &NodeId::from("n".to_owned()),
2039            &config,
2040            &BTreeMap::from([(DataId::from("cmd".to_owned()), dummy_input())]),
2041            &BTreeSet::new(),
2042        )
2043        .unwrap_err()
2044        .to_string();
2045        assert!(
2046            err.contains("typo_in") && err.contains("not declared"),
2047            "error should name the undeclared input, got: {err}"
2048        );
2049    }
2050
2051    #[test]
2052    fn validate_multi_topic_rejects_undeclared_derived_output() {
2053        // No explicit `output:` — the bridge derives the output id from the
2054        // topic name (`/scan` -> `scan`). A derived id that is not a declared
2055        // output silently drops every message, so validation must reject it.
2056        let config = Ros2BridgeConfig {
2057            topics: Some(vec![Ros2TopicConfig {
2058                topic: "/scan".into(),
2059                message_type: "sensor_msgs/LaserScan".into(),
2060                direction: Ros2Direction::Subscribe,
2061                output: None,
2062                input: None,
2063                qos: None,
2064            }]),
2065            ..Default::default()
2066        };
2067        let err = validate_ros2_config(
2068            &NodeId::from("n".to_owned()),
2069            &config,
2070            &BTreeMap::new(),
2071            &BTreeSet::from([DataId::from("laser".to_owned())]),
2072        )
2073        .unwrap_err()
2074        .to_string();
2075        assert!(
2076            err.contains("scan") && err.contains("not declared"),
2077            "error should name the derived output, got: {err}"
2078        );
2079    }
2080
2081    #[test]
2082    fn validate_multi_topic_accepts_derived_output() {
2083        // The topic-derived id (`/scan` -> `scan`) matches a declared output.
2084        let config = Ros2BridgeConfig {
2085            topics: Some(vec![Ros2TopicConfig {
2086                topic: "/scan".into(),
2087                message_type: "sensor_msgs/LaserScan".into(),
2088                direction: Ros2Direction::Subscribe,
2089                output: None,
2090                input: None,
2091                qos: None,
2092            }]),
2093            ..Default::default()
2094        };
2095        validate_ros2_config(
2096            &NodeId::from("n".to_owned()),
2097            &config,
2098            &BTreeMap::new(),
2099            &BTreeSet::from([DataId::from("scan".to_owned())]),
2100        )
2101        .expect("a topic-derived output matching a declared output should validate");
2102    }
2103
2104    #[test]
2105    fn validate_multi_topic_accepts_declared_ports() {
2106        let config = Ros2BridgeConfig {
2107            topics: Some(vec![
2108                Ros2TopicConfig {
2109                    topic: "/scan".into(),
2110                    message_type: "sensor_msgs/LaserScan".into(),
2111                    direction: Ros2Direction::Subscribe,
2112                    output: Some("scan".into()),
2113                    input: None,
2114                    qos: None,
2115                },
2116                Ros2TopicConfig {
2117                    topic: "/cmd_vel".into(),
2118                    message_type: "geometry_msgs/Twist".into(),
2119                    direction: Ros2Direction::Publish,
2120                    output: None,
2121                    input: Some("cmd".into()),
2122                    qos: None,
2123                },
2124            ]),
2125            ..Default::default()
2126        };
2127        validate_ros2_config(
2128            &NodeId::from("n".to_owned()),
2129            &config,
2130            &BTreeMap::from([(DataId::from("cmd".to_owned()), dummy_input())]),
2131            &BTreeSet::from([DataId::from("scan".to_owned())]),
2132        )
2133        .expect("multi-topic config mapping declared ports should validate");
2134    }
2135
2136    #[test]
2137    fn validate_multiple_modes() {
2138        let config = Ros2BridgeConfig {
2139            topic: Some("/t".into()),
2140            service: Some("/s".into()),
2141            message_type: Some("a/B".into()),
2142            service_type: Some("a/B".into()),
2143            role: Some(Ros2Role::Client),
2144            ..Default::default()
2145        };
2146        let err = validate_ros2_config(
2147            &NodeId::from("n".to_owned()),
2148            &config,
2149            &BTreeMap::new(),
2150            &BTreeSet::new(),
2151        )
2152        .unwrap_err();
2153        assert!(err.to_string().contains("multiple of"));
2154    }
2155
2156    #[test]
2157    fn validate_service_client_ok() {
2158        let config = service_config(Ros2Role::Client);
2159        let mut inputs = BTreeMap::new();
2160        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2161        let mut outputs = BTreeSet::new();
2162        outputs.insert(DataId::from("response".to_owned()));
2163        validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs).unwrap();
2164    }
2165
2166    #[test]
2167    fn validate_service_client_missing_service_type() {
2168        let config = Ros2BridgeConfig {
2169            service: Some("/svc".into()),
2170            role: Some(Ros2Role::Client),
2171            ..Default::default()
2172        };
2173        let mut inputs = BTreeMap::new();
2174        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2175        let mut outputs = BTreeSet::new();
2176        outputs.insert(DataId::from("response".to_owned()));
2177        let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2178            .unwrap_err();
2179        assert!(err.to_string().contains("service_type"));
2180    }
2181
2182    #[test]
2183    fn validate_service_client_missing_role() {
2184        let config = Ros2BridgeConfig {
2185            service: Some("/svc".into()),
2186            service_type: Some("a/B".into()),
2187            ..Default::default()
2188        };
2189        let mut inputs = BTreeMap::new();
2190        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2191        let mut outputs = BTreeSet::new();
2192        outputs.insert(DataId::from("response".to_owned()));
2193        let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2194            .unwrap_err();
2195        assert!(err.to_string().contains("role"));
2196    }
2197
2198    #[test]
2199    fn validate_service_client_no_inputs() {
2200        let config = service_config(Ros2Role::Client);
2201        let mut outputs = BTreeSet::new();
2202        outputs.insert(DataId::from("response".to_owned()));
2203        let err = validate_ros2_config(
2204            &NodeId::from("n".to_owned()),
2205            &config,
2206            &BTreeMap::new(),
2207            &outputs,
2208        )
2209        .unwrap_err();
2210        assert!(err.to_string().contains("input"));
2211    }
2212
2213    #[test]
2214    fn validate_service_client_no_outputs() {
2215        let config = service_config(Ros2Role::Client);
2216        let mut inputs = BTreeMap::new();
2217        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2218        let err = validate_ros2_config(
2219            &NodeId::from("n".to_owned()),
2220            &config,
2221            &inputs,
2222            &BTreeSet::new(),
2223        )
2224        .unwrap_err();
2225        assert!(err.to_string().contains("output"));
2226    }
2227
2228    #[test]
2229    fn validate_service_server_ok() {
2230        let config = service_config(Ros2Role::Server);
2231        let mut inputs = BTreeMap::new();
2232        inputs.insert(DataId::from("response".to_owned()), dummy_input());
2233        let mut outputs = BTreeSet::new();
2234        outputs.insert(DataId::from("request".to_owned()));
2235        validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs).unwrap();
2236    }
2237
2238    #[test]
2239    fn validate_service_server_no_inputs() {
2240        let config = service_config(Ros2Role::Server);
2241        let mut outputs = BTreeSet::new();
2242        outputs.insert(DataId::from("request".to_owned()));
2243        let err = validate_ros2_config(
2244            &NodeId::from("n".to_owned()),
2245            &config,
2246            &BTreeMap::new(),
2247            &outputs,
2248        )
2249        .unwrap_err();
2250        assert!(err.to_string().contains("input"));
2251    }
2252
2253    #[test]
2254    fn validate_service_server_no_outputs() {
2255        let config = service_config(Ros2Role::Server);
2256        let mut inputs = BTreeMap::new();
2257        inputs.insert(DataId::from("response".to_owned()), dummy_input());
2258        let err = validate_ros2_config(
2259            &NodeId::from("n".to_owned()),
2260            &config,
2261            &inputs,
2262            &BTreeSet::new(),
2263        )
2264        .unwrap_err();
2265        assert!(err.to_string().contains("output"));
2266    }
2267
2268    #[test]
2269    fn validate_action_client_ok() {
2270        let config = action_config(Ros2Role::Client);
2271        let mut inputs = BTreeMap::new();
2272        inputs.insert(DataId::from("goal".to_owned()), dummy_input());
2273        let mut outputs = BTreeSet::new();
2274        outputs.insert(DataId::from("feedback".to_owned()));
2275        outputs.insert(DataId::from("result".to_owned()));
2276        validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs).unwrap();
2277    }
2278
2279    #[test]
2280    fn validate_action_client_missing_action_type() {
2281        let config = Ros2BridgeConfig {
2282            action: Some("/nav".into()),
2283            role: Some(Ros2Role::Client),
2284            ..Default::default()
2285        };
2286        let mut inputs = BTreeMap::new();
2287        inputs.insert(DataId::from("goal".to_owned()), dummy_input());
2288        let mut outputs = BTreeSet::new();
2289        outputs.insert(DataId::from("feedback".to_owned()));
2290        let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2291            .unwrap_err();
2292        assert!(err.to_string().contains("action_type"));
2293    }
2294
2295    #[test]
2296    fn validate_action_client_no_inputs() {
2297        let config = action_config(Ros2Role::Client);
2298        let mut outputs = BTreeSet::new();
2299        outputs.insert(DataId::from("feedback".to_owned()));
2300        let err = validate_ros2_config(
2301            &NodeId::from("n".to_owned()),
2302            &config,
2303            &BTreeMap::new(),
2304            &outputs,
2305        )
2306        .unwrap_err();
2307        assert!(err.to_string().contains("input"));
2308    }
2309
2310    #[test]
2311    fn validate_action_client_no_outputs() {
2312        let config = action_config(Ros2Role::Client);
2313        let mut inputs = BTreeMap::new();
2314        inputs.insert(DataId::from("goal".to_owned()), dummy_input());
2315        let err = validate_ros2_config(
2316            &NodeId::from("n".to_owned()),
2317            &config,
2318            &inputs,
2319            &BTreeSet::new(),
2320        )
2321        .unwrap_err();
2322        assert!(err.to_string().contains("output"));
2323    }
2324
2325    #[test]
2326    fn validate_action_server_ok() {
2327        let config = action_config(Ros2Role::Server);
2328        let mut inputs = BTreeMap::new();
2329        inputs.insert(DataId::from("feedback".to_owned()), dummy_input());
2330        let mut outputs = BTreeSet::new();
2331        outputs.insert(DataId::from("goal".to_owned()));
2332        validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs).unwrap();
2333    }
2334
2335    #[test]
2336    fn validate_action_server_no_inputs() {
2337        let config = action_config(Ros2Role::Server);
2338        let mut outputs = BTreeSet::new();
2339        outputs.insert(DataId::from("goal".to_owned()));
2340        let err = validate_ros2_config(
2341            &NodeId::from("n".to_owned()),
2342            &config,
2343            &BTreeMap::new(),
2344            &outputs,
2345        )
2346        .unwrap_err();
2347        assert!(err.to_string().contains("input"));
2348    }
2349
2350    #[test]
2351    fn validate_action_server_no_outputs() {
2352        let config = action_config(Ros2Role::Server);
2353        let mut inputs = BTreeMap::new();
2354        inputs.insert(DataId::from("feedback".to_owned()), dummy_input());
2355        let err = validate_ros2_config(
2356            &NodeId::from("n".to_owned()),
2357            &config,
2358            &inputs,
2359            &BTreeSet::new(),
2360        )
2361        .unwrap_err();
2362        assert!(err.to_string().contains("output"));
2363    }
2364
2365    #[test]
2366    fn validate_bad_type_format() {
2367        let config = Ros2BridgeConfig {
2368            service: Some("/svc".into()),
2369            service_type: Some("invalid_no_slash".into()),
2370            role: Some(Ros2Role::Client),
2371            ..Default::default()
2372        };
2373        let mut inputs = BTreeMap::new();
2374        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2375        let mut outputs = BTreeSet::new();
2376        outputs.insert(DataId::from("response".to_owned()));
2377        let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2378            .unwrap_err();
2379        assert!(err.to_string().contains("package/TypeName"));
2380    }
2381
2382    #[test]
2383    fn validate_type_rejects_special_chars() {
2384        let config = Ros2BridgeConfig {
2385            service: Some("/svc".into()),
2386            service_type: Some("pkg-bad/Evil".into()),
2387            role: Some(Ros2Role::Client),
2388            ..Default::default()
2389        };
2390        let mut inputs = BTreeMap::new();
2391        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2392        let mut outputs = BTreeSet::new();
2393        outputs.insert(DataId::from("response".to_owned()));
2394        let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2395            .unwrap_err();
2396        assert!(err.to_string().contains("alphanumeric"));
2397    }
2398
2399    #[test]
2400    fn ros2_name_accepts_valid() {
2401        let node = NodeId::from("n".to_owned());
2402        for name in [
2403            "topic",
2404            "/topic",
2405            "/a/b/c",
2406            "/add_two_ints",
2407            "ns/sub_topic",
2408            "/navigate",
2409            "_hidden",
2410            "/_internal/state",
2411            "/_ros2cli_node",
2412        ] {
2413            validate_ros2_name(&node, "topic", name)
2414                .unwrap_or_else(|e| panic!("`{name}` should be valid: {e}"));
2415        }
2416    }
2417
2418    #[test]
2419    fn ros2_name_rejects_double_leading_slash() {
2420        let node = NodeId::from("n".to_owned());
2421        let err = validate_ros2_name(&node, "topic", "//topic").unwrap_err();
2422        assert!(err.to_string().contains("consecutive slashes"));
2423    }
2424
2425    #[test]
2426    fn ros2_name_rejects_trailing_slash() {
2427        let node = NodeId::from("n".to_owned());
2428        let err = validate_ros2_name(&node, "topic", "topic/").unwrap_err();
2429        assert!(err.to_string().contains("end with"));
2430    }
2431
2432    #[test]
2433    fn ros2_name_rejects_consecutive_interior_slashes() {
2434        let node = NodeId::from("n".to_owned());
2435        let err = validate_ros2_name(&node, "topic", "topic//sub").unwrap_err();
2436        assert!(err.to_string().contains("consecutive slashes"));
2437    }
2438
2439    #[test]
2440    fn ros2_name_rejects_bare_slash() {
2441        let node = NodeId::from("n".to_owned());
2442        let err = validate_ros2_name(&node, "topic", "/").unwrap_err();
2443        assert!(err.to_string().contains("bare"));
2444    }
2445
2446    #[test]
2447    fn ros2_name_accepts_leading_underscore() {
2448        let node = NodeId::from("n".to_owned());
2449        for name in ["_hidden", "/_internal/state", "/_ros2cli_node"] {
2450            validate_ros2_name(&node, "topic", name)
2451                .unwrap_or_else(|e| panic!("`{name}` should be valid (hidden topic): {e}"));
2452        }
2453    }
2454
2455    #[test]
2456    fn ros2_name_rejects_token_starting_with_digit() {
2457        let node = NodeId::from("n".to_owned());
2458        let err = validate_ros2_name(&node, "topic", "/2bad").unwrap_err();
2459        assert!(err.to_string().contains("digit"));
2460    }
2461
2462    #[test]
2463    fn validate_qos_bad_durability() {
2464        let config = Ros2BridgeConfig {
2465            service: Some("/svc".into()),
2466            service_type: Some("a/B".into()),
2467            role: Some(Ros2Role::Client),
2468            qos: dora_message::descriptor::Ros2QosConfig {
2469                durability: Some("persistent".into()),
2470                ..Default::default()
2471            },
2472            ..Default::default()
2473        };
2474        let mut inputs = BTreeMap::new();
2475        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2476        let mut outputs = BTreeSet::new();
2477        outputs.insert(DataId::from("response".to_owned()));
2478        let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2479            .unwrap_err();
2480        assert!(err.to_string().contains("durability"));
2481    }
2482
2483    #[test]
2484    fn validate_qos_keep_last_out_of_range() {
2485        let config = Ros2BridgeConfig {
2486            service: Some("/svc".into()),
2487            service_type: Some("a/B".into()),
2488            role: Some(Ros2Role::Client),
2489            qos: dora_message::descriptor::Ros2QosConfig {
2490                keep_last: Some(100_000),
2491                ..Default::default()
2492            },
2493            ..Default::default()
2494        };
2495        let mut inputs = BTreeMap::new();
2496        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2497        let mut outputs = BTreeSet::new();
2498        outputs.insert(DataId::from("response".to_owned()));
2499        let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2500            .unwrap_err();
2501        assert!(err.to_string().contains("keep_last"));
2502    }
2503
2504    // --- Type annotation tests ---
2505
2506    fn parse_dataflow(yaml: &str) -> Descriptor {
2507        serde_yaml::from_str(yaml).expect("test YAML should parse")
2508    }
2509
2510    #[test]
2511    fn type_check_no_annotations_no_warnings() {
2512        let dataflow = parse_dataflow("nodes:\n  - id: a\n");
2513        let reg = TypeRegistry::new();
2514        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2515        assert!(warnings.is_empty());
2516    }
2517
2518    #[test]
2519    fn type_check_valid_output_type() {
2520        let dataflow = parse_dataflow(
2521            "nodes:\n  - id: camera\n    outputs:\n      - image\n    output_types:\n      image: std/media/v1/Image\n",
2522        );
2523        let reg = TypeRegistry::new();
2524        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2525        assert!(warnings.is_empty());
2526    }
2527
2528    #[test]
2529    fn type_check_output_types_key_not_in_outputs() {
2530        let dataflow = parse_dataflow(
2531            "nodes:\n  - id: camera\n    output_types:\n      image: std/media/v1/Image\n",
2532        );
2533        let reg = TypeRegistry::new();
2534        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2535        assert_eq!(warnings.len(), 1);
2536        assert!(warnings[0].message.contains("not found in outputs"));
2537    }
2538
2539    #[test]
2540    fn type_check_unknown_urn_with_suggestion() {
2541        let dataflow = parse_dataflow(
2542            "nodes:\n  - id: camera\n    outputs:\n      - image\n    output_types:\n      image: std/media/v1/Imag\n",
2543        );
2544        let reg = TypeRegistry::new();
2545        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2546        assert_eq!(warnings.len(), 1);
2547        assert!(warnings[0].message.contains("unknown type"));
2548        assert!(warnings[0].message.contains("did you mean"));
2549    }
2550
2551    #[test]
2552    fn type_check_matching_edge_types() {
2553        let dataflow = parse_dataflow(
2554            "\
2555nodes:
2556  - id: sender
2557    outputs:
2558      - data
2559    output_types:
2560      data: std/core/v1/Float32
2561  - id: receiver
2562    inputs:
2563      data: sender/data
2564    input_types:
2565      data: std/core/v1/Float32
2566",
2567        );
2568        let reg = TypeRegistry::new();
2569        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2570        assert!(warnings.is_empty());
2571    }
2572
2573    #[test]
2574    fn type_check_mismatched_edge_types() {
2575        let dataflow = parse_dataflow(
2576            "\
2577nodes:
2578  - id: sender
2579    outputs:
2580      - data
2581    output_types:
2582      data: std/core/v1/Float32
2583  - id: receiver
2584    inputs:
2585      data: sender/data
2586    input_types:
2587      data: std/media/v1/Image
2588",
2589        );
2590        let reg = TypeRegistry::new();
2591        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2592        assert_eq!(warnings.len(), 1);
2593        assert!(warnings[0].message.contains("type mismatch"));
2594        assert!(warnings[0].message.contains("Float32"));
2595        assert!(warnings[0].message.contains("Image"));
2596    }
2597
2598    // --- Phase 1: strict mode tests ---
2599
2600    #[test]
2601    fn strict_types_parses_in_yaml() {
2602        let dataflow = parse_dataflow("nodes:\n  - id: a\nstrict_types: true\n");
2603        assert_eq!(dataflow.strict_types, Some(true));
2604    }
2605
2606    #[test]
2607    fn strict_mode_warns_on_unannotated_upstream() {
2608        let dataflow = parse_dataflow(
2609            "\
2610nodes:
2611  - id: sender
2612    outputs:
2613      - data
2614  - id: receiver
2615    inputs:
2616      data: sender/data
2617    input_types:
2618      data: std/core/v1/Float32
2619",
2620        );
2621        let reg = TypeRegistry::new();
2622        let result = check_type_annotations_full(&dataflow, &reg, true);
2623        assert!(!result.warnings.is_empty());
2624        assert!(result.warnings[0].message.contains("no type annotation"));
2625    }
2626
2627    /// Regression: an upstream output that *is* annotated but with an
2628    /// unresolvable URN (a typo) must not trigger the strict-mode "upstream
2629    /// has no type annotation" warning. The unknown URN is already reported by
2630    /// the port-type check; a second "no annotation" warning would be
2631    /// factually wrong and send the user chasing a missing annotation instead
2632    /// of the typo.
2633    #[test]
2634    fn strict_mode_no_missing_annotation_warning_for_typoed_upstream() {
2635        let dataflow = parse_dataflow(
2636            "\
2637nodes:
2638  - id: sender
2639    outputs:
2640      - image
2641    output_types:
2642      image: std/media/v1/Imag
2643  - id: receiver
2644    inputs:
2645      image: sender/image
2646    input_types:
2647      image: std/media/v1/Image
2648",
2649        );
2650        let reg = TypeRegistry::new();
2651        let result = check_type_annotations_full(&dataflow, &reg, true);
2652        // The typo itself is still reported...
2653        assert!(
2654            result
2655                .warnings
2656                .iter()
2657                .any(|w| w.message.contains("unknown type")),
2658            "expected an unknown-type warning for the typo, got: {:?}",
2659            result.warnings
2660        );
2661        // ...but the misleading "no type annotation" warning must not appear:
2662        // the upstream *is* annotated, just misspelled.
2663        assert!(
2664            !result
2665                .warnings
2666                .iter()
2667                .any(|w| w.message.contains("no type annotation")),
2668            "must not claim the annotated-but-typoed upstream has no annotation, got: {:?}",
2669            result.warnings
2670        );
2671    }
2672
2673    #[test]
2674    fn non_strict_no_warning_on_unannotated_upstream() {
2675        let dataflow = parse_dataflow(
2676            "\
2677nodes:
2678  - id: sender
2679    outputs:
2680      - data
2681  - id: receiver
2682    inputs:
2683      data: sender/data
2684    input_types:
2685      data: std/core/v1/Float32
2686",
2687        );
2688        let reg = TypeRegistry::new();
2689        let result = check_type_annotations_full(&dataflow, &reg, false);
2690        assert!(result.warnings.is_empty());
2691    }
2692
2693    // --- Phase 3: type inference tests ---
2694
2695    #[test]
2696    fn inference_from_annotated_upstream() {
2697        let dataflow = parse_dataflow(
2698            "\
2699nodes:
2700  - id: sensor
2701    outputs:
2702      - reading
2703    output_types:
2704      reading: std/core/v1/Float64
2705  - id: processor
2706    inputs:
2707      reading: sensor/reading
2708",
2709        );
2710        let reg = TypeRegistry::new();
2711        let result = check_type_annotations_full(&dataflow, &reg, false);
2712        assert!(result.warnings.is_empty());
2713        assert_eq!(result.inferences.len(), 1);
2714        assert_eq!(result.inferences[0].inferred_urn, "std/core/v1/Float64");
2715        assert_eq!(result.inferences[0].port_id, "reading");
2716    }
2717
2718    #[test]
2719    fn no_inference_when_both_annotated() {
2720        let dataflow = parse_dataflow(
2721            "\
2722nodes:
2723  - id: sender
2724    outputs:
2725      - data
2726    output_types:
2727      data: std/core/v1/Float32
2728  - id: receiver
2729    inputs:
2730      data: sender/data
2731    input_types:
2732      data: std/core/v1/Float32
2733",
2734        );
2735        let reg = TypeRegistry::new();
2736        let result = check_type_annotations_full(&dataflow, &reg, false);
2737        assert!(result.inferences.is_empty());
2738    }
2739
2740    #[test]
2741    fn no_inference_when_neither_annotated() {
2742        let dataflow = parse_dataflow(
2743            "\
2744nodes:
2745  - id: sender
2746    outputs:
2747      - data
2748  - id: receiver
2749    inputs:
2750      data: sender/data
2751",
2752        );
2753        let reg = TypeRegistry::new();
2754        let result = check_type_annotations_full(&dataflow, &reg, false);
2755        assert!(result.inferences.is_empty());
2756    }
2757
2758    // --- Phase 4: compatibility tests ---
2759
2760    #[test]
2761    fn compat_uint8_to_uint32_edge() {
2762        let dataflow = parse_dataflow(
2763            "\
2764nodes:
2765  - id: sender
2766    outputs:
2767      - data
2768    output_types:
2769      data: std/core/v1/UInt8
2770  - id: receiver
2771    inputs:
2772      data: sender/data
2773    input_types:
2774      data: std/core/v1/UInt32
2775",
2776        );
2777        let reg = TypeRegistry::new();
2778        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2779        assert!(warnings.is_empty(), "UInt8 -> UInt32 should be compatible");
2780    }
2781
2782    #[test]
2783    fn compat_any_to_bytes_edge() {
2784        let dataflow = parse_dataflow(
2785            "\
2786nodes:
2787  - id: sender
2788    outputs:
2789      - data
2790    output_types:
2791      data: std/media/v1/Image
2792  - id: receiver
2793    inputs:
2794      data: sender/data
2795    input_types:
2796      data: std/core/v1/Bytes
2797",
2798        );
2799        let reg = TypeRegistry::new();
2800        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2801        assert!(
2802            warnings.is_empty(),
2803            "anything -> Bytes should be compatible"
2804        );
2805    }
2806
2807    #[test]
2808    fn compat_user_defined_rule_in_yaml() {
2809        let dataflow = parse_dataflow(
2810            "\
2811type_rules:
2812  - from: std/core/v1/UInt8
2813    to: std/core/v1/String
2814nodes:
2815  - id: sender
2816    outputs:
2817      - data
2818    output_types:
2819      data: std/core/v1/UInt8
2820  - id: receiver
2821    inputs:
2822      data: sender/data
2823    input_types:
2824      data: std/core/v1/String
2825",
2826        );
2827        let reg = TypeRegistry::new();
2828        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2829        assert!(
2830            warnings.is_empty(),
2831            "user-defined rule should make this compatible"
2832        );
2833    }
2834
2835    // --- Phase 5: metadata tests ---
2836
2837    #[test]
2838    fn metadata_pattern_resolves() {
2839        let dataflow = parse_dataflow(
2840            "\
2841nodes:
2842  - id: srv
2843    pattern: service-server
2844    outputs:
2845      - response
2846",
2847        );
2848        let reg = TypeRegistry::new();
2849        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2850        assert!(warnings.is_empty());
2851    }
2852
2853    #[test]
2854    fn metadata_unknown_pattern() {
2855        let dataflow = parse_dataflow(
2856            "\
2857nodes:
2858  - id: srv
2859    pattern: unknown-pattern
2860    outputs:
2861      - response
2862",
2863        );
2864        let reg = TypeRegistry::new();
2865        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2866        assert_eq!(warnings.len(), 1);
2867        assert!(warnings[0].message.contains("unknown pattern"));
2868    }
2869
2870    #[test]
2871    fn metadata_output_key_not_in_outputs() {
2872        let dataflow = parse_dataflow(
2873            "\
2874nodes:
2875  - id: srv
2876    output_metadata:
2877      missing_port: [request_id]
2878    outputs:
2879      - response
2880",
2881        );
2882        let reg = TypeRegistry::new();
2883        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2884        assert_eq!(warnings.len(), 1);
2885        assert!(warnings[0].message.contains("output_metadata key"));
2886    }
2887
2888    // --- Timer auto-inject test ---
2889
2890    #[test]
2891    fn timer_input_type_mismatch() {
2892        let dataflow = parse_dataflow(
2893            "\
2894nodes:
2895  - id: node
2896    inputs:
2897      tick: dora/timer/millis/100
2898    input_types:
2899      tick: std/media/v1/Image
2900",
2901        );
2902        let reg = TypeRegistry::new();
2903        let warnings = check_type_annotations_full(&dataflow, &reg, false).warnings;
2904        assert!(!warnings.is_empty());
2905        assert!(warnings[0].message.contains("type mismatch"));
2906    }
2907
2908    #[test]
2909    fn validate_qos_negative_lease_duration() {
2910        let config = Ros2BridgeConfig {
2911            service: Some("/svc".into()),
2912            service_type: Some("a/B".into()),
2913            role: Some(Ros2Role::Client),
2914            qos: dora_message::descriptor::Ros2QosConfig {
2915                lease_duration: Some(-1.0),
2916                ..Default::default()
2917            },
2918            ..Default::default()
2919        };
2920        let mut inputs = BTreeMap::new();
2921        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2922        let mut outputs = BTreeSet::new();
2923        outputs.insert(DataId::from("response".to_owned()));
2924        let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2925            .unwrap_err();
2926        assert!(err.to_string().contains("lease_duration"));
2927    }
2928
2929    /// A finite but enormous QoS duration passes the old
2930    /// `is_finite()` / `>= 0` check yet overflows `Duration`, which panics
2931    /// `Duration::from_secs_f64` in the ROS2 bridge QoS conversion. Both
2932    /// fields must be rejected at descriptor validation instead.
2933    #[test]
2934    fn validate_qos_overflowing_durations_are_rejected() {
2935        let base = Ros2BridgeConfig {
2936            service: Some("/svc".into()),
2937            service_type: Some("a/B".into()),
2938            role: Some(Ros2Role::Client),
2939            ..Default::default()
2940        };
2941        let mut inputs = BTreeMap::new();
2942        inputs.insert(DataId::from("request".to_owned()), dummy_input());
2943        let mut outputs = BTreeSet::new();
2944        outputs.insert(DataId::from("response".to_owned()));
2945
2946        // `1e300` seconds is finite and positive but far beyond `Duration::MAX`.
2947        for (field, qos) in [
2948            (
2949                "lease_duration",
2950                dora_message::descriptor::Ros2QosConfig {
2951                    lease_duration: Some(1e300),
2952                    ..Default::default()
2953                },
2954            ),
2955            (
2956                "max_blocking_time",
2957                dora_message::descriptor::Ros2QosConfig {
2958                    reliable: true,
2959                    max_blocking_time: Some(1e300),
2960                    ..Default::default()
2961                },
2962            ),
2963        ] {
2964            let config = Ros2BridgeConfig {
2965                qos,
2966                ..base.clone()
2967            };
2968            let err =
2969                validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2970                    .unwrap_err();
2971            assert!(
2972                err.to_string().contains(field),
2973                "expected error naming `{field}`, got: {err}"
2974            );
2975        }
2976    }
2977
2978    // --- Wiring validation tests ---
2979
2980    #[test]
2981    fn wiring_valid_dataflow() {
2982        let yaml = r#"
2983nodes:
2984  - id: source
2985    path: source.py
2986    outputs:
2987      - data
2988  - id: sink
2989    path: sink.py
2990    inputs:
2991      data: source/data
2992"#;
2993        let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
2994        check_wiring(&descriptor).unwrap();
2995    }
2996
2997    /// Every shipped `ros2:` example must satisfy the port-mapping rule the
2998    /// bridge binds by. The docs present these as working dataflows, and a
2999    /// mismatch is silent data loss at runtime rather than a startup error, so
3000    /// parsing them is not enough — they have to be validated.
3001    #[test]
3002    fn ros2_example_dataflows_have_valid_port_mappings() {
3003        let examples = [
3004            "examples/ros2-bridge/yaml-bridge/dataflow.yml",
3005            "examples/ros2-bridge/yaml-bridge/dataflow-zenoh.yml",
3006            "examples/ros2-bridge/yaml-bridge-service/dataflow-client.yml",
3007            "examples/ros2-bridge/yaml-bridge-service/dataflow-client-zenoh.yml",
3008            "examples/ros2-bridge/yaml-bridge-service/dataflow-server.yml",
3009            "examples/ros2-bridge/yaml-bridge-service/dataflow-server-zenoh.yml",
3010            "examples/ros2-bridge/yaml-bridge-action/dataflow.yml",
3011            "examples/ros2-bridge/yaml-bridge-action/dataflow-zenoh.yml",
3012            "examples/ros2-bridge/yaml-bridge-action-server/dataflow.yml",
3013        ];
3014        for relative in examples {
3015            let Some(yaml) = repo_fixture(relative) else {
3016                continue; // packaged crate: the examples tree is not shipped
3017            };
3018            let descriptor: Descriptor = serde_yaml::from_str(&yaml).unwrap();
3019            if let Err(err) = validate_ros2_configs(&descriptor) {
3020                panic!("shipped example `{relative}` has an invalid ros2 config: {err}");
3021            }
3022        }
3023    }
3024
3025    /// Reads a fixture that lives outside this crate, or `None` when this is
3026    /// not a repository checkout. `include_str!` would be the obvious choice,
3027    /// but a path that leaves the crate directory is not in the published
3028    /// `.crate`, so the crate would fail to *compile* its tests for anyone
3029    /// building from crates.io (#3400).
3030    ///
3031    /// The workspace manifest is the marker for "we are in the repo". Only
3032    /// its absence skips: inside a checkout a missing fixture is a stale
3033    /// path and panics, so this keeps the one property `include_str!` had
3034    /// that a plain `.ok()` would throw away.
3035    fn repo_fixture(relative: &str) -> Option<String> {
3036        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
3037        if !root.join("Cargo.toml").is_file() {
3038            return None;
3039        }
3040        let path = root.join(relative);
3041        Some(
3042            std::fs::read_to_string(&path)
3043                .unwrap_or_else(|e| panic!("fixture {} is missing: {e}", path.display())),
3044        )
3045    }
3046
3047    #[test]
3048    fn ros2_zenoh_documentation_examples_parse_with_explicit_profiles() {
3049        let examples = [
3050            "examples/ros2-bridge/yaml-bridge/dataflow-zenoh.yml",
3051            "examples/ros2-bridge/yaml-bridge-service/dataflow-client-zenoh.yml",
3052            "examples/ros2-bridge/yaml-bridge-action/dataflow-zenoh.yml",
3053        ];
3054        for relative in examples {
3055            let Some(yaml) = repo_fixture(relative) else {
3056                continue; // packaged crate: the examples tree is not shipped
3057            };
3058            let descriptor: Descriptor = serde_yaml::from_str(&yaml).unwrap();
3059            let ros2 = descriptor
3060                .nodes
3061                .iter()
3062                .find_map(|node| node.ros2.as_ref())
3063                .unwrap();
3064            assert!(matches!(
3065                ros2.transport,
3066                Ros2TransportConfig::Zenoh {
3067                    compatibility: RmwZenohCompatibility::Humble,
3068                    ..
3069                }
3070            ));
3071        }
3072    }
3073
3074    #[test]
3075    fn ros2_zenoh_documentation_links_upstream_wire_contract() {
3076        let Some(guide) = repo_fixture("guide/src/advanced/ros2-bridge.md") else {
3077            return; // packaged crate: the guide is not shipped
3078        };
3079        assert!(guide.contains("https://github.com/ros2/rmw_zenoh/blob/rolling/docs/design.md"));
3080        assert!(guide.contains("https://www.ros.org/reps/rep-2016.html"));
3081    }
3082
3083    #[test]
3084    fn wiring_rejects_nonexistent_source_node() {
3085        let yaml = r#"
3086nodes:
3087  - id: sink
3088    path: sink.py
3089    inputs:
3090      data: nonexistent/data
3091"#;
3092        let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3093        let err = check_wiring(&descriptor).unwrap_err();
3094        assert!(
3095            err.to_string().contains("nonexistent"),
3096            "expected error about missing node, got: {err}"
3097        );
3098    }
3099
3100    #[test]
3101    fn wiring_rejects_nonexistent_output() {
3102        let yaml = r#"
3103nodes:
3104  - id: source
3105    path: source.py
3106    outputs:
3107      - data
3108  - id: sink
3109    path: sink.py
3110    inputs:
3111      data: source/typo
3112"#;
3113        let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3114        let err = check_wiring(&descriptor).unwrap_err();
3115        assert!(
3116            err.to_string().contains("typo"),
3117            "expected error about missing output, got: {err}"
3118        );
3119    }
3120
3121    #[test]
3122    fn wiring_runtime_input_id_order() {
3123        // A runtime node's operator input that references a missing source
3124        // must report the input in the conventional
3125        // `{node_id}/{operator_id}/{input_id}` order (see #2019).
3126        let yaml = r#"
3127nodes:
3128  - id: runtime-node
3129    operators:
3130      - id: my-operator
3131        shared-library: op
3132        inputs:
3133          tick: nonexistent/data
3134        outputs:
3135          - status
3136"#;
3137        let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3138        let err = check_wiring(&descriptor).unwrap_err();
3139        let msg = err.to_string();
3140        assert!(
3141            msg.contains("runtime-node/my-operator/tick"),
3142            "expected node/operator/input order, got: {msg}"
3143        );
3144        assert!(
3145            !msg.contains("my-operator/runtime-node/tick"),
3146            "input id should not use reversed operator/node order, got: {msg}"
3147        );
3148    }
3149
3150    // --- Single-`operator:` producer edges (short-form references) ---
3151    //
3152    // A consumer of a single-`operator:` node references its output in short
3153    // form (`node/output`) in the source YAML. The `op/` prefix is only
3154    // injected later by `resolve_aliases_and_set_defaults`, which the
3155    // pre-build type check does not run. These tests guard that the type
3156    // check still sees the upstream type across such an edge.
3157
3158    #[test]
3159    fn infers_type_across_single_operator_edge() {
3160        let dataflow = parse_dataflow(
3161            "\
3162nodes:
3163  - id: producer
3164    operator:
3165      python: producer.py
3166      outputs:
3167        - result
3168      output_types:
3169        result: std/core/v1/Float64
3170  - id: consumer
3171    inputs:
3172      reading: producer/result
3173",
3174        );
3175        let reg = TypeRegistry::new();
3176        let result = check_type_annotations_full(&dataflow, &reg, false);
3177        assert!(
3178            result.warnings.is_empty(),
3179            "unexpected: {:?}",
3180            result.warnings
3181        );
3182        assert_eq!(
3183            result.inferences.len(),
3184            1,
3185            "should infer from the operator output"
3186        );
3187        assert_eq!(result.inferences[0].inferred_urn, "std/core/v1/Float64");
3188        assert_eq!(result.inferences[0].port_id, "reading");
3189    }
3190
3191    #[test]
3192    fn detects_mismatch_across_single_operator_edge() {
3193        let dataflow = parse_dataflow(
3194            "\
3195nodes:
3196  - id: producer
3197    operator:
3198      python: producer.py
3199      outputs:
3200        - result
3201      output_types:
3202        result: std/core/v1/Float64
3203  - id: consumer
3204    inputs:
3205      reading: producer/result
3206    input_types:
3207      reading: std/core/v1/Int32
3208",
3209        );
3210        let reg = TypeRegistry::new();
3211        let result = check_type_annotations_full(&dataflow, &reg, false);
3212        assert!(
3213            result
3214                .warnings
3215                .iter()
3216                .any(|w| w.message.contains("type mismatch")),
3217            "expected a type mismatch warning, got: {:?}",
3218            result.warnings
3219        );
3220    }
3221
3222    #[test]
3223    fn strict_mode_no_false_positive_across_single_operator_edge() {
3224        // Upstream single-operator output IS annotated, so strict mode must
3225        // not claim it has "no type annotation".
3226        let dataflow = parse_dataflow(
3227            "\
3228nodes:
3229  - id: producer
3230    operator:
3231      python: producer.py
3232      outputs:
3233        - result
3234      output_types:
3235        result: std/core/v1/Float64
3236  - id: consumer
3237    inputs:
3238      reading: producer/result
3239    input_types:
3240      reading: std/core/v1/Float64
3241",
3242        );
3243        let reg = TypeRegistry::new();
3244        let result = check_type_annotations_full(&dataflow, &reg, true);
3245        assert!(
3246            !result
3247                .warnings
3248                .iter()
3249                .any(|w| w.message.contains("no type annotation")),
3250            "annotated upstream must not trigger a strict no-annotation warning, got: {:?}",
3251            result.warnings
3252        );
3253    }
3254
3255    // --- Focused unit tests for parse_byte_size and parse_log_level ---
3256    //
3257    // Added 2026-04-08 to close the biggest mutation-score gap in
3258    // dora-core: 14 escaped mutants in parse_byte_size and 6 in
3259    // parse_log_level (20 of the 91 missed mutants in validate.rs).
3260    // See docs/qa-baseline-2026-04-07.md.
3261    //
3262    // These tests exercise every match arm and every boundary case by
3263    // construction, so any mutation that deletes an arm, flips a
3264    // comparison, or changes a multiplier is caught.
3265
3266    // parse_byte_size: every unit produces the exact byte count ----
3267
3268    #[test]
3269    fn parse_byte_size_bare_number() {
3270        assert_eq!(parse_byte_size("0").unwrap(), 0);
3271        assert_eq!(parse_byte_size("1").unwrap(), 1);
3272        assert_eq!(parse_byte_size("100").unwrap(), 100);
3273        assert_eq!(parse_byte_size("1000000").unwrap(), 1_000_000);
3274    }
3275
3276    #[test]
3277    fn parse_byte_size_bytes_unit() {
3278        assert_eq!(parse_byte_size("0B").unwrap(), 0);
3279        assert_eq!(parse_byte_size("1B").unwrap(), 1);
3280        assert_eq!(parse_byte_size("42B").unwrap(), 42);
3281        // case insensitive
3282        assert_eq!(parse_byte_size("42b").unwrap(), 42);
3283    }
3284
3285    #[test]
3286    fn parse_byte_size_kilobyte_units() {
3287        assert_eq!(parse_byte_size("1KB").unwrap(), 1024);
3288        assert_eq!(parse_byte_size("1K").unwrap(), 1024);
3289        assert_eq!(parse_byte_size("2KB").unwrap(), 2048);
3290        assert_eq!(parse_byte_size("4K").unwrap(), 4096);
3291        // case insensitive
3292        assert_eq!(parse_byte_size("1kb").unwrap(), 1024);
3293        assert_eq!(parse_byte_size("1k").unwrap(), 1024);
3294    }
3295
3296    #[test]
3297    fn parse_byte_size_megabyte_units() {
3298        assert_eq!(parse_byte_size("1MB").unwrap(), 1024 * 1024);
3299        assert_eq!(parse_byte_size("1M").unwrap(), 1024 * 1024);
3300        assert_eq!(parse_byte_size("10MB").unwrap(), 10 * 1024 * 1024);
3301        // distinct from KB: pins the multiplier ratio
3302        assert_eq!(
3303            parse_byte_size("1MB").unwrap(),
3304            1024 * parse_byte_size("1KB").unwrap()
3305        );
3306    }
3307
3308    #[test]
3309    fn parse_byte_size_gigabyte_units() {
3310        assert_eq!(parse_byte_size("1GB").unwrap(), 1024 * 1024 * 1024);
3311        assert_eq!(parse_byte_size("1G").unwrap(), 1024 * 1024 * 1024);
3312        // distinct from MB: pins the multiplier ratio
3313        assert_eq!(
3314            parse_byte_size("1GB").unwrap(),
3315            1024 * parse_byte_size("1MB").unwrap()
3316        );
3317    }
3318
3319    #[test]
3320    fn parse_byte_size_all_units_are_distinct() {
3321        // Pins each unit's multiplier to catch mutations that swap arms
3322        // (e.g., KB → MB would make "1KB" and "1MB" equal).
3323        let b = parse_byte_size("1B").unwrap();
3324        let kb = parse_byte_size("1KB").unwrap();
3325        let mb = parse_byte_size("1MB").unwrap();
3326        let gb = parse_byte_size("1GB").unwrap();
3327        assert_eq!(b, 1);
3328        assert_eq!(kb, 1024);
3329        assert_eq!(mb, 1024 * kb);
3330        assert_eq!(gb, 1024 * mb);
3331        // And strict ordering
3332        assert!(b < kb);
3333        assert!(kb < mb);
3334        assert!(mb < gb);
3335    }
3336
3337    #[test]
3338    fn parse_byte_size_accepts_sizes_above_4gib() {
3339        // `usize` is 32 bits on i686/armv7; the log size limit must not be
3340        // bounded by it (#3604).
3341        assert_eq!(parse_byte_size("8GB").unwrap(), 8 << 30);
3342        assert_eq!(parse_byte_size("5000MB").unwrap(), 5000 << 20);
3343        assert_eq!(parse_byte_size("4294967296").unwrap(), 1 << 32);
3344        assert_eq!(parse_byte_size("4.5GB").unwrap(), 9 << 29);
3345    }
3346
3347    #[test]
3348    fn parse_byte_size_float_path() {
3349        // When the integer parse fails, fall back to float.
3350        assert_eq!(parse_byte_size("1.5KB").unwrap(), 1536);
3351        assert_eq!(parse_byte_size("0.5MB").unwrap(), 512 * 1024);
3352        assert_eq!(parse_byte_size("2.25KB").unwrap(), 2304);
3353    }
3354
3355    #[test]
3356    fn parse_byte_size_whitespace_tolerated() {
3357        assert_eq!(parse_byte_size(" 1KB ").unwrap(), 1024);
3358        assert_eq!(parse_byte_size("1 KB").unwrap(), 1024);
3359        assert_eq!(parse_byte_size("  1  KB  ").unwrap(), 1024);
3360    }
3361
3362    #[test]
3363    fn parse_byte_size_rejects_negative() {
3364        // A negative f64 cast to u64 saturates to 0, which would silently
3365        // disable a limit instead of being rejected (issue #2018).
3366        assert!(parse_byte_size("-1KB").is_err());
3367        assert!(parse_byte_size("-0.5MB").is_err());
3368        assert!(parse_byte_size("-1").is_err());
3369        assert!(parse_byte_size("-100").is_err());
3370    }
3371
3372    #[test]
3373    fn parse_byte_size_rejects_non_finite() {
3374        // inf/nan cast to u64 saturate (to u64::MAX / 0) rather than erroring.
3375        assert!(parse_byte_size("infKB").is_err());
3376        assert!(parse_byte_size("nanMB").is_err());
3377    }
3378
3379    #[test]
3380    fn parse_byte_size_rejects_unknown_unit() {
3381        assert!(parse_byte_size("1TB").is_err());
3382        assert!(parse_byte_size("1XB").is_err());
3383        assert!(parse_byte_size("1foo").is_err());
3384    }
3385
3386    #[test]
3387    fn parse_byte_size_rejects_integer_overflow() {
3388        // num fits in u64 but num * multiplier overflows; must not panic
3389        // (debug) or silently wrap (release).
3390        assert!(parse_byte_size("20000000000GB").is_err());
3391        assert!(parse_byte_size("18446744073709551615GB").is_err());
3392    }
3393
3394    #[test]
3395    fn parse_byte_size_rejects_float_overflow() {
3396        // num does not fit in u64 (so it takes the f64 path) and is finite and
3397        // positive, but num * multiplier exceeds u64::MAX. Casting the result
3398        // to u64 saturates to u64::MAX instead of erroring, so it must be
3399        // rejected up front rather than silently clamped.
3400        assert!(parse_byte_size("99999999999999999999GB").is_err());
3401        assert!(parse_byte_size("99999999999999999999.0GB").is_err());
3402        assert!(parse_byte_size("184467440737095516160B").is_err());
3403    }
3404
3405    #[test]
3406    fn parse_byte_size_rejects_invalid_number() {
3407        assert!(parse_byte_size("abc").is_err());
3408        assert!(parse_byte_size("abcKB").is_err());
3409        assert!(parse_byte_size("1.2.3KB").is_err());
3410    }
3411
3412    #[test]
3413    fn send_as_rejects_names_that_are_not_valid_output_ids() {
3414        for (field, value) in [
3415            ("send_stdout_as", "std out"),
3416            ("send_stdout_as", "out/"),
3417            ("send_logs_as", "\"\""),
3418            ("send_logs_as", "a//b"),
3419        ] {
3420            let yaml = format!(
3421                "nodes:\n  - id: talker\n    path: talker\n    {field}: {value}\n    outputs: [out]\n"
3422            );
3423            let descriptor: Descriptor = serde_yaml::from_str(&yaml).unwrap();
3424            let err = check_dataflow_static(&descriptor)
3425                .expect_err(&format!("`{field}: {value}` must be rejected"));
3426            assert!(
3427                format!("{err:?}").contains("is not a valid output id"),
3428                "unexpected error for `{field}: {value}`: {err:?}"
3429            );
3430        }
3431    }
3432
3433    #[test]
3434    fn send_as_rejects_empty_operator_entry() {
3435        let yaml = r#"
3436nodes:
3437  - id: rt
3438    operators:
3439      - id: op1
3440        python: op.py
3441        send_stdout_as: ""
3442        send_logs_as: ""
3443        outputs: [out]
3444"#;
3445        let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3446        let nodes = descriptor.resolve_aliases_and_set_defaults().unwrap();
3447        let node = &nodes[&NodeId::from("rt".to_string())];
3448        assert!(node.send_stdout_as().is_err());
3449        assert!(node.send_logs_as().is_err());
3450    }
3451
3452    #[test]
3453    fn send_as_accepts_valid_names() {
3454        let yaml = r#"
3455nodes:
3456  - id: rt
3457    operators:
3458      - id: op1
3459        python: op.py
3460        send_stdout_as: stdout
3461        send_logs_as: logs
3462        outputs: [stdout, logs]
3463  - id: talker
3464    path: talker
3465    send_stdout_as: stdout
3466    send_logs_as: logs
3467    outputs: [stdout, logs]
3468"#;
3469        let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3470        let nodes = descriptor.resolve_aliases_and_set_defaults().unwrap();
3471        let rt = &nodes[&NodeId::from("rt".to_string())];
3472        assert_eq!(rt.send_stdout_as().unwrap().as_deref(), Some("op1/stdout"));
3473        assert_eq!(rt.send_logs_as().unwrap().as_deref(), Some("op1/logs"));
3474        let talker = &nodes[&NodeId::from("talker".to_string())];
3475        assert_eq!(talker.send_stdout_as().unwrap().as_deref(), Some("stdout"));
3476        assert_eq!(talker.send_logs_as().unwrap().as_deref(), Some("logs"));
3477    }
3478
3479    // parse_log_level: every level variant ----
3480
3481    #[test]
3482    fn parse_log_level_all_levels() {
3483        use dora_message::common::LogLevelOrStdout;
3484
3485        assert!(matches!(
3486            parse_log_level("error").unwrap(),
3487            LogLevelOrStdout::LogLevel(log::Level::Error)
3488        ));
3489        assert!(matches!(
3490            parse_log_level("warn").unwrap(),
3491            LogLevelOrStdout::LogLevel(log::Level::Warn)
3492        ));
3493        assert!(matches!(
3494            parse_log_level("info").unwrap(),
3495            LogLevelOrStdout::LogLevel(log::Level::Info)
3496        ));
3497        assert!(matches!(
3498            parse_log_level("debug").unwrap(),
3499            LogLevelOrStdout::LogLevel(log::Level::Debug)
3500        ));
3501        assert!(matches!(
3502            parse_log_level("trace").unwrap(),
3503            LogLevelOrStdout::LogLevel(log::Level::Trace)
3504        ));
3505        assert!(matches!(
3506            parse_log_level("stdout").unwrap(),
3507            LogLevelOrStdout::Stdout
3508        ));
3509    }
3510
3511    #[test]
3512    fn parse_log_level_case_insensitive() {
3513        use dora_message::common::LogLevelOrStdout;
3514
3515        for variant in ["ERROR", "Error", "error", "ErRoR"] {
3516            assert!(matches!(
3517                parse_log_level(variant).unwrap(),
3518                LogLevelOrStdout::LogLevel(log::Level::Error)
3519            ));
3520        }
3521        for variant in ["STDOUT", "Stdout", "stdout"] {
3522            assert!(matches!(
3523                parse_log_level(variant).unwrap(),
3524                LogLevelOrStdout::Stdout
3525            ));
3526        }
3527    }
3528
3529    #[test]
3530    fn parse_log_level_rejects_unknown() {
3531        assert!(parse_log_level("").is_err());
3532        assert!(parse_log_level("INVALID").is_err());
3533        assert!(parse_log_level("fatal").is_err());
3534        assert!(parse_log_level("log").is_err());
3535    }
3536
3537    #[test]
3538    fn parse_log_level_error_message_lists_options() {
3539        let err = parse_log_level("bogus").unwrap_err().to_string();
3540        // The error message should name at least the valid options so
3541        // users can fix typos. Pinning this so the fmt::Display impl
3542        // used in the bail! macro stays informative.
3543        for expected in ["error", "warn", "info", "debug", "trace", "stdout"] {
3544            assert!(
3545                err.contains(expected),
3546                "expected '{expected}' to be mentioned in error, got: {err}"
3547            );
3548        }
3549    }
3550
3551    #[test]
3552    fn max_rotated_files_accepts_zero_and_still_caps_at_100() {
3553        let node = |n: u32| -> ResolvedNode {
3554            let mut custom = custom_node();
3555            custom.max_rotated_files = Some(n);
3556            ResolvedNode::new(NodeId::from("n".to_owned()), CoreNodeKind::Custom(custom))
3557        };
3558
3559        // 0 is a real configuration: keep the active log only, rotating the
3560        // previous one away. The documented disk bound
3561        // `max_log_size * (1 + max_rotated_files)` is one file at 0.
3562        assert_eq!(node(0).max_rotated_files().unwrap(), Some(0));
3563        // The upper bound is unchanged.
3564        assert_eq!(node(100).max_rotated_files().unwrap(), Some(100));
3565        assert!(node(101).max_rotated_files().is_err());
3566    }
3567}
3568
3569/// Property-based tests for the pure parsing/validation helpers. These
3570/// complement the Kani proofs in `crate::metadata` — see
3571/// `docs/formal-verification.md` for how the tiers fit together.
3572#[cfg(test)]
3573mod proptest_properties {
3574    use super::{parse_byte_size, validate_ros2_name};
3575    use dora_message::id::NodeId;
3576    use proptest::prelude::*;
3577
3578    fn node_id() -> NodeId {
3579        NodeId::from("prop".to_owned())
3580    }
3581
3582    /// Strategy for ROS2 names that are valid by construction: optional
3583    /// leading '/', then '/'-separated tokens matching `[A-Za-z_][A-Za-z0-9_]*`.
3584    fn valid_ros2_name() -> impl Strategy<Value = String> {
3585        (
3586            any::<bool>(),
3587            prop::collection::vec("[A-Za-z_][A-Za-z0-9_]{0,8}", 1..4),
3588        )
3589            .prop_map(|(absolute, tokens)| {
3590                let joined = tokens.join("/");
3591                if absolute {
3592                    format!("/{joined}")
3593                } else {
3594                    joined
3595                }
3596            })
3597    }
3598
3599    proptest! {
3600        /// Total: never panics, whatever the input.
3601        #[test]
3602        fn ros2_name_validation_never_panics(name in ".{0,32}") {
3603            let _ = validate_ros2_name(&node_id(), "topic", &name);
3604        }
3605
3606        /// Completeness: structurally valid names are always accepted.
3607        #[test]
3608        fn ros2_name_accepts_valid_names(name in valid_ros2_name()) {
3609            prop_assert!(validate_ros2_name(&node_id(), "topic", &name).is_ok());
3610        }
3611
3612        /// Soundness: every accepted name satisfies all documented rules.
3613        ///
3614        /// The strategy samples from the valid alphabet plus a few
3615        /// near-miss characters ('.', '-', ' ') so that *accepted* names
3616        /// are common — a fully random unicode strategy would virtually
3617        /// never enter the accepted branch, making the property vacuous.
3618        #[test]
3619        fn ros2_name_accepted_implies_well_formed(name in "[a-zA-Z0-9_/. -]{0,16}") {
3620            if validate_ros2_name(&node_id(), "topic", &name).is_ok() {
3621                prop_assert!(!name.is_empty());
3622                prop_assert!(name.chars().all(|c| c.is_ascii_alphanumeric()
3623                    || c == '_'
3624                    || c == '/'));
3625                prop_assert!(!name.contains("//"));
3626                prop_assert!(!name.ends_with('/'));
3627                for (i, token) in name.split('/').enumerate() {
3628                    if i == 0 && token.is_empty() {
3629                        continue;
3630                    }
3631                    prop_assert!(!token.starts_with(|c: char| c.is_ascii_digit()));
3632                }
3633            }
3634        }
3635
3636        /// Total: never panics, whatever the input.
3637        #[test]
3638        fn byte_size_parsing_never_panics(s in ".{0,24}") {
3639            let _ = parse_byte_size(&s);
3640        }
3641
3642        /// Integer inputs with units multiply exactly (no float rounding)
3643        /// and overflow is reported as an error, never wrapped.
3644        #[test]
3645        fn byte_size_integer_units_are_exact(
3646            num in any::<u64>(),
3647            unit_idx in 0usize..7,
3648            lowercase in any::<bool>(),
3649        ) {
3650            // All unit spellings accepted by `parse_byte_size` (which also
3651            // uppercases, so lowercase forms must behave identically).
3652            let (unit, multiplier) =
3653                [("B", 1u64), ("KB", 1 << 10), ("K", 1 << 10), ("MB", 1 << 20),
3654                 ("M", 1 << 20), ("GB", 1 << 30), ("G", 1 << 30)][unit_idx];
3655            let unit = if lowercase { unit.to_lowercase() } else { unit.to_string() };
3656            match parse_byte_size(&format!("{num}{unit}")) {
3657                Ok(bytes) => prop_assert_eq!(Some(bytes), num.checked_mul(multiplier)),
3658                Err(_) => prop_assert!(num.checked_mul(multiplier).is_none()),
3659            }
3660        }
3661
3662        /// Bare integers parse to themselves.
3663        #[test]
3664        fn byte_size_bare_integer_is_identity(num in any::<u64>()) {
3665            prop_assert_eq!(parse_byte_size(&num.to_string()).unwrap(), num);
3666        }
3667
3668        /// The float fallback path is total for ordinary decimals: any
3669        /// simple non-negative decimal with a valid unit parses Ok.
3670        #[test]
3671        fn byte_size_fractional_inputs_parse(
3672            int_part in 0u32..1_000_000,
3673            frac in 0u32..100,
3674            unit_idx in 0usize..7,
3675        ) {
3676            let unit = ["B", "KB", "K", "MB", "M", "GB", "G"][unit_idx];
3677            let input = format!("{int_part}.{frac:02}{unit}");
3678            prop_assert!(parse_byte_size(&input).is_ok(), "failed to parse '{input}'");
3679        }
3680    }
3681}