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