1use crate::{
2 adjust_shared_library_path,
3 descriptor::{self, source_is_url},
4 get_python_path,
5};
6
7use dora_message::{
8 config::{Input, InputMapping, UserInputMapping, parse_byte_count},
9 descriptor::{CoreNodeKind, DYNAMIC_SOURCE, OperatorSource, ResolvedNode, SHELL_SOURCE},
10 id::{DataId, NodeId, OperatorId},
11};
12use eyre::{Context, bail, eyre};
13use std::{
14 collections::{BTreeMap, BTreeSet},
15 path::Path,
16 process::Command,
17};
18use tracing::info;
19
20use super::{Descriptor, DescriptorExt, resolve_path};
21const VERSION: &str = env!("CARGO_PKG_VERSION");
22
23pub fn check_wiring(dataflow: &Descriptor) -> eyre::Result<()> {
28 let nodes = dataflow.resolve_aliases_and_set_defaults()?;
29 check_wiring_resolved(&nodes)
30}
31
32fn 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
59pub fn check_dataflow_static(dataflow: &Descriptor) -> eyre::Result<()> {
66 validate_ros2_configs(dataflow)?;
68
69 let nodes = dataflow.resolve_aliases_and_set_defaults()?;
70 check_dataflow_static_resolved(dataflow, &nodes)
71}
72
73fn 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
86fn check_dataflow_static_resolved(
89 dataflow: &Descriptor,
90 nodes: &BTreeMap<NodeId, ResolvedNode>,
91) -> eyre::Result<()> {
92 for node in nodes.values() {
93 check_node_timing(node)?;
94 }
95 check_seconds_field(
100 "dataflow",
101 "health_check_interval",
102 dataflow.health_check_interval,
103 false,
104 )?;
105
106 check_wiring_resolved(nodes)?;
108
109 for node in nodes.values() {
111 node.send_stdout_as()
112 .context("Could not resolve `send_stdout_as` configuration")?;
113 node.send_logs_as()
114 .context("Could not resolve `send_logs_as` configuration")?;
115 node.min_log_level()
116 .context("Could not resolve `min_log_level` configuration")?;
117 node.max_log_size()
118 .context("Could not resolve `max_log_size` configuration")?;
119 node.max_rotated_files()
120 .context("Could not resolve `max_rotated_files` configuration")?;
121 }
122
123 Ok(())
124}
125
126pub fn check_dataflow(dataflow: &Descriptor, working_dir: &Path) -> eyre::Result<()> {
127 validate_ros2_configs(dataflow)?;
131 let nodes = dataflow.resolve_aliases_and_set_defaults()?;
132 check_dataflow_static_resolved(dataflow, &nodes)?;
133
134 let mut has_python_operator = false;
135
136 for node in nodes.values() {
138 match &node.kind {
139 descriptor::CoreNodeKind::Custom(custom) => match &custom.source {
140 dora_message::descriptor::NodeSource::Local => match custom.path.as_str() {
141 SHELL_SOURCE => (),
142 DYNAMIC_SOURCE => (),
143 source => {
144 if source_is_url(source) {
145 info!("{source} is a URL."); } else if custom.build.is_some() {
147 info!("skipping path check for node with build command");
148 } else {
149 resolve_path(source, working_dir).wrap_err_with(|| {
150 format!("Could not find source path `{source}`")
151 })?;
152 };
153 }
154 },
155 dora_message::descriptor::NodeSource::GitBranch { .. } => {
156 info!("skipping check for node with git source");
157 }
158 },
159 descriptor::CoreNodeKind::Runtime(node) => {
160 for operator_definition in &node.operators {
161 match &operator_definition.config.source {
162 OperatorSource::SharedLibrary(path) => {
163 if source_is_url(path) {
164 info!("{path} is a URL."); } else if operator_definition.config.build.is_some() {
166 info!("skipping path check for operator with build command");
167 } else {
168 let path = adjust_shared_library_path(Path::new(&path))?;
169 if !working_dir.join(&path).exists() {
170 bail!("no shared library at `{}`", path.display());
171 }
172 }
173 }
174 OperatorSource::Python(python_source) => {
175 has_python_operator = true;
176 let path = &python_source.source;
177 if source_is_url(path) {
178 info!("{path} is a URL."); } else if operator_definition.config.build.is_some() {
180 info!("skipping path check for operator with build command");
181 } else if !working_dir.join(path).exists() {
182 bail!("no Python library at `{path}`");
183 }
184 }
185 OperatorSource::Wasm(path) => {
186 if source_is_url(path) {
187 info!("{path} is a URL."); } else if operator_definition.config.build.is_some() {
189 info!("skipping path check for operator with build command");
190 } else if !working_dir.join(path).exists() {
191 bail!("no WASM library at `{path}`");
192 }
193 }
194 }
195 }
196 }
197 }
198 }
199
200 if has_python_operator {
201 check_python_runtime()?;
202 }
203
204 Ok(())
205}
206
207pub fn check_node_timing(node: &ResolvedNode) -> eyre::Result<()> {
222 check_node_timing_values(node)?;
223 if let descriptor::CoreNodeKind::Custom(custom) = &node.kind
224 && custom.path.as_str() == DYNAMIC_SOURCE
225 && custom.startup_timeout.is_some()
226 {
227 bail!(
228 "dynamic node `{}` cannot specify `startup_timeout` (dynamic nodes connect out-of-band and are not managed by the startup watchdog)",
229 node.id
230 );
231 }
232 Ok(())
233}
234
235pub fn check_node_timing_values(node: &ResolvedNode) -> eyre::Result<()> {
247 if let descriptor::CoreNodeKind::Custom(custom) = &node.kind {
248 check_timing_fields(&node.id, custom)?;
249 }
250 for (input_id, input) in node_inputs(node) {
254 check_seconds_field(
255 &format!("input `{input_id}` of node `{}`", node.id),
256 "input_timeout",
257 input.input_timeout,
258 true,
259 )?;
260 }
261 Ok(())
262}
263
264fn check_timing_fields(
272 node_id: &NodeId,
273 custom: &dora_message::descriptor::CustomNode,
274) -> eyre::Result<()> {
275 let owner = format!("node `{node_id}`");
276 for (field, value) in [
277 ("finish_grace_secs", custom.finish_grace_secs),
278 ("health_check_timeout", custom.health_check_timeout),
279 ("startup_timeout", custom.startup_timeout),
280 ("restart_delay", custom.restart_delay),
281 ("max_restart_delay", custom.max_restart_delay),
282 ("restart_window", custom.restart_window),
283 ] {
284 check_seconds_field(&owner, field, value, true)?;
285 }
286 Ok(())
287}
288
289fn check_seconds_field(
306 owner: &str,
307 field: &str,
308 value: Option<f64>,
309 allow_zero: bool,
310) -> eyre::Result<()> {
311 if let Some(value) = value {
312 let duration = std::time::Duration::try_from_secs_f64(value);
317 let is_zero = duration.as_ref().is_ok_and(|d| d.is_zero());
318 if !allow_zero && is_zero {
319 bail!(
323 "{owner} has invalid `{field}`: {value} \
324 (must be a positive number of seconds; this value is zero or \
325 rounds down to a zero-length duration)"
326 );
327 }
328 if duration.is_err() {
329 let requirement = if allow_zero {
330 "non-negative"
331 } else {
332 "positive"
333 };
334 bail!(
335 "{owner} has invalid `{field}`: {value} \
336 (must be a finite, {requirement} number of seconds smaller than {})",
337 std::time::Duration::MAX.as_secs_f64()
338 );
339 }
340 }
341 Ok(())
342}
343
344fn node_inputs(node: &ResolvedNode) -> Vec<(&DataId, &Input)> {
347 match &node.kind {
348 CoreNodeKind::Custom(custom) => custom.run_config.inputs.iter().collect(),
349 CoreNodeKind::Runtime(runtime) => runtime
350 .operators
351 .iter()
352 .flat_map(|op| op.config.inputs.iter())
353 .collect(),
354 }
355}
356
357pub trait ResolvedNodeExt {
358 fn send_stdout_as(&self) -> eyre::Result<Option<String>>;
359 fn send_logs_as(&self) -> eyre::Result<Option<String>>;
360 fn min_log_level(&self) -> eyre::Result<Option<dora_message::common::LogLevelOrStdout>>;
361 fn max_log_size(&self) -> eyre::Result<Option<u64>>;
362 fn max_rotated_files(&self) -> eyre::Result<Option<u32>>;
363}
364
365fn checked_output_name(field: &str, name: String) -> eyre::Result<String> {
371 name.parse::<DataId>()
372 .map_err(|err| eyre!("`{field}: {name}` is not a valid output id: {err}"))?;
373 Ok(name)
374}
375
376impl ResolvedNodeExt for ResolvedNode {
377 fn send_stdout_as(&self) -> eyre::Result<Option<String>> {
378 let name = match &self.kind {
379 CoreNodeKind::Runtime(n) => {
381 let count = n
382 .operators
383 .iter()
384 .filter(|op| op.config.send_stdout_as.is_some())
385 .count();
386 if count == 1 && n.operators.len() > 1 {
387 tracing::warn!(
388 "All stdout from all operators of a runtime are going to be sent in the selected `send_stdout_as` operator."
389 )
390 } else if count > 1 {
391 return Err(eyre!(
392 "More than one `send_stdout_as` entries for a runtime node. Please only use one `send_stdout_as` per runtime."
393 ));
394 }
395 n.operators.iter().find_map(|op| {
396 op.config
397 .send_stdout_as
398 .as_ref()
399 .map(|stdout| format!("{}/{}", op.id, stdout))
400 })
401 }
402 CoreNodeKind::Custom(n) => n.send_stdout_as.clone(),
403 };
404 name.map(|name| checked_output_name("send_stdout_as", name))
405 .transpose()
406 }
407
408 fn send_logs_as(&self) -> eyre::Result<Option<String>> {
409 let name = match &self.kind {
410 CoreNodeKind::Runtime(n) => {
411 let count = n
412 .operators
413 .iter()
414 .filter(|op| op.config.send_logs_as.is_some())
415 .count();
416 if count > 1 {
417 return Err(eyre!(
418 "More than one `send_logs_as` entries for a runtime node. Please only use one `send_logs_as` per runtime."
419 ));
420 }
421 n.operators.iter().find_map(|op| {
422 op.config
423 .send_logs_as
424 .as_ref()
425 .map(|logs| format!("{}/{}", op.id, logs))
426 })
427 }
428 CoreNodeKind::Custom(n) => n.send_logs_as.clone(),
429 };
430 name.map(|name| checked_output_name("send_logs_as", name))
431 .transpose()
432 }
433
434 fn min_log_level(&self) -> eyre::Result<Option<dora_message::common::LogLevelOrStdout>> {
435 let level_str = match &self.kind {
436 CoreNodeKind::Runtime(n) => {
437 let levels: Vec<_> = n
439 .operators
440 .iter()
441 .filter_map(|op| op.config.min_log_level.as_deref())
442 .collect();
443 if levels.len() > 1 {
444 return Err(eyre!(
445 "More than one `min_log_level` entries for a runtime node. Please only use one `min_log_level` per runtime."
446 ));
447 }
448 levels.first().map(|s| s.to_string())
449 }
450 CoreNodeKind::Custom(n) => n.min_log_level.clone(),
451 };
452 match level_str {
453 None => Ok(None),
454 Some(s) => {
455 let level = parse_log_level(&s)?;
456 Ok(Some(level))
457 }
458 }
459 }
460
461 fn max_log_size(&self) -> eyre::Result<Option<u64>> {
462 let size_str = match &self.kind {
463 CoreNodeKind::Runtime(n) => {
464 let sizes: Vec<_> = n
465 .operators
466 .iter()
467 .filter_map(|op| op.config.max_log_size.as_deref())
468 .collect();
469 if sizes.len() > 1 {
470 return Err(eyre!(
471 "More than one `max_log_size` entries for a runtime node. Please only use one `max_log_size` per runtime."
472 ));
473 }
474 sizes.first().map(|s| s.to_string())
475 }
476 CoreNodeKind::Custom(n) => n.max_log_size.clone(),
477 };
478 match size_str {
479 None => Ok(None),
480 Some(s) => {
481 let bytes = parse_byte_size(&s)?;
482 Ok(Some(bytes))
483 }
484 }
485 }
486
487 fn max_rotated_files(&self) -> eyre::Result<Option<u32>> {
488 let value = match &self.kind {
489 CoreNodeKind::Runtime(n) => {
490 let values: Vec<_> = n
491 .operators
492 .iter()
493 .filter_map(|op| op.config.max_rotated_files)
494 .collect();
495 if values.len() > 1 {
496 return Err(eyre!(
497 "More than one `max_rotated_files` entries for a runtime node. Please only use one `max_rotated_files` per runtime."
498 ));
499 }
500 values.first().copied()
501 }
502 CoreNodeKind::Custom(n) => n.max_rotated_files,
503 };
504 if let Some(n) = value {
505 if n > 100 {
510 bail!("`max_rotated_files` must not exceed 100");
511 }
512 }
513 Ok(value)
514 }
515}
516
517fn parse_byte_size(s: &str) -> eyre::Result<u64> {
518 parse_byte_count(s).map_err(|e| eyre!("{e}"))
522}
523
524fn parse_log_level(s: &str) -> eyre::Result<dora_message::common::LogLevelOrStdout> {
525 match s.to_lowercase().as_str() {
526 "error" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
527 log::Level::Error,
528 )),
529 "warn" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
530 log::Level::Warn,
531 )),
532 "info" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
533 log::Level::Info,
534 )),
535 "debug" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
536 log::Level::Debug,
537 )),
538 "trace" => Ok(dora_message::common::LogLevelOrStdout::LogLevel(
539 log::Level::Trace,
540 )),
541 "stdout" => Ok(dora_message::common::LogLevelOrStdout::Stdout),
542 _ => bail!(
543 "invalid min_log_level: '{s}', expected one of: error, warn, info, debug, trace, stdout"
544 ),
545 }
546}
547
548fn check_input(
549 input: &Input,
550 nodes: &BTreeMap<NodeId, super::ResolvedNode>,
551 input_id_str: &str,
552) -> Result<(), eyre::ErrReport> {
553 match &input.mapping {
554 InputMapping::Timer { interval: _ } | InputMapping::Logs(_) => {}
555 InputMapping::User(UserInputMapping { source, output }) => {
556 let source_node = nodes.get(source).ok_or_else(|| {
562 eyre!("source node `{source}` mapped to input `{input_id_str}` does not exist",)
563 })?;
564 match &source_node.kind {
565 CoreNodeKind::Custom(custom_node) => {
566 if !custom_node.run_config.outputs.contains(output) {
567 bail!(
568 "output `{source}/{output}` mapped to \
569 input `{input_id_str}` does not exist",
570 );
571 }
572 }
573 CoreNodeKind::Runtime(runtime) => {
574 let Some((operator_id, output)) = output.split_once('/') else {
579 bail!(
580 "input `{input_id_str}` references output `{output}` of node \
581 `{source}`, which is a runtime node; runtime node outputs \
582 must include the operator id \
583 (expected format: `{source}/<operator_id>/<output_id>`)"
584 );
585 };
586 let operator_id = OperatorId::from(operator_id.to_owned());
587 let output = DataId::from(output.to_owned());
588
589 let operator = runtime
590 .operators
591 .iter()
592 .find(|o| o.id == operator_id)
593 .ok_or_else(|| {
594 eyre!(
595 "source operator `{source}/{operator_id}` used \
596 for input `{input_id_str}` does not exist",
597 )
598 })?;
599
600 if !operator.config.outputs.contains(&output) {
601 bail!(
602 "output `{source}/{operator_id}/{output}` mapped to \
603 input `{input_id_str}` does not exist",
604 );
605 }
606 }
607 }
608 }
609 };
610 Ok(())
611}
612
613fn check_python_runtime() -> eyre::Result<()> {
614 let reinstall_command =
616 format!("Please reinstall it with: `pip install dora-rs=={VERSION} --force`");
617 let mut command = Command::new(get_python_path().context("Could not get python binary")?);
618 command.args([
619 "-c",
620 &format!(
621 "
622import dora;
623assert dora.__version__=='{VERSION}', 'Python dora-rs should be {VERSION}, but current version is %s. {reinstall_command}' % (dora.__version__)
624 "
625 ),
626 ]);
627 let mut result = command
628 .spawn()
629 .wrap_err("Could not spawn python dora-rs command.")?;
630 let status = result
631 .wait()
632 .wrap_err("Could not get exit status when checking python dora-rs")?;
633
634 if !status.success() {
635 bail!("Something went wrong with Python dora-rs. {reinstall_command}")
636 }
637
638 Ok(())
639}
640
641fn validate_ros2_config(
642 node_id: &NodeId,
643 config: &dora_message::descriptor::Ros2BridgeConfig,
644 node_inputs: &BTreeMap<DataId, Input>,
645 node_outputs: &BTreeSet<DataId>,
646) -> eyre::Result<()> {
647 use dora_message::descriptor::{Ros2Role, Ros2TransportConfig};
648
649 if let Ros2TransportConfig::Zenoh {
650 config_uri: Some(uri),
651 ..
652 } = &config.transport
653 && uri.as_os_str().is_empty()
654 {
655 bail!("node `{node_id}`: ros2 Zenoh config_uri must not be empty");
656 }
657
658 let mode_count = [
660 config.topic.is_some(),
661 config.topics.is_some(),
662 config.service.is_some(),
663 config.action.is_some(),
664 ]
665 .iter()
666 .filter(|&&v| v)
667 .count();
668 if mode_count == 0 {
669 bail!(
670 "node `{node_id}`: ros2 config requires one of \
671 `topic`, `topics`, `service`, or `action`"
672 );
673 }
674 if mode_count > 1 {
675 bail!(
676 "node `{node_id}`: ros2 config has multiple of \
677 `topic`, `topics`, `service`, `action` - only one is allowed"
678 );
679 }
680
681 if let Some(topic) = &config.topic {
682 validate_ros2_name(node_id, "topic", topic)?;
683 let message_type = config.message_type.as_ref().ok_or_else(|| {
684 eyre!("node `{node_id}`: ros2 config with `topic` requires `message_type`")
685 })?;
686 validate_ros2_type_format(node_id, topic, message_type)?;
687
688 super::resolve_ros2_single_topic(node_id, config, node_inputs, node_outputs)?;
694 } else if let Some(topics) = &config.topics {
695 if topics.is_empty() {
696 bail!("node `{node_id}`: ros2 `topics` list must not be empty");
697 }
698 if topics.len() > 64 {
699 bail!(
700 "node `{node_id}`: ros2 `topics` list has {} entries, maximum is 64",
701 topics.len()
702 );
703 }
704 for t in topics {
705 validate_ros2_name(node_id, "topic", &t.topic)?;
706 validate_ros2_type_format(node_id, &t.topic, &t.message_type)?;
707 }
708 super::validate_ros2_topic_ports(node_id, topics, node_inputs, node_outputs)?;
714 } else if let Some(service) = &config.service {
715 validate_ros2_name(node_id, "service", service)?;
716 let service_type = config.service_type.as_ref().ok_or_else(|| {
717 eyre!("node `{node_id}`: ros2 config with `service` requires `service_type`")
718 })?;
719 validate_ros2_type_format(node_id, service, service_type)?;
720 let role = config.role.as_ref().ok_or_else(|| {
721 eyre!("node `{node_id}`: ros2 service bridge requires `role` (client or server)")
722 })?;
723 match role {
724 Ros2Role::Client => {
725 if node_inputs.is_empty() {
726 bail!(
727 "node `{node_id}`: ros2 service client requires at least one input (request)"
728 );
729 }
730 if node_outputs.is_empty() {
731 bail!(
732 "node `{node_id}`: ros2 service client requires at least one output (response)"
733 );
734 }
735 }
736 Ros2Role::Server => {
737 if node_inputs.is_empty() {
738 bail!(
739 "node `{node_id}`: ros2 service server requires at least one input (response)"
740 );
741 }
742 if node_outputs.is_empty() {
743 bail!(
744 "node `{node_id}`: ros2 service server requires at least one output (request)"
745 );
746 }
747 }
748 }
749 } else if let Some(action) = &config.action {
750 validate_ros2_name(node_id, "action", action)?;
751 let action_type = config.action_type.as_ref().ok_or_else(|| {
752 eyre!("node `{node_id}`: ros2 config with `action` requires `action_type`")
753 })?;
754 validate_ros2_type_format(node_id, action, action_type)?;
755 let role = config
756 .role
757 .as_ref()
758 .ok_or_else(|| eyre!("node `{node_id}`: ros2 action bridge requires `role`"))?;
759 match role {
760 Ros2Role::Client => {
761 if node_inputs.is_empty() {
762 bail!(
763 "node `{node_id}`: ros2 action client requires at least one input (goal)"
764 );
765 }
766 if node_outputs.is_empty() {
767 bail!(
768 "node `{node_id}`: ros2 action client requires at least one output \
769 (feedback/result)"
770 );
771 }
772 }
773 Ros2Role::Server => {
774 if node_inputs.is_empty() {
775 bail!(
776 "node `{node_id}`: ros2 action server requires at least one input \
777 (feedback/result)"
778 );
779 }
780 if node_outputs.is_empty() {
781 bail!(
782 "node `{node_id}`: ros2 action server requires at least one output (goal)"
783 );
784 }
785 }
786 }
787 }
788
789 validate_ros2_qos(node_id, &config.qos)?;
791 if let Some(topics) = &config.topics {
792 for t in topics {
793 if let Some(qos) = &t.qos {
794 validate_ros2_qos(node_id, qos)?;
795 }
796 }
797 }
798
799 Ok(())
800}
801
802fn validate_ros2_qos(
803 node_id: &NodeId,
804 qos: &dora_message::descriptor::Ros2QosConfig,
805) -> eyre::Result<()> {
806 if let Some(d) = &qos.durability {
807 match d.as_str() {
808 "volatile" | "transient_local" => {}
809 _ => bail!(
810 "node `{node_id}`: invalid QoS durability `{d}`, \
811 expected \"volatile\" or \"transient_local\""
812 ),
813 }
814 }
815 if let Some(l) = &qos.liveliness {
816 match l.as_str() {
817 "automatic" | "manual_by_participant" | "manual_by_topic" => {}
818 _ => bail!(
819 "node `{node_id}`: invalid QoS liveliness `{l}`, \
820 expected \"automatic\", \"manual_by_participant\", or \"manual_by_topic\""
821 ),
822 }
823 }
824 if let Some(depth) = qos.keep_last
825 && !(1..=10_000).contains(&depth)
826 {
827 bail!(
828 "node `{node_id}`: QoS keep_last depth {depth} out of range, \
829 must be between 1 and 10000"
830 );
831 }
832 let owner = format!("node `{node_id}`");
840 check_seconds_field(&owner, "QoS max_blocking_time", qos.max_blocking_time, true)?;
841 check_seconds_field(&owner, "QoS lease_duration", qos.lease_duration, true)?;
842 Ok(())
843}
844
845fn validate_ros2_name(node_id: &NodeId, field: &str, name: &str) -> eyre::Result<()> {
853 if name.is_empty() {
854 bail!("node `{node_id}`: `{field}` must not be empty");
855 }
856 if !name
857 .chars()
858 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '/')
859 {
860 bail!(
861 "node `{node_id}`: invalid `{field}` name `{name}`, \
862 only ASCII alphanumeric, underscore, and '/' characters allowed"
863 );
864 }
865 if name == "/" {
866 bail!("node `{node_id}`: invalid `{field}` name, must not be a bare '/'");
867 }
868 if name.contains("//") {
869 bail!(
870 "node `{node_id}`: invalid `{field}` name `{name}`, \
871 consecutive slashes ('//') are not allowed"
872 );
873 }
874 if name.ends_with('/') {
875 bail!(
876 "node `{node_id}`: invalid `{field}` name `{name}`, \
877 name must not end with '/'"
878 );
879 }
880 for (i, token) in name.split('/').enumerate() {
884 if i == 0 && token.is_empty() {
885 continue;
886 }
887 if token.starts_with(|c: char| c.is_ascii_digit()) {
888 bail!(
889 "node `{node_id}`: invalid `{field}` name `{name}`, \
890 a token must not start with a digit (offending token `{token}`)"
891 );
892 }
893 }
894 Ok(())
895}
896
897#[derive(Debug)]
899pub struct TypeWarning {
900 pub node_id: String,
902 pub message: String,
904}
905
906impl std::fmt::Display for TypeWarning {
907 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
908 write!(f, "node \"{}\": {}", self.node_id, self.message)
909 }
910}
911
912#[derive(Debug)]
914pub struct TypeInference {
915 pub node_id: String,
917 pub port_id: String,
919 pub inferred_urn: String,
921 pub source: String,
923}
924
925impl std::fmt::Display for TypeInference {
926 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
927 write!(
928 f,
929 "inferred {} on {}/{} (from {})",
930 self.inferred_urn, self.node_id, self.port_id, self.source
931 )
932 }
933}
934
935pub struct TypeCheckResult {
937 pub warnings: Vec<TypeWarning>,
939 pub inferences: Vec<TypeInference>,
941}
942
943const TIMER_TYPE: &str = "std/core/v1/UInt64";
945
946fn register_output_type(
954 output_type_map: &mut BTreeMap<(String, String), String>,
955 annotated: &mut BTreeSet<(String, String)>,
956 key: (String, String),
957 urn: &str,
958 registry: &crate::types::TypeRegistry,
959) {
960 annotated.insert(key.clone());
961 if registry.resolve(urn).is_some() {
962 output_type_map.insert(key, urn.to_string());
963 }
964}
965
966pub fn check_type_annotations_full(
980 dataflow: &super::Descriptor,
981 registry: &crate::types::TypeRegistry,
982 strict: bool,
983) -> TypeCheckResult {
984 use crate::types::{CompatibilityGraph, TypeRule};
985
986 let mut warnings = Vec::new();
987 let mut inferences = Vec::new();
988
989 let user_rules: Vec<TypeRule> = dataflow
991 .type_rules
992 .iter()
993 .map(|r| TypeRule {
994 from: r.from.clone(),
995 to: r.to.clone(),
996 })
997 .collect();
998 let compat = CompatibilityGraph::new(&user_rules);
999
1000 let mut output_type_map: BTreeMap<(String, String), String> = BTreeMap::new();
1003
1004 let mut annotated_output_ports: BTreeSet<(String, String)> = BTreeSet::new();
1011
1012 for node in &dataflow.nodes {
1013 let nid = node.id.to_string();
1014
1015 check_port_types(
1017 &nid,
1018 &node.output_types,
1019 |id| node.outputs.contains(id),
1020 "output",
1021 registry,
1022 &mut warnings,
1023 );
1024 for (output_id, urn) in &node.output_types {
1026 register_output_type(
1027 &mut output_type_map,
1028 &mut annotated_output_ports,
1029 (nid.clone(), output_id.to_string()),
1030 urn,
1031 registry,
1032 );
1033 }
1034
1035 check_port_types(
1037 &nid,
1038 &node.input_types,
1039 |id| node.inputs.contains_key(id),
1040 "input",
1041 registry,
1042 &mut warnings,
1043 );
1044
1045 check_metadata_annotations(
1047 &nid,
1048 &node.output_metadata,
1049 &node.pattern,
1050 &node.outputs,
1051 &mut warnings,
1052 );
1053
1054 if let Some(op) = &node.operator {
1056 let op_id = op
1057 .id
1058 .as_ref()
1059 .map(|id| id.to_string())
1060 .unwrap_or_else(|| super::SINGLE_OPERATOR_DEFAULT_ID.to_string());
1061 check_port_types(
1062 &nid,
1063 &op.config.output_types,
1064 |id| op.config.outputs.contains(id),
1065 "output",
1066 registry,
1067 &mut warnings,
1068 );
1069 for (output_id, urn) in &op.config.output_types {
1070 register_output_type(
1080 &mut output_type_map,
1081 &mut annotated_output_ports,
1082 (nid.clone(), format!("{op_id}/{output_id}")),
1083 urn,
1084 registry,
1085 );
1086 register_output_type(
1087 &mut output_type_map,
1088 &mut annotated_output_ports,
1089 (nid.clone(), output_id.to_string()),
1090 urn,
1091 registry,
1092 );
1093 }
1094 check_port_types(
1095 &nid,
1096 &op.config.input_types,
1097 |id| op.config.inputs.contains_key(id),
1098 "input",
1099 registry,
1100 &mut warnings,
1101 );
1102 check_metadata_annotations(
1103 &nid,
1104 &op.config.output_metadata,
1105 &op.config.pattern,
1106 &op.config.outputs,
1107 &mut warnings,
1108 );
1109 }
1110 if let Some(runtime) = &node.operators {
1111 for op in &runtime.operators {
1112 let label = format!("{nid}/{}", op.id);
1113 check_port_types(
1114 &label,
1115 &op.config.output_types,
1116 |id| op.config.outputs.contains(id),
1117 "output",
1118 registry,
1119 &mut warnings,
1120 );
1121 for (output_id, urn) in &op.config.output_types {
1122 register_output_type(
1123 &mut output_type_map,
1124 &mut annotated_output_ports,
1125 (nid.clone(), format!("{}/{output_id}", op.id)),
1126 urn,
1127 registry,
1128 );
1129 }
1130 check_port_types(
1131 &label,
1132 &op.config.input_types,
1133 |id| op.config.inputs.contains_key(id),
1134 "input",
1135 registry,
1136 &mut warnings,
1137 );
1138 check_metadata_annotations(
1139 &label,
1140 &op.config.output_metadata,
1141 &op.config.pattern,
1142 &op.config.outputs,
1143 &mut warnings,
1144 );
1145 }
1146 }
1147 }
1148
1149 for node in &dataflow.nodes {
1152 let nid = node.id.to_string();
1153 let timer_types = timer_input_types(&node.inputs);
1154 check_edge_mismatches_with_compat(
1155 &nid,
1156 &node.input_types,
1157 &node.inputs,
1158 &output_type_map,
1159 &annotated_output_ports,
1160 &timer_types,
1161 &compat,
1162 registry,
1163 strict,
1164 &mut warnings,
1165 &mut inferences,
1166 );
1167
1168 if let Some(op) = &node.operator {
1169 let op_timer = timer_input_types(&op.config.inputs);
1170 check_edge_mismatches_with_compat(
1171 &nid,
1172 &op.config.input_types,
1173 &op.config.inputs,
1174 &output_type_map,
1175 &annotated_output_ports,
1176 &op_timer,
1177 &compat,
1178 registry,
1179 strict,
1180 &mut warnings,
1181 &mut inferences,
1182 );
1183 }
1184 if let Some(runtime) = &node.operators {
1185 for op in &runtime.operators {
1186 let label = format!("{nid}/{}", op.id);
1187 let op_timer = timer_input_types(&op.config.inputs);
1188 check_edge_mismatches_with_compat(
1189 &label,
1190 &op.config.input_types,
1191 &op.config.inputs,
1192 &output_type_map,
1193 &annotated_output_ports,
1194 &op_timer,
1195 &compat,
1196 registry,
1197 strict,
1198 &mut warnings,
1199 &mut inferences,
1200 );
1201 }
1202 }
1203 }
1204
1205 TypeCheckResult {
1206 warnings,
1207 inferences,
1208 }
1209}
1210
1211fn timer_input_types(inputs: &BTreeMap<DataId, Input>) -> BTreeMap<DataId, String> {
1213 if !inputs
1215 .values()
1216 .any(|i| matches!(i.mapping, InputMapping::Timer { .. }))
1217 {
1218 return BTreeMap::new();
1219 }
1220 let mut result = BTreeMap::new();
1221 for (input_id, input) in inputs {
1222 if matches!(input.mapping, InputMapping::Timer { .. }) {
1223 result.insert(input_id.clone(), TIMER_TYPE.to_string());
1224 }
1225 }
1226 result
1227}
1228
1229fn check_port_types(
1231 node_id: &str,
1232 type_map: &BTreeMap<DataId, String>,
1233 contains: impl Fn(&DataId) -> bool,
1234 port_kind: &str,
1235 registry: &crate::types::TypeRegistry,
1236 warnings: &mut Vec<TypeWarning>,
1237) {
1238 for (port_id, urn) in type_map {
1239 if !contains(port_id) {
1240 warnings.push(TypeWarning {
1241 node_id: node_id.to_string(),
1242 message: format!(
1243 "{port_kind}_types key \"{port_id}\" not found in {port_kind}s list"
1244 ),
1245 });
1246 }
1247 if registry.resolve(urn).is_none() {
1248 let hint = registry
1249 .suggest(urn)
1250 .map(|s| format!(" (did you mean \"{s}\"?)"))
1251 .unwrap_or_default();
1252 warnings.push(TypeWarning {
1253 node_id: node_id.to_string(),
1254 message: format!("unknown type \"{urn}\" on {port_kind} \"{port_id}\"{hint}"),
1255 });
1256 }
1257 }
1258}
1259
1260fn check_metadata_annotations(
1262 node_id: &str,
1263 output_metadata: &BTreeMap<DataId, Vec<String>>,
1264 pattern: &Option<String>,
1265 outputs: &BTreeSet<DataId>,
1266 warnings: &mut Vec<TypeWarning>,
1267) {
1268 for output_id in output_metadata.keys() {
1270 if !outputs.contains(output_id) {
1271 warnings.push(TypeWarning {
1272 node_id: node_id.to_string(),
1273 message: format!("output_metadata key \"{output_id}\" not found in outputs list"),
1274 });
1275 }
1276 }
1277
1278 if let Some(pat) = pattern
1280 && crate::types::pattern_metadata_keys(pat).is_none()
1281 {
1282 warnings.push(TypeWarning {
1283 node_id: node_id.to_string(),
1284 message: format!(
1285 "unknown pattern \"{pat}\", expected one of: \
1286 service-server, service-client, action-server, action-client"
1287 ),
1288 });
1289 }
1290}
1291
1292#[allow(clippy::too_many_arguments)]
1297fn check_edge_mismatches_with_compat(
1298 node_id: &str,
1299 input_types: &BTreeMap<DataId, String>,
1300 inputs: &BTreeMap<DataId, Input>,
1301 output_type_map: &BTreeMap<(String, String), String>,
1302 annotated_output_ports: &BTreeSet<(String, String)>,
1303 timer_types: &BTreeMap<DataId, String>,
1304 compat: &crate::types::CompatibilityGraph,
1305 registry: &crate::types::TypeRegistry,
1306 strict: bool,
1307 warnings: &mut Vec<TypeWarning>,
1308 inferences: &mut Vec<TypeInference>,
1309) {
1310 for (input_id, input) in inputs {
1311 match &input.mapping {
1312 InputMapping::User(mapping) => {
1313 let key = (mapping.source.to_string(), mapping.output.to_string());
1314 let upstream_urn = output_type_map.get(&key);
1315 let downstream_urn = input_types.get(input_id);
1316
1317 match (upstream_urn, downstream_urn) {
1318 (Some(out_urn), Some(in_urn)) if !compat.is_compatible(out_urn, in_urn) => {
1319 let schema_detail = check_schema_compat(out_urn, in_urn, registry);
1320 let detail = schema_detail.map(|d| format!(" ({d})")).unwrap_or_default();
1321 warnings.push(TypeWarning {
1322 node_id: node_id.to_string(),
1323 message: format!(
1324 "type mismatch on input \"{input_id}\": \
1325 upstream {}/{} declares \"{out_urn}\", \
1326 but expected \"{in_urn}\"{detail}",
1327 mapping.source, mapping.output,
1328 ),
1329 });
1330 }
1331 (Some(out_urn), None) => {
1332 inferences.push(TypeInference {
1333 node_id: node_id.to_string(),
1334 port_id: input_id.to_string(),
1335 inferred_urn: out_urn.clone(),
1336 source: format!("{}/{}", mapping.source, mapping.output),
1337 });
1338 }
1339 (None, Some(in_urn)) if strict && !annotated_output_ports.contains(&key) => {
1349 warnings.push(TypeWarning {
1350 node_id: node_id.to_string(),
1351 message: format!(
1352 "input \"{input_id}\" expects type \"{in_urn}\" but upstream \
1353 {}/{} has no type annotation",
1354 mapping.source, mapping.output,
1355 ),
1356 });
1357 }
1358 _ => {}
1359 }
1360 }
1361 InputMapping::Timer { .. } => {
1362 if let Some(expected_urn) = input_types.get(input_id) {
1364 let timer_urn = timer_types
1365 .get(input_id)
1366 .map(|s| s.as_str())
1367 .unwrap_or(TIMER_TYPE);
1368 if !compat.is_compatible(timer_urn, expected_urn) {
1369 warnings.push(TypeWarning {
1370 node_id: node_id.to_string(),
1371 message: format!(
1372 "type mismatch on input \"{input_id}\": \
1373 timer provides \"{timer_urn}\", \
1374 but expected \"{expected_urn}\"",
1375 ),
1376 });
1377 }
1378 }
1379 }
1380 InputMapping::Logs(_) => {}
1382 }
1383 }
1384}
1385
1386fn check_schema_compat(
1389 out_urn: &str,
1390 in_urn: &str,
1391 registry: &crate::types::TypeRegistry,
1392) -> Option<String> {
1393 let out_def = registry.resolve(out_urn)?;
1394 let in_def = registry.resolve(in_urn)?;
1395 let out_schema = out_def.to_arrow_schema_with_registry(registry)?;
1396 let in_schema = in_def.to_arrow_schema_with_registry(registry)?;
1397 match crate::types::schema_compatible(&in_schema, &out_schema) {
1399 Ok(()) => None,
1400 Err(e) => Some(e.to_string()),
1401 }
1402}
1403
1404fn validate_ros2_type_format(node_id: &NodeId, name: &str, type_str: &str) -> eyre::Result<()> {
1405 let parts: Vec<&str> = type_str.split('/').collect();
1407 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
1408 bail!(
1409 "node `{node_id}`: invalid type `{type_str}` for `{name}`, \
1410 expected format `package/TypeName` (e.g. `sensor_msgs/Image`)"
1411 );
1412 }
1413 for (label, part) in [("package", parts[0]), ("type name", parts[1])] {
1414 if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1415 bail!(
1416 "node `{node_id}`: invalid {label} `{part}` in type `{type_str}` for `{name}`, \
1417 only ASCII alphanumeric and underscore characters allowed"
1418 );
1419 }
1420 }
1421 Ok(())
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427 use crate::types::TypeRegistry;
1428 use dora_message::config::{Input, InputMapping};
1429 use dora_message::descriptor::{
1430 Descriptor, RmwZenohCompatibility, Ros2BridgeConfig, Ros2Direction, Ros2Role,
1431 Ros2TopicConfig, Ros2TransportConfig,
1432 };
1433 use std::{path::PathBuf, time::Duration};
1434
1435 fn dummy_input() -> Input {
1436 Input {
1437 mapping: InputMapping::Timer {
1438 interval: Duration::from_secs(1),
1439 },
1440 queue_size: None,
1441 input_timeout: None,
1442 queue_policy: None,
1443 }
1444 }
1445
1446 fn service_config(role: Ros2Role) -> Ros2BridgeConfig {
1447 Ros2BridgeConfig {
1448 service: Some("/add_two_ints".into()),
1449 service_type: Some("example_interfaces/AddTwoInts".into()),
1450 role: Some(role),
1451 ..Default::default()
1452 }
1453 }
1454
1455 fn action_config(role: Ros2Role) -> Ros2BridgeConfig {
1456 Ros2BridgeConfig {
1457 action: Some("/navigate".into()),
1458 action_type: Some("nav2_msgs/NavigateToPose".into()),
1459 role: Some(role),
1460 ..Default::default()
1461 }
1462 }
1463
1464 fn runtime_node() -> ResolvedNode {
1465 serde_yaml::from_str(
1466 r#"
1467id: runtime-node
1468operators:
1469 - id: op1
1470 python: op.py
1471 outputs:
1472 - out
1473"#,
1474 )
1475 .unwrap()
1476 }
1477
1478 fn custom_node() -> dora_message::descriptor::CustomNode {
1479 dora_message::descriptor::CustomNode::new("node".to_string())
1480 }
1481
1482 #[test]
1483 fn timing_fields_accept_finite_non_negative_and_none() {
1484 let id = NodeId::from("n".to_owned());
1485 let mut node = custom_node();
1486 check_timing_fields(&id, &node).unwrap();
1488 node.finish_grace_secs = Some(3600.0);
1490 node.health_check_timeout = Some(0.0);
1491 node.startup_timeout = Some(5.0);
1492 check_timing_fields(&id, &node).unwrap();
1493 }
1494
1495 #[test]
1496 fn timing_fields_reject_negative_finish_grace_secs() {
1497 let id = NodeId::from("n".to_owned());
1498 let mut node = custom_node();
1499 node.finish_grace_secs = Some(-1.0);
1500 let err = check_timing_fields(&id, &node).unwrap_err().to_string();
1501 assert!(
1502 err.contains("finish_grace_secs") && err.contains("non-negative"),
1503 "error should name the field and the constraint, got: {err}"
1504 );
1505 }
1506
1507 #[test]
1508 fn timing_fields_reject_negative_startup_timeout() {
1509 let id = NodeId::from("n".to_owned());
1510 let mut node = custom_node();
1511 node.startup_timeout = Some(-0.5);
1512 let err = check_timing_fields(&id, &node).unwrap_err().to_string();
1513 assert!(
1514 err.contains("startup_timeout") && err.contains("non-negative"),
1515 "error should name the field and the constraint, got: {err}"
1516 );
1517 }
1518
1519 #[test]
1520 fn dynamic_node_rejects_startup_timeout() {
1521 let descriptor: Descriptor = serde_yaml::from_str(
1522 r#"
1523nodes:
1524 - id: dyn
1525 path: dynamic
1526 startup_timeout: 5.0
1527"#,
1528 )
1529 .unwrap();
1530 let err = check_dataflow_static(&descriptor).unwrap_err().to_string();
1531 assert!(
1532 err.contains("dynamic node `dyn` cannot specify `startup_timeout`"),
1533 "error should explain dynamic node startup_timeout rejection, got: {err}"
1534 );
1535 }
1536
1537 #[test]
1545 fn timing_values_check_skips_the_dynamic_node_policy_rule() {
1546 let descriptor: Descriptor = serde_yaml::from_str(
1547 r#"
1548nodes:
1549 - id: dyn
1550 path: dynamic
1551 startup_timeout: 5.0
1552"#,
1553 )
1554 .unwrap();
1555 let nodes = descriptor.resolve_aliases_and_set_defaults().unwrap();
1556 let node = nodes.values().next().unwrap();
1557
1558 check_node_timing_values(node)
1559 .expect("an ignored startup_timeout is not a panic risk, so the spawn path allows it");
1560 let err = check_node_timing(node).unwrap_err().to_string();
1561 assert!(
1562 err.contains("dynamic node `dyn` cannot specify `startup_timeout`"),
1563 "the node-join path keeps the policy rule, got: {err}"
1564 );
1565
1566 let bad: Descriptor = serde_yaml::from_str(
1568 r#"
1569nodes:
1570 - id: dyn
1571 path: dynamic
1572 restart_delay: -1.0
1573"#,
1574 )
1575 .unwrap();
1576 let bad_nodes = bad.resolve_aliases_and_set_defaults().unwrap();
1577 let err = check_node_timing_values(bad_nodes.values().next().unwrap())
1578 .unwrap_err()
1579 .to_string();
1580 assert!(
1581 err.contains("restart_delay"),
1582 "the spawn path still rejects a panicking value, got: {err}"
1583 );
1584 }
1585
1586 #[test]
1587 fn timing_fields_reject_non_finite_values() {
1588 let id = NodeId::from("n".to_owned());
1589 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1590 let mut node = custom_node();
1591 node.health_check_timeout = Some(bad);
1592 let err = check_timing_fields(&id, &node).unwrap_err().to_string();
1593 assert!(
1594 err.contains("health_check_timeout"),
1595 "non-finite {bad} should be rejected, got: {err}"
1596 );
1597 }
1598 }
1599
1600 #[test]
1601 fn seconds_field_accepts_none_zero_and_positive() {
1602 check_seconds_field("owner", "field", None, true).unwrap();
1603 check_seconds_field("owner", "field", Some(0.0), true).unwrap();
1604 check_seconds_field("owner", "field", Some(3600.0), true).unwrap();
1605 }
1606
1607 #[test]
1608 fn seconds_field_rejects_negative_and_non_finite() {
1609 for bad in [-1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1610 let err = check_seconds_field("owner", "field", Some(bad), true)
1611 .unwrap_err()
1612 .to_string();
1613 assert!(
1614 err.contains("field") && err.contains("non-negative"),
1615 "{bad} should be rejected with a field/constraint message, got: {err}"
1616 );
1617 }
1618 }
1619
1620 #[test]
1626 fn seconds_field_rejects_values_that_overflow_duration() {
1627 for bad in [1e20, Duration::MAX.as_secs_f64()] {
1628 assert!(Duration::try_from_secs_f64(bad).is_err());
1630 let err = check_seconds_field("owner", "field", Some(bad), true)
1631 .unwrap_err()
1632 .to_string();
1633 assert!(
1634 err.contains("field") && err.contains("smaller than"),
1635 "overflowing {bad} should be rejected with a field/bound message, got: {err}"
1636 );
1637 }
1638 }
1639
1640 #[test]
1642 fn seconds_field_accepts_large_representable_value() {
1643 assert!(Duration::try_from_secs_f64(1e18).is_ok());
1644 check_seconds_field("owner", "field", Some(1e18), true).unwrap();
1645 }
1646
1647 #[test]
1651 fn seconds_field_rejects_zero_when_positive_required() {
1652 check_seconds_field("owner", "field", None, false).unwrap();
1653 check_seconds_field("owner", "field", Some(3600.0), false).unwrap();
1654 for bad in [0.0, 1e-10, -1.0, f64::NAN, f64::INFINITY] {
1658 let err = check_seconds_field("owner", "field", Some(bad), false)
1659 .unwrap_err()
1660 .to_string();
1661 assert!(
1662 err.contains("field") && err.contains("positive"),
1663 "{bad} should be rejected with a field/constraint message, got: {err}"
1664 );
1665 }
1666 check_seconds_field("owner", "field", Some(1e-10), true).unwrap();
1669 }
1670
1671 #[test]
1677 fn check_dataflow_rejects_negative_health_check_interval() {
1678 let dataflow = parse_dataflow(
1679 "\
1680health_check_interval: -1.0
1681nodes:
1682 - id: a
1683 path: node_a
1684 build: cargo build
1685 outputs:
1686 - out
1687",
1688 );
1689 let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1690 .unwrap_err()
1691 .to_string();
1692 assert!(
1693 err.contains("health_check_interval") && err.contains("positive"),
1694 "error should name the field and constraint, got: {err}"
1695 );
1696 }
1697
1698 #[test]
1702 fn check_dataflow_rejects_zero_health_check_interval() {
1703 let dataflow = parse_dataflow(
1704 "\
1705health_check_interval: 0.0
1706nodes:
1707 - id: a
1708 path: node_a
1709 build: cargo build
1710 outputs:
1711 - out
1712",
1713 );
1714 let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1715 .unwrap_err()
1716 .to_string();
1717 assert!(
1718 err.contains("health_check_interval") && err.contains("positive"),
1719 "error should name the field and constraint, got: {err}"
1720 );
1721 }
1722
1723 #[test]
1729 fn check_dataflow_rejects_subnanosecond_health_check_interval() {
1730 let dataflow = parse_dataflow(
1731 "\
1732health_check_interval: 0.0000000001
1733nodes:
1734 - id: a
1735 path: node_a
1736 build: cargo build
1737 outputs:
1738 - out
1739",
1740 );
1741 let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1742 .unwrap_err()
1743 .to_string();
1744 assert!(
1745 err.contains("health_check_interval") && err.contains("positive"),
1746 "error should name the field and constraint, got: {err}"
1747 );
1748 }
1749
1750 #[test]
1753 fn check_dataflow_rejects_non_finite_input_timeout() {
1754 let dataflow = parse_dataflow(
1755 "\
1756nodes:
1757 - id: a
1758 path: node_a
1759 build: cargo build
1760 outputs:
1761 - out
1762 - id: b
1763 path: node_b
1764 build: cargo build
1765 inputs:
1766 x:
1767 source: a/out
1768 input_timeout: .inf
1769",
1770 );
1771 let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1772 .unwrap_err()
1773 .to_string();
1774 assert!(
1775 err.contains("input_timeout") && err.contains('x'),
1776 "error should name the offending input and field, got: {err}"
1777 );
1778 }
1779
1780 #[test]
1783 fn check_dataflow_rejects_overflowing_input_timeout() {
1784 let dataflow = parse_dataflow(
1785 "\
1786nodes:
1787 - id: a
1788 path: node_a
1789 build: cargo build
1790 outputs:
1791 - out
1792 - id: b
1793 path: node_b
1794 build: cargo build
1795 inputs:
1796 x:
1797 source: a/out
1798 input_timeout: 1e20
1799",
1800 );
1801 let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1802 .unwrap_err()
1803 .to_string();
1804 assert!(
1805 err.contains("input_timeout") && err.contains('x'),
1806 "error should name the offending input and field, got: {err}"
1807 );
1808 }
1809
1810 #[test]
1811 fn check_dataflow_accepts_valid_interval_and_timeout() {
1812 let dataflow = parse_dataflow(
1813 "\
1814health_check_interval: 2.5
1815nodes:
1816 - id: a
1817 path: node_a
1818 build: cargo build
1819 outputs:
1820 - out
1821 - id: b
1822 path: node_b
1823 build: cargo build
1824 inputs:
1825 x:
1826 source: a/out
1827 input_timeout: 0.5
1828",
1829 );
1830 check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test")).unwrap();
1831 }
1832
1833 fn user_input(source: &str, output: &str) -> Input {
1834 Input {
1835 mapping: InputMapping::User(UserInputMapping {
1836 source: NodeId::from(source.to_owned()),
1837 output: DataId::from(output.to_owned()),
1838 }),
1839 queue_size: None,
1840 input_timeout: None,
1841 queue_policy: None,
1842 }
1843 }
1844
1845 #[test]
1846 fn runtime_input_with_operator_segment_is_accepted() {
1847 let node = runtime_node();
1848 let nodes = BTreeMap::from([(node.id.clone(), node)]);
1849 check_input(&user_input("runtime-node", "op1/out"), &nodes, "sink/in").unwrap();
1850 }
1851
1852 #[test]
1853 fn runtime_input_without_operator_segment_reports_expected_format() {
1854 let node = runtime_node();
1858 let nodes = BTreeMap::from([(node.id.clone(), node)]);
1859 let err = check_input(&user_input("runtime-node", "out"), &nodes, "sink/in")
1860 .unwrap_err()
1861 .to_string();
1862 assert!(
1863 err.contains("runtime-node/<operator_id>/<output_id>"),
1864 "error should explain the expected format, got: {err}"
1865 );
1866 }
1867
1868 #[test]
1874 fn operator_with_build_command_skips_missing_source_check() {
1875 let dataflow = parse_dataflow(
1876 "\
1877nodes:
1878 - id: runtime-node
1879 operators:
1880 - id: op1
1881 wasm: does/not/exist.wasm
1882 build: cargo build
1883 outputs:
1884 - out
1885",
1886 );
1887 check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test")).unwrap();
1888 }
1889
1890 #[test]
1891 fn operator_without_build_command_rejects_missing_source() {
1892 let dataflow = parse_dataflow(
1893 "\
1894nodes:
1895 - id: runtime-node
1896 operators:
1897 - id: op1
1898 wasm: does/not/exist.wasm
1899 outputs:
1900 - out
1901",
1902 );
1903 let err = check_dataflow(&dataflow, Path::new("/nonexistent-dora-validate-test"))
1904 .unwrap_err()
1905 .to_string();
1906 assert!(
1907 err.contains("no WASM library"),
1908 "missing source without a build command should still be rejected, got: {err}"
1909 );
1910 }
1911
1912 #[test]
1913 fn validate_no_mode_set() {
1914 let config = Ros2BridgeConfig::default();
1915 let err = validate_ros2_config(
1916 &NodeId::from("n".to_owned()),
1917 &config,
1918 &BTreeMap::new(),
1919 &BTreeSet::new(),
1920 )
1921 .unwrap_err();
1922 assert!(err.to_string().contains("requires one of"));
1923 }
1924
1925 #[test]
1926 fn validate_rejects_empty_ros2_zenoh_config_uri() {
1927 let config = Ros2BridgeConfig {
1928 transport: Ros2TransportConfig::Zenoh {
1929 compatibility: RmwZenohCompatibility::Humble,
1930 config_uri: Some(PathBuf::new()),
1931 },
1932 topic: Some("/t".into()),
1933 message_type: Some("a/B".into()),
1934 ..Default::default()
1935 };
1936 let err = validate_ros2_config(
1937 &NodeId::from("n".to_owned()),
1938 &config,
1939 &BTreeMap::new(),
1940 &BTreeSet::from([DataId::from("out".to_owned())]),
1941 )
1942 .unwrap_err();
1943 assert!(err.to_string().contains("config_uri must not be empty"));
1944 }
1945
1946 fn single_topic_config(topic: &str, direction: Ros2Direction) -> Ros2BridgeConfig {
1947 Ros2BridgeConfig {
1948 topic: Some(topic.into()),
1949 message_type: Some("turtlesim/Pose".into()),
1950 direction,
1951 ..Default::default()
1952 }
1953 }
1954
1955 #[test]
1958 fn validate_single_topic_accepts_the_sole_declared_port() {
1959 let config = single_topic_config("/turtle1/pose", Ros2Direction::Subscribe);
1960 validate_ros2_config(
1961 &NodeId::from("n".to_owned()),
1962 &config,
1963 &BTreeMap::new(),
1964 &BTreeSet::from([DataId::from("pose".to_owned())]),
1965 )
1966 .unwrap();
1967 }
1968
1969 #[test]
1972 fn validate_single_topic_rejects_ambiguous_subscribe_output() {
1973 let config = single_topic_config("/turtle1/pose", Ros2Direction::Subscribe);
1974 let err = validate_ros2_config(
1975 &NodeId::from("n".to_owned()),
1976 &config,
1977 &BTreeMap::new(),
1978 &BTreeSet::from([
1979 DataId::from("pose".to_owned()),
1980 DataId::from("log".to_owned()),
1981 ]),
1982 )
1983 .unwrap_err()
1984 .to_string();
1985 assert!(
1986 err.contains("no unambiguous output")
1987 && err.contains("`pose`")
1988 && err.contains("`log`")
1989 && err.contains("turtle1_pose"),
1990 "error should list the candidates and the topic-derived id, got: {err}"
1991 );
1992 }
1993
1994 #[test]
1995 fn validate_multi_topic_rejects_undeclared_subscribe_output() {
1996 let config = Ros2BridgeConfig {
2000 topics: Some(vec![Ros2TopicConfig {
2001 topic: "/scan".into(),
2002 message_type: "sensor_msgs/LaserScan".into(),
2003 direction: Ros2Direction::Subscribe,
2004 output: Some("typo_out".into()),
2005 input: None,
2006 qos: None,
2007 }]),
2008 ..Default::default()
2009 };
2010 let err = validate_ros2_config(
2011 &NodeId::from("n".to_owned()),
2012 &config,
2013 &BTreeMap::new(),
2014 &BTreeSet::from([DataId::from("scan".to_owned())]),
2015 )
2016 .unwrap_err()
2017 .to_string();
2018 assert!(
2019 err.contains("typo_out") && err.contains("not declared"),
2020 "error should name the undeclared output, got: {err}"
2021 );
2022 }
2023
2024 #[test]
2025 fn validate_multi_topic_rejects_undeclared_publish_input() {
2026 let config = Ros2BridgeConfig {
2027 topics: Some(vec![Ros2TopicConfig {
2028 topic: "/cmd_vel".into(),
2029 message_type: "geometry_msgs/Twist".into(),
2030 direction: Ros2Direction::Publish,
2031 output: None,
2032 input: Some("typo_in".into()),
2033 qos: None,
2034 }]),
2035 ..Default::default()
2036 };
2037 let err = validate_ros2_config(
2038 &NodeId::from("n".to_owned()),
2039 &config,
2040 &BTreeMap::from([(DataId::from("cmd".to_owned()), dummy_input())]),
2041 &BTreeSet::new(),
2042 )
2043 .unwrap_err()
2044 .to_string();
2045 assert!(
2046 err.contains("typo_in") && err.contains("not declared"),
2047 "error should name the undeclared input, got: {err}"
2048 );
2049 }
2050
2051 #[test]
2052 fn validate_multi_topic_rejects_undeclared_derived_output() {
2053 let config = Ros2BridgeConfig {
2057 topics: Some(vec![Ros2TopicConfig {
2058 topic: "/scan".into(),
2059 message_type: "sensor_msgs/LaserScan".into(),
2060 direction: Ros2Direction::Subscribe,
2061 output: None,
2062 input: None,
2063 qos: None,
2064 }]),
2065 ..Default::default()
2066 };
2067 let err = validate_ros2_config(
2068 &NodeId::from("n".to_owned()),
2069 &config,
2070 &BTreeMap::new(),
2071 &BTreeSet::from([DataId::from("laser".to_owned())]),
2072 )
2073 .unwrap_err()
2074 .to_string();
2075 assert!(
2076 err.contains("scan") && err.contains("not declared"),
2077 "error should name the derived output, got: {err}"
2078 );
2079 }
2080
2081 #[test]
2082 fn validate_multi_topic_accepts_derived_output() {
2083 let config = Ros2BridgeConfig {
2085 topics: Some(vec![Ros2TopicConfig {
2086 topic: "/scan".into(),
2087 message_type: "sensor_msgs/LaserScan".into(),
2088 direction: Ros2Direction::Subscribe,
2089 output: None,
2090 input: None,
2091 qos: None,
2092 }]),
2093 ..Default::default()
2094 };
2095 validate_ros2_config(
2096 &NodeId::from("n".to_owned()),
2097 &config,
2098 &BTreeMap::new(),
2099 &BTreeSet::from([DataId::from("scan".to_owned())]),
2100 )
2101 .expect("a topic-derived output matching a declared output should validate");
2102 }
2103
2104 #[test]
2105 fn validate_multi_topic_accepts_declared_ports() {
2106 let config = Ros2BridgeConfig {
2107 topics: Some(vec![
2108 Ros2TopicConfig {
2109 topic: "/scan".into(),
2110 message_type: "sensor_msgs/LaserScan".into(),
2111 direction: Ros2Direction::Subscribe,
2112 output: Some("scan".into()),
2113 input: None,
2114 qos: None,
2115 },
2116 Ros2TopicConfig {
2117 topic: "/cmd_vel".into(),
2118 message_type: "geometry_msgs/Twist".into(),
2119 direction: Ros2Direction::Publish,
2120 output: None,
2121 input: Some("cmd".into()),
2122 qos: None,
2123 },
2124 ]),
2125 ..Default::default()
2126 };
2127 validate_ros2_config(
2128 &NodeId::from("n".to_owned()),
2129 &config,
2130 &BTreeMap::from([(DataId::from("cmd".to_owned()), dummy_input())]),
2131 &BTreeSet::from([DataId::from("scan".to_owned())]),
2132 )
2133 .expect("multi-topic config mapping declared ports should validate");
2134 }
2135
2136 #[test]
2137 fn validate_multiple_modes() {
2138 let config = Ros2BridgeConfig {
2139 topic: Some("/t".into()),
2140 service: Some("/s".into()),
2141 message_type: Some("a/B".into()),
2142 service_type: Some("a/B".into()),
2143 role: Some(Ros2Role::Client),
2144 ..Default::default()
2145 };
2146 let err = validate_ros2_config(
2147 &NodeId::from("n".to_owned()),
2148 &config,
2149 &BTreeMap::new(),
2150 &BTreeSet::new(),
2151 )
2152 .unwrap_err();
2153 assert!(err.to_string().contains("multiple of"));
2154 }
2155
2156 #[test]
2157 fn validate_service_client_ok() {
2158 let config = service_config(Ros2Role::Client);
2159 let mut inputs = BTreeMap::new();
2160 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2161 let mut outputs = BTreeSet::new();
2162 outputs.insert(DataId::from("response".to_owned()));
2163 validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs).unwrap();
2164 }
2165
2166 #[test]
2167 fn validate_service_client_missing_service_type() {
2168 let config = Ros2BridgeConfig {
2169 service: Some("/svc".into()),
2170 role: Some(Ros2Role::Client),
2171 ..Default::default()
2172 };
2173 let mut inputs = BTreeMap::new();
2174 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2175 let mut outputs = BTreeSet::new();
2176 outputs.insert(DataId::from("response".to_owned()));
2177 let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2178 .unwrap_err();
2179 assert!(err.to_string().contains("service_type"));
2180 }
2181
2182 #[test]
2183 fn validate_service_client_missing_role() {
2184 let config = Ros2BridgeConfig {
2185 service: Some("/svc".into()),
2186 service_type: Some("a/B".into()),
2187 ..Default::default()
2188 };
2189 let mut inputs = BTreeMap::new();
2190 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2191 let mut outputs = BTreeSet::new();
2192 outputs.insert(DataId::from("response".to_owned()));
2193 let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2194 .unwrap_err();
2195 assert!(err.to_string().contains("role"));
2196 }
2197
2198 #[test]
2199 fn validate_service_client_no_inputs() {
2200 let config = service_config(Ros2Role::Client);
2201 let mut outputs = BTreeSet::new();
2202 outputs.insert(DataId::from("response".to_owned()));
2203 let err = validate_ros2_config(
2204 &NodeId::from("n".to_owned()),
2205 &config,
2206 &BTreeMap::new(),
2207 &outputs,
2208 )
2209 .unwrap_err();
2210 assert!(err.to_string().contains("input"));
2211 }
2212
2213 #[test]
2214 fn validate_service_client_no_outputs() {
2215 let config = service_config(Ros2Role::Client);
2216 let mut inputs = BTreeMap::new();
2217 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2218 let err = validate_ros2_config(
2219 &NodeId::from("n".to_owned()),
2220 &config,
2221 &inputs,
2222 &BTreeSet::new(),
2223 )
2224 .unwrap_err();
2225 assert!(err.to_string().contains("output"));
2226 }
2227
2228 #[test]
2229 fn validate_service_server_ok() {
2230 let config = service_config(Ros2Role::Server);
2231 let mut inputs = BTreeMap::new();
2232 inputs.insert(DataId::from("response".to_owned()), dummy_input());
2233 let mut outputs = BTreeSet::new();
2234 outputs.insert(DataId::from("request".to_owned()));
2235 validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs).unwrap();
2236 }
2237
2238 #[test]
2239 fn validate_service_server_no_inputs() {
2240 let config = service_config(Ros2Role::Server);
2241 let mut outputs = BTreeSet::new();
2242 outputs.insert(DataId::from("request".to_owned()));
2243 let err = validate_ros2_config(
2244 &NodeId::from("n".to_owned()),
2245 &config,
2246 &BTreeMap::new(),
2247 &outputs,
2248 )
2249 .unwrap_err();
2250 assert!(err.to_string().contains("input"));
2251 }
2252
2253 #[test]
2254 fn validate_service_server_no_outputs() {
2255 let config = service_config(Ros2Role::Server);
2256 let mut inputs = BTreeMap::new();
2257 inputs.insert(DataId::from("response".to_owned()), dummy_input());
2258 let err = validate_ros2_config(
2259 &NodeId::from("n".to_owned()),
2260 &config,
2261 &inputs,
2262 &BTreeSet::new(),
2263 )
2264 .unwrap_err();
2265 assert!(err.to_string().contains("output"));
2266 }
2267
2268 #[test]
2269 fn validate_action_client_ok() {
2270 let config = action_config(Ros2Role::Client);
2271 let mut inputs = BTreeMap::new();
2272 inputs.insert(DataId::from("goal".to_owned()), dummy_input());
2273 let mut outputs = BTreeSet::new();
2274 outputs.insert(DataId::from("feedback".to_owned()));
2275 outputs.insert(DataId::from("result".to_owned()));
2276 validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs).unwrap();
2277 }
2278
2279 #[test]
2280 fn validate_action_client_missing_action_type() {
2281 let config = Ros2BridgeConfig {
2282 action: Some("/nav".into()),
2283 role: Some(Ros2Role::Client),
2284 ..Default::default()
2285 };
2286 let mut inputs = BTreeMap::new();
2287 inputs.insert(DataId::from("goal".to_owned()), dummy_input());
2288 let mut outputs = BTreeSet::new();
2289 outputs.insert(DataId::from("feedback".to_owned()));
2290 let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2291 .unwrap_err();
2292 assert!(err.to_string().contains("action_type"));
2293 }
2294
2295 #[test]
2296 fn validate_action_client_no_inputs() {
2297 let config = action_config(Ros2Role::Client);
2298 let mut outputs = BTreeSet::new();
2299 outputs.insert(DataId::from("feedback".to_owned()));
2300 let err = validate_ros2_config(
2301 &NodeId::from("n".to_owned()),
2302 &config,
2303 &BTreeMap::new(),
2304 &outputs,
2305 )
2306 .unwrap_err();
2307 assert!(err.to_string().contains("input"));
2308 }
2309
2310 #[test]
2311 fn validate_action_client_no_outputs() {
2312 let config = action_config(Ros2Role::Client);
2313 let mut inputs = BTreeMap::new();
2314 inputs.insert(DataId::from("goal".to_owned()), dummy_input());
2315 let err = validate_ros2_config(
2316 &NodeId::from("n".to_owned()),
2317 &config,
2318 &inputs,
2319 &BTreeSet::new(),
2320 )
2321 .unwrap_err();
2322 assert!(err.to_string().contains("output"));
2323 }
2324
2325 #[test]
2326 fn validate_action_server_ok() {
2327 let config = action_config(Ros2Role::Server);
2328 let mut inputs = BTreeMap::new();
2329 inputs.insert(DataId::from("feedback".to_owned()), dummy_input());
2330 let mut outputs = BTreeSet::new();
2331 outputs.insert(DataId::from("goal".to_owned()));
2332 validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs).unwrap();
2333 }
2334
2335 #[test]
2336 fn validate_action_server_no_inputs() {
2337 let config = action_config(Ros2Role::Server);
2338 let mut outputs = BTreeSet::new();
2339 outputs.insert(DataId::from("goal".to_owned()));
2340 let err = validate_ros2_config(
2341 &NodeId::from("n".to_owned()),
2342 &config,
2343 &BTreeMap::new(),
2344 &outputs,
2345 )
2346 .unwrap_err();
2347 assert!(err.to_string().contains("input"));
2348 }
2349
2350 #[test]
2351 fn validate_action_server_no_outputs() {
2352 let config = action_config(Ros2Role::Server);
2353 let mut inputs = BTreeMap::new();
2354 inputs.insert(DataId::from("feedback".to_owned()), dummy_input());
2355 let err = validate_ros2_config(
2356 &NodeId::from("n".to_owned()),
2357 &config,
2358 &inputs,
2359 &BTreeSet::new(),
2360 )
2361 .unwrap_err();
2362 assert!(err.to_string().contains("output"));
2363 }
2364
2365 #[test]
2366 fn validate_bad_type_format() {
2367 let config = Ros2BridgeConfig {
2368 service: Some("/svc".into()),
2369 service_type: Some("invalid_no_slash".into()),
2370 role: Some(Ros2Role::Client),
2371 ..Default::default()
2372 };
2373 let mut inputs = BTreeMap::new();
2374 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2375 let mut outputs = BTreeSet::new();
2376 outputs.insert(DataId::from("response".to_owned()));
2377 let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2378 .unwrap_err();
2379 assert!(err.to_string().contains("package/TypeName"));
2380 }
2381
2382 #[test]
2383 fn validate_type_rejects_special_chars() {
2384 let config = Ros2BridgeConfig {
2385 service: Some("/svc".into()),
2386 service_type: Some("pkg-bad/Evil".into()),
2387 role: Some(Ros2Role::Client),
2388 ..Default::default()
2389 };
2390 let mut inputs = BTreeMap::new();
2391 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2392 let mut outputs = BTreeSet::new();
2393 outputs.insert(DataId::from("response".to_owned()));
2394 let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2395 .unwrap_err();
2396 assert!(err.to_string().contains("alphanumeric"));
2397 }
2398
2399 #[test]
2400 fn ros2_name_accepts_valid() {
2401 let node = NodeId::from("n".to_owned());
2402 for name in [
2403 "topic",
2404 "/topic",
2405 "/a/b/c",
2406 "/add_two_ints",
2407 "ns/sub_topic",
2408 "/navigate",
2409 "_hidden",
2410 "/_internal/state",
2411 "/_ros2cli_node",
2412 ] {
2413 validate_ros2_name(&node, "topic", name)
2414 .unwrap_or_else(|e| panic!("`{name}` should be valid: {e}"));
2415 }
2416 }
2417
2418 #[test]
2419 fn ros2_name_rejects_double_leading_slash() {
2420 let node = NodeId::from("n".to_owned());
2421 let err = validate_ros2_name(&node, "topic", "//topic").unwrap_err();
2422 assert!(err.to_string().contains("consecutive slashes"));
2423 }
2424
2425 #[test]
2426 fn ros2_name_rejects_trailing_slash() {
2427 let node = NodeId::from("n".to_owned());
2428 let err = validate_ros2_name(&node, "topic", "topic/").unwrap_err();
2429 assert!(err.to_string().contains("end with"));
2430 }
2431
2432 #[test]
2433 fn ros2_name_rejects_consecutive_interior_slashes() {
2434 let node = NodeId::from("n".to_owned());
2435 let err = validate_ros2_name(&node, "topic", "topic//sub").unwrap_err();
2436 assert!(err.to_string().contains("consecutive slashes"));
2437 }
2438
2439 #[test]
2440 fn ros2_name_rejects_bare_slash() {
2441 let node = NodeId::from("n".to_owned());
2442 let err = validate_ros2_name(&node, "topic", "/").unwrap_err();
2443 assert!(err.to_string().contains("bare"));
2444 }
2445
2446 #[test]
2447 fn ros2_name_accepts_leading_underscore() {
2448 let node = NodeId::from("n".to_owned());
2449 for name in ["_hidden", "/_internal/state", "/_ros2cli_node"] {
2450 validate_ros2_name(&node, "topic", name)
2451 .unwrap_or_else(|e| panic!("`{name}` should be valid (hidden topic): {e}"));
2452 }
2453 }
2454
2455 #[test]
2456 fn ros2_name_rejects_token_starting_with_digit() {
2457 let node = NodeId::from("n".to_owned());
2458 let err = validate_ros2_name(&node, "topic", "/2bad").unwrap_err();
2459 assert!(err.to_string().contains("digit"));
2460 }
2461
2462 #[test]
2463 fn validate_qos_bad_durability() {
2464 let config = Ros2BridgeConfig {
2465 service: Some("/svc".into()),
2466 service_type: Some("a/B".into()),
2467 role: Some(Ros2Role::Client),
2468 qos: dora_message::descriptor::Ros2QosConfig {
2469 durability: Some("persistent".into()),
2470 ..Default::default()
2471 },
2472 ..Default::default()
2473 };
2474 let mut inputs = BTreeMap::new();
2475 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2476 let mut outputs = BTreeSet::new();
2477 outputs.insert(DataId::from("response".to_owned()));
2478 let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2479 .unwrap_err();
2480 assert!(err.to_string().contains("durability"));
2481 }
2482
2483 #[test]
2484 fn validate_qos_keep_last_out_of_range() {
2485 let config = Ros2BridgeConfig {
2486 service: Some("/svc".into()),
2487 service_type: Some("a/B".into()),
2488 role: Some(Ros2Role::Client),
2489 qos: dora_message::descriptor::Ros2QosConfig {
2490 keep_last: Some(100_000),
2491 ..Default::default()
2492 },
2493 ..Default::default()
2494 };
2495 let mut inputs = BTreeMap::new();
2496 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2497 let mut outputs = BTreeSet::new();
2498 outputs.insert(DataId::from("response".to_owned()));
2499 let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2500 .unwrap_err();
2501 assert!(err.to_string().contains("keep_last"));
2502 }
2503
2504 fn parse_dataflow(yaml: &str) -> Descriptor {
2507 serde_yaml::from_str(yaml).expect("test YAML should parse")
2508 }
2509
2510 #[test]
2511 fn type_check_no_annotations_no_warnings() {
2512 let dataflow = parse_dataflow("nodes:\n - id: a\n");
2513 let reg = TypeRegistry::new();
2514 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2515 assert!(warnings.is_empty());
2516 }
2517
2518 #[test]
2519 fn type_check_valid_output_type() {
2520 let dataflow = parse_dataflow(
2521 "nodes:\n - id: camera\n outputs:\n - image\n output_types:\n image: std/media/v1/Image\n",
2522 );
2523 let reg = TypeRegistry::new();
2524 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2525 assert!(warnings.is_empty());
2526 }
2527
2528 #[test]
2529 fn type_check_output_types_key_not_in_outputs() {
2530 let dataflow = parse_dataflow(
2531 "nodes:\n - id: camera\n output_types:\n image: std/media/v1/Image\n",
2532 );
2533 let reg = TypeRegistry::new();
2534 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2535 assert_eq!(warnings.len(), 1);
2536 assert!(warnings[0].message.contains("not found in outputs"));
2537 }
2538
2539 #[test]
2540 fn type_check_unknown_urn_with_suggestion() {
2541 let dataflow = parse_dataflow(
2542 "nodes:\n - id: camera\n outputs:\n - image\n output_types:\n image: std/media/v1/Imag\n",
2543 );
2544 let reg = TypeRegistry::new();
2545 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2546 assert_eq!(warnings.len(), 1);
2547 assert!(warnings[0].message.contains("unknown type"));
2548 assert!(warnings[0].message.contains("did you mean"));
2549 }
2550
2551 #[test]
2552 fn type_check_matching_edge_types() {
2553 let dataflow = parse_dataflow(
2554 "\
2555nodes:
2556 - id: sender
2557 outputs:
2558 - data
2559 output_types:
2560 data: std/core/v1/Float32
2561 - id: receiver
2562 inputs:
2563 data: sender/data
2564 input_types:
2565 data: std/core/v1/Float32
2566",
2567 );
2568 let reg = TypeRegistry::new();
2569 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2570 assert!(warnings.is_empty());
2571 }
2572
2573 #[test]
2574 fn type_check_mismatched_edge_types() {
2575 let dataflow = parse_dataflow(
2576 "\
2577nodes:
2578 - id: sender
2579 outputs:
2580 - data
2581 output_types:
2582 data: std/core/v1/Float32
2583 - id: receiver
2584 inputs:
2585 data: sender/data
2586 input_types:
2587 data: std/media/v1/Image
2588",
2589 );
2590 let reg = TypeRegistry::new();
2591 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2592 assert_eq!(warnings.len(), 1);
2593 assert!(warnings[0].message.contains("type mismatch"));
2594 assert!(warnings[0].message.contains("Float32"));
2595 assert!(warnings[0].message.contains("Image"));
2596 }
2597
2598 #[test]
2601 fn strict_types_parses_in_yaml() {
2602 let dataflow = parse_dataflow("nodes:\n - id: a\nstrict_types: true\n");
2603 assert_eq!(dataflow.strict_types, Some(true));
2604 }
2605
2606 #[test]
2607 fn strict_mode_warns_on_unannotated_upstream() {
2608 let dataflow = parse_dataflow(
2609 "\
2610nodes:
2611 - id: sender
2612 outputs:
2613 - data
2614 - id: receiver
2615 inputs:
2616 data: sender/data
2617 input_types:
2618 data: std/core/v1/Float32
2619",
2620 );
2621 let reg = TypeRegistry::new();
2622 let result = check_type_annotations_full(&dataflow, ®, true);
2623 assert!(!result.warnings.is_empty());
2624 assert!(result.warnings[0].message.contains("no type annotation"));
2625 }
2626
2627 #[test]
2634 fn strict_mode_no_missing_annotation_warning_for_typoed_upstream() {
2635 let dataflow = parse_dataflow(
2636 "\
2637nodes:
2638 - id: sender
2639 outputs:
2640 - image
2641 output_types:
2642 image: std/media/v1/Imag
2643 - id: receiver
2644 inputs:
2645 image: sender/image
2646 input_types:
2647 image: std/media/v1/Image
2648",
2649 );
2650 let reg = TypeRegistry::new();
2651 let result = check_type_annotations_full(&dataflow, ®, true);
2652 assert!(
2654 result
2655 .warnings
2656 .iter()
2657 .any(|w| w.message.contains("unknown type")),
2658 "expected an unknown-type warning for the typo, got: {:?}",
2659 result.warnings
2660 );
2661 assert!(
2664 !result
2665 .warnings
2666 .iter()
2667 .any(|w| w.message.contains("no type annotation")),
2668 "must not claim the annotated-but-typoed upstream has no annotation, got: {:?}",
2669 result.warnings
2670 );
2671 }
2672
2673 #[test]
2674 fn non_strict_no_warning_on_unannotated_upstream() {
2675 let dataflow = parse_dataflow(
2676 "\
2677nodes:
2678 - id: sender
2679 outputs:
2680 - data
2681 - id: receiver
2682 inputs:
2683 data: sender/data
2684 input_types:
2685 data: std/core/v1/Float32
2686",
2687 );
2688 let reg = TypeRegistry::new();
2689 let result = check_type_annotations_full(&dataflow, ®, false);
2690 assert!(result.warnings.is_empty());
2691 }
2692
2693 #[test]
2696 fn inference_from_annotated_upstream() {
2697 let dataflow = parse_dataflow(
2698 "\
2699nodes:
2700 - id: sensor
2701 outputs:
2702 - reading
2703 output_types:
2704 reading: std/core/v1/Float64
2705 - id: processor
2706 inputs:
2707 reading: sensor/reading
2708",
2709 );
2710 let reg = TypeRegistry::new();
2711 let result = check_type_annotations_full(&dataflow, ®, false);
2712 assert!(result.warnings.is_empty());
2713 assert_eq!(result.inferences.len(), 1);
2714 assert_eq!(result.inferences[0].inferred_urn, "std/core/v1/Float64");
2715 assert_eq!(result.inferences[0].port_id, "reading");
2716 }
2717
2718 #[test]
2719 fn no_inference_when_both_annotated() {
2720 let dataflow = parse_dataflow(
2721 "\
2722nodes:
2723 - id: sender
2724 outputs:
2725 - data
2726 output_types:
2727 data: std/core/v1/Float32
2728 - id: receiver
2729 inputs:
2730 data: sender/data
2731 input_types:
2732 data: std/core/v1/Float32
2733",
2734 );
2735 let reg = TypeRegistry::new();
2736 let result = check_type_annotations_full(&dataflow, ®, false);
2737 assert!(result.inferences.is_empty());
2738 }
2739
2740 #[test]
2741 fn no_inference_when_neither_annotated() {
2742 let dataflow = parse_dataflow(
2743 "\
2744nodes:
2745 - id: sender
2746 outputs:
2747 - data
2748 - id: receiver
2749 inputs:
2750 data: sender/data
2751",
2752 );
2753 let reg = TypeRegistry::new();
2754 let result = check_type_annotations_full(&dataflow, ®, false);
2755 assert!(result.inferences.is_empty());
2756 }
2757
2758 #[test]
2761 fn compat_uint8_to_uint32_edge() {
2762 let dataflow = parse_dataflow(
2763 "\
2764nodes:
2765 - id: sender
2766 outputs:
2767 - data
2768 output_types:
2769 data: std/core/v1/UInt8
2770 - id: receiver
2771 inputs:
2772 data: sender/data
2773 input_types:
2774 data: std/core/v1/UInt32
2775",
2776 );
2777 let reg = TypeRegistry::new();
2778 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2779 assert!(warnings.is_empty(), "UInt8 -> UInt32 should be compatible");
2780 }
2781
2782 #[test]
2783 fn compat_any_to_bytes_edge() {
2784 let dataflow = parse_dataflow(
2785 "\
2786nodes:
2787 - id: sender
2788 outputs:
2789 - data
2790 output_types:
2791 data: std/media/v1/Image
2792 - id: receiver
2793 inputs:
2794 data: sender/data
2795 input_types:
2796 data: std/core/v1/Bytes
2797",
2798 );
2799 let reg = TypeRegistry::new();
2800 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2801 assert!(
2802 warnings.is_empty(),
2803 "anything -> Bytes should be compatible"
2804 );
2805 }
2806
2807 #[test]
2808 fn compat_user_defined_rule_in_yaml() {
2809 let dataflow = parse_dataflow(
2810 "\
2811type_rules:
2812 - from: std/core/v1/UInt8
2813 to: std/core/v1/String
2814nodes:
2815 - id: sender
2816 outputs:
2817 - data
2818 output_types:
2819 data: std/core/v1/UInt8
2820 - id: receiver
2821 inputs:
2822 data: sender/data
2823 input_types:
2824 data: std/core/v1/String
2825",
2826 );
2827 let reg = TypeRegistry::new();
2828 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2829 assert!(
2830 warnings.is_empty(),
2831 "user-defined rule should make this compatible"
2832 );
2833 }
2834
2835 #[test]
2838 fn metadata_pattern_resolves() {
2839 let dataflow = parse_dataflow(
2840 "\
2841nodes:
2842 - id: srv
2843 pattern: service-server
2844 outputs:
2845 - response
2846",
2847 );
2848 let reg = TypeRegistry::new();
2849 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2850 assert!(warnings.is_empty());
2851 }
2852
2853 #[test]
2854 fn metadata_unknown_pattern() {
2855 let dataflow = parse_dataflow(
2856 "\
2857nodes:
2858 - id: srv
2859 pattern: unknown-pattern
2860 outputs:
2861 - response
2862",
2863 );
2864 let reg = TypeRegistry::new();
2865 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2866 assert_eq!(warnings.len(), 1);
2867 assert!(warnings[0].message.contains("unknown pattern"));
2868 }
2869
2870 #[test]
2871 fn metadata_output_key_not_in_outputs() {
2872 let dataflow = parse_dataflow(
2873 "\
2874nodes:
2875 - id: srv
2876 output_metadata:
2877 missing_port: [request_id]
2878 outputs:
2879 - response
2880",
2881 );
2882 let reg = TypeRegistry::new();
2883 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2884 assert_eq!(warnings.len(), 1);
2885 assert!(warnings[0].message.contains("output_metadata key"));
2886 }
2887
2888 #[test]
2891 fn timer_input_type_mismatch() {
2892 let dataflow = parse_dataflow(
2893 "\
2894nodes:
2895 - id: node
2896 inputs:
2897 tick: dora/timer/millis/100
2898 input_types:
2899 tick: std/media/v1/Image
2900",
2901 );
2902 let reg = TypeRegistry::new();
2903 let warnings = check_type_annotations_full(&dataflow, ®, false).warnings;
2904 assert!(!warnings.is_empty());
2905 assert!(warnings[0].message.contains("type mismatch"));
2906 }
2907
2908 #[test]
2909 fn validate_qos_negative_lease_duration() {
2910 let config = Ros2BridgeConfig {
2911 service: Some("/svc".into()),
2912 service_type: Some("a/B".into()),
2913 role: Some(Ros2Role::Client),
2914 qos: dora_message::descriptor::Ros2QosConfig {
2915 lease_duration: Some(-1.0),
2916 ..Default::default()
2917 },
2918 ..Default::default()
2919 };
2920 let mut inputs = BTreeMap::new();
2921 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2922 let mut outputs = BTreeSet::new();
2923 outputs.insert(DataId::from("response".to_owned()));
2924 let err = validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2925 .unwrap_err();
2926 assert!(err.to_string().contains("lease_duration"));
2927 }
2928
2929 #[test]
2934 fn validate_qos_overflowing_durations_are_rejected() {
2935 let base = Ros2BridgeConfig {
2936 service: Some("/svc".into()),
2937 service_type: Some("a/B".into()),
2938 role: Some(Ros2Role::Client),
2939 ..Default::default()
2940 };
2941 let mut inputs = BTreeMap::new();
2942 inputs.insert(DataId::from("request".to_owned()), dummy_input());
2943 let mut outputs = BTreeSet::new();
2944 outputs.insert(DataId::from("response".to_owned()));
2945
2946 for (field, qos) in [
2948 (
2949 "lease_duration",
2950 dora_message::descriptor::Ros2QosConfig {
2951 lease_duration: Some(1e300),
2952 ..Default::default()
2953 },
2954 ),
2955 (
2956 "max_blocking_time",
2957 dora_message::descriptor::Ros2QosConfig {
2958 reliable: true,
2959 max_blocking_time: Some(1e300),
2960 ..Default::default()
2961 },
2962 ),
2963 ] {
2964 let config = Ros2BridgeConfig {
2965 qos,
2966 ..base.clone()
2967 };
2968 let err =
2969 validate_ros2_config(&NodeId::from("n".to_owned()), &config, &inputs, &outputs)
2970 .unwrap_err();
2971 assert!(
2972 err.to_string().contains(field),
2973 "expected error naming `{field}`, got: {err}"
2974 );
2975 }
2976 }
2977
2978 #[test]
2981 fn wiring_valid_dataflow() {
2982 let yaml = r#"
2983nodes:
2984 - id: source
2985 path: source.py
2986 outputs:
2987 - data
2988 - id: sink
2989 path: sink.py
2990 inputs:
2991 data: source/data
2992"#;
2993 let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
2994 check_wiring(&descriptor).unwrap();
2995 }
2996
2997 #[test]
3002 fn ros2_example_dataflows_have_valid_port_mappings() {
3003 let examples = [
3004 "examples/ros2-bridge/yaml-bridge/dataflow.yml",
3005 "examples/ros2-bridge/yaml-bridge/dataflow-zenoh.yml",
3006 "examples/ros2-bridge/yaml-bridge-service/dataflow-client.yml",
3007 "examples/ros2-bridge/yaml-bridge-service/dataflow-client-zenoh.yml",
3008 "examples/ros2-bridge/yaml-bridge-service/dataflow-server.yml",
3009 "examples/ros2-bridge/yaml-bridge-service/dataflow-server-zenoh.yml",
3010 "examples/ros2-bridge/yaml-bridge-action/dataflow.yml",
3011 "examples/ros2-bridge/yaml-bridge-action/dataflow-zenoh.yml",
3012 "examples/ros2-bridge/yaml-bridge-action-server/dataflow.yml",
3013 ];
3014 for relative in examples {
3015 let Some(yaml) = repo_fixture(relative) else {
3016 continue; };
3018 let descriptor: Descriptor = serde_yaml::from_str(&yaml).unwrap();
3019 if let Err(err) = validate_ros2_configs(&descriptor) {
3020 panic!("shipped example `{relative}` has an invalid ros2 config: {err}");
3021 }
3022 }
3023 }
3024
3025 fn repo_fixture(relative: &str) -> Option<String> {
3036 let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
3037 if !root.join("Cargo.toml").is_file() {
3038 return None;
3039 }
3040 let path = root.join(relative);
3041 Some(
3042 std::fs::read_to_string(&path)
3043 .unwrap_or_else(|e| panic!("fixture {} is missing: {e}", path.display())),
3044 )
3045 }
3046
3047 #[test]
3048 fn ros2_zenoh_documentation_examples_parse_with_explicit_profiles() {
3049 let examples = [
3050 "examples/ros2-bridge/yaml-bridge/dataflow-zenoh.yml",
3051 "examples/ros2-bridge/yaml-bridge-service/dataflow-client-zenoh.yml",
3052 "examples/ros2-bridge/yaml-bridge-action/dataflow-zenoh.yml",
3053 ];
3054 for relative in examples {
3055 let Some(yaml) = repo_fixture(relative) else {
3056 continue; };
3058 let descriptor: Descriptor = serde_yaml::from_str(&yaml).unwrap();
3059 let ros2 = descriptor
3060 .nodes
3061 .iter()
3062 .find_map(|node| node.ros2.as_ref())
3063 .unwrap();
3064 assert!(matches!(
3065 ros2.transport,
3066 Ros2TransportConfig::Zenoh {
3067 compatibility: RmwZenohCompatibility::Humble,
3068 ..
3069 }
3070 ));
3071 }
3072 }
3073
3074 #[test]
3075 fn ros2_zenoh_documentation_links_upstream_wire_contract() {
3076 let Some(guide) = repo_fixture("guide/src/advanced/ros2-bridge.md") else {
3077 return; };
3079 assert!(guide.contains("https://github.com/ros2/rmw_zenoh/blob/rolling/docs/design.md"));
3080 assert!(guide.contains("https://www.ros.org/reps/rep-2016.html"));
3081 }
3082
3083 #[test]
3084 fn wiring_rejects_nonexistent_source_node() {
3085 let yaml = r#"
3086nodes:
3087 - id: sink
3088 path: sink.py
3089 inputs:
3090 data: nonexistent/data
3091"#;
3092 let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3093 let err = check_wiring(&descriptor).unwrap_err();
3094 assert!(
3095 err.to_string().contains("nonexistent"),
3096 "expected error about missing node, got: {err}"
3097 );
3098 }
3099
3100 #[test]
3101 fn wiring_rejects_nonexistent_output() {
3102 let yaml = r#"
3103nodes:
3104 - id: source
3105 path: source.py
3106 outputs:
3107 - data
3108 - id: sink
3109 path: sink.py
3110 inputs:
3111 data: source/typo
3112"#;
3113 let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3114 let err = check_wiring(&descriptor).unwrap_err();
3115 assert!(
3116 err.to_string().contains("typo"),
3117 "expected error about missing output, got: {err}"
3118 );
3119 }
3120
3121 #[test]
3122 fn wiring_runtime_input_id_order() {
3123 let yaml = r#"
3127nodes:
3128 - id: runtime-node
3129 operators:
3130 - id: my-operator
3131 shared-library: op
3132 inputs:
3133 tick: nonexistent/data
3134 outputs:
3135 - status
3136"#;
3137 let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3138 let err = check_wiring(&descriptor).unwrap_err();
3139 let msg = err.to_string();
3140 assert!(
3141 msg.contains("runtime-node/my-operator/tick"),
3142 "expected node/operator/input order, got: {msg}"
3143 );
3144 assert!(
3145 !msg.contains("my-operator/runtime-node/tick"),
3146 "input id should not use reversed operator/node order, got: {msg}"
3147 );
3148 }
3149
3150 #[test]
3159 fn infers_type_across_single_operator_edge() {
3160 let dataflow = parse_dataflow(
3161 "\
3162nodes:
3163 - id: producer
3164 operator:
3165 python: producer.py
3166 outputs:
3167 - result
3168 output_types:
3169 result: std/core/v1/Float64
3170 - id: consumer
3171 inputs:
3172 reading: producer/result
3173",
3174 );
3175 let reg = TypeRegistry::new();
3176 let result = check_type_annotations_full(&dataflow, ®, false);
3177 assert!(
3178 result.warnings.is_empty(),
3179 "unexpected: {:?}",
3180 result.warnings
3181 );
3182 assert_eq!(
3183 result.inferences.len(),
3184 1,
3185 "should infer from the operator output"
3186 );
3187 assert_eq!(result.inferences[0].inferred_urn, "std/core/v1/Float64");
3188 assert_eq!(result.inferences[0].port_id, "reading");
3189 }
3190
3191 #[test]
3192 fn detects_mismatch_across_single_operator_edge() {
3193 let dataflow = parse_dataflow(
3194 "\
3195nodes:
3196 - id: producer
3197 operator:
3198 python: producer.py
3199 outputs:
3200 - result
3201 output_types:
3202 result: std/core/v1/Float64
3203 - id: consumer
3204 inputs:
3205 reading: producer/result
3206 input_types:
3207 reading: std/core/v1/Int32
3208",
3209 );
3210 let reg = TypeRegistry::new();
3211 let result = check_type_annotations_full(&dataflow, ®, false);
3212 assert!(
3213 result
3214 .warnings
3215 .iter()
3216 .any(|w| w.message.contains("type mismatch")),
3217 "expected a type mismatch warning, got: {:?}",
3218 result.warnings
3219 );
3220 }
3221
3222 #[test]
3223 fn strict_mode_no_false_positive_across_single_operator_edge() {
3224 let dataflow = parse_dataflow(
3227 "\
3228nodes:
3229 - id: producer
3230 operator:
3231 python: producer.py
3232 outputs:
3233 - result
3234 output_types:
3235 result: std/core/v1/Float64
3236 - id: consumer
3237 inputs:
3238 reading: producer/result
3239 input_types:
3240 reading: std/core/v1/Float64
3241",
3242 );
3243 let reg = TypeRegistry::new();
3244 let result = check_type_annotations_full(&dataflow, ®, true);
3245 assert!(
3246 !result
3247 .warnings
3248 .iter()
3249 .any(|w| w.message.contains("no type annotation")),
3250 "annotated upstream must not trigger a strict no-annotation warning, got: {:?}",
3251 result.warnings
3252 );
3253 }
3254
3255 #[test]
3269 fn parse_byte_size_bare_number() {
3270 assert_eq!(parse_byte_size("0").unwrap(), 0);
3271 assert_eq!(parse_byte_size("1").unwrap(), 1);
3272 assert_eq!(parse_byte_size("100").unwrap(), 100);
3273 assert_eq!(parse_byte_size("1000000").unwrap(), 1_000_000);
3274 }
3275
3276 #[test]
3277 fn parse_byte_size_bytes_unit() {
3278 assert_eq!(parse_byte_size("0B").unwrap(), 0);
3279 assert_eq!(parse_byte_size("1B").unwrap(), 1);
3280 assert_eq!(parse_byte_size("42B").unwrap(), 42);
3281 assert_eq!(parse_byte_size("42b").unwrap(), 42);
3283 }
3284
3285 #[test]
3286 fn parse_byte_size_kilobyte_units() {
3287 assert_eq!(parse_byte_size("1KB").unwrap(), 1024);
3288 assert_eq!(parse_byte_size("1K").unwrap(), 1024);
3289 assert_eq!(parse_byte_size("2KB").unwrap(), 2048);
3290 assert_eq!(parse_byte_size("4K").unwrap(), 4096);
3291 assert_eq!(parse_byte_size("1kb").unwrap(), 1024);
3293 assert_eq!(parse_byte_size("1k").unwrap(), 1024);
3294 }
3295
3296 #[test]
3297 fn parse_byte_size_megabyte_units() {
3298 assert_eq!(parse_byte_size("1MB").unwrap(), 1024 * 1024);
3299 assert_eq!(parse_byte_size("1M").unwrap(), 1024 * 1024);
3300 assert_eq!(parse_byte_size("10MB").unwrap(), 10 * 1024 * 1024);
3301 assert_eq!(
3303 parse_byte_size("1MB").unwrap(),
3304 1024 * parse_byte_size("1KB").unwrap()
3305 );
3306 }
3307
3308 #[test]
3309 fn parse_byte_size_gigabyte_units() {
3310 assert_eq!(parse_byte_size("1GB").unwrap(), 1024 * 1024 * 1024);
3311 assert_eq!(parse_byte_size("1G").unwrap(), 1024 * 1024 * 1024);
3312 assert_eq!(
3314 parse_byte_size("1GB").unwrap(),
3315 1024 * parse_byte_size("1MB").unwrap()
3316 );
3317 }
3318
3319 #[test]
3320 fn parse_byte_size_all_units_are_distinct() {
3321 let b = parse_byte_size("1B").unwrap();
3324 let kb = parse_byte_size("1KB").unwrap();
3325 let mb = parse_byte_size("1MB").unwrap();
3326 let gb = parse_byte_size("1GB").unwrap();
3327 assert_eq!(b, 1);
3328 assert_eq!(kb, 1024);
3329 assert_eq!(mb, 1024 * kb);
3330 assert_eq!(gb, 1024 * mb);
3331 assert!(b < kb);
3333 assert!(kb < mb);
3334 assert!(mb < gb);
3335 }
3336
3337 #[test]
3338 fn parse_byte_size_accepts_sizes_above_4gib() {
3339 assert_eq!(parse_byte_size("8GB").unwrap(), 8 << 30);
3342 assert_eq!(parse_byte_size("5000MB").unwrap(), 5000 << 20);
3343 assert_eq!(parse_byte_size("4294967296").unwrap(), 1 << 32);
3344 assert_eq!(parse_byte_size("4.5GB").unwrap(), 9 << 29);
3345 }
3346
3347 #[test]
3348 fn parse_byte_size_float_path() {
3349 assert_eq!(parse_byte_size("1.5KB").unwrap(), 1536);
3351 assert_eq!(parse_byte_size("0.5MB").unwrap(), 512 * 1024);
3352 assert_eq!(parse_byte_size("2.25KB").unwrap(), 2304);
3353 }
3354
3355 #[test]
3356 fn parse_byte_size_whitespace_tolerated() {
3357 assert_eq!(parse_byte_size(" 1KB ").unwrap(), 1024);
3358 assert_eq!(parse_byte_size("1 KB").unwrap(), 1024);
3359 assert_eq!(parse_byte_size(" 1 KB ").unwrap(), 1024);
3360 }
3361
3362 #[test]
3363 fn parse_byte_size_rejects_negative() {
3364 assert!(parse_byte_size("-1KB").is_err());
3367 assert!(parse_byte_size("-0.5MB").is_err());
3368 assert!(parse_byte_size("-1").is_err());
3369 assert!(parse_byte_size("-100").is_err());
3370 }
3371
3372 #[test]
3373 fn parse_byte_size_rejects_non_finite() {
3374 assert!(parse_byte_size("infKB").is_err());
3376 assert!(parse_byte_size("nanMB").is_err());
3377 }
3378
3379 #[test]
3380 fn parse_byte_size_rejects_unknown_unit() {
3381 assert!(parse_byte_size("1TB").is_err());
3382 assert!(parse_byte_size("1XB").is_err());
3383 assert!(parse_byte_size("1foo").is_err());
3384 }
3385
3386 #[test]
3387 fn parse_byte_size_rejects_integer_overflow() {
3388 assert!(parse_byte_size("20000000000GB").is_err());
3391 assert!(parse_byte_size("18446744073709551615GB").is_err());
3392 }
3393
3394 #[test]
3395 fn parse_byte_size_rejects_float_overflow() {
3396 assert!(parse_byte_size("99999999999999999999GB").is_err());
3401 assert!(parse_byte_size("99999999999999999999.0GB").is_err());
3402 assert!(parse_byte_size("184467440737095516160B").is_err());
3403 }
3404
3405 #[test]
3406 fn parse_byte_size_rejects_invalid_number() {
3407 assert!(parse_byte_size("abc").is_err());
3408 assert!(parse_byte_size("abcKB").is_err());
3409 assert!(parse_byte_size("1.2.3KB").is_err());
3410 }
3411
3412 #[test]
3413 fn send_as_rejects_names_that_are_not_valid_output_ids() {
3414 for (field, value) in [
3415 ("send_stdout_as", "std out"),
3416 ("send_stdout_as", "out/"),
3417 ("send_logs_as", "\"\""),
3418 ("send_logs_as", "a//b"),
3419 ] {
3420 let yaml = format!(
3421 "nodes:\n - id: talker\n path: talker\n {field}: {value}\n outputs: [out]\n"
3422 );
3423 let descriptor: Descriptor = serde_yaml::from_str(&yaml).unwrap();
3424 let err = check_dataflow_static(&descriptor)
3425 .expect_err(&format!("`{field}: {value}` must be rejected"));
3426 assert!(
3427 format!("{err:?}").contains("is not a valid output id"),
3428 "unexpected error for `{field}: {value}`: {err:?}"
3429 );
3430 }
3431 }
3432
3433 #[test]
3434 fn send_as_rejects_empty_operator_entry() {
3435 let yaml = r#"
3436nodes:
3437 - id: rt
3438 operators:
3439 - id: op1
3440 python: op.py
3441 send_stdout_as: ""
3442 send_logs_as: ""
3443 outputs: [out]
3444"#;
3445 let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3446 let nodes = descriptor.resolve_aliases_and_set_defaults().unwrap();
3447 let node = &nodes[&NodeId::from("rt".to_string())];
3448 assert!(node.send_stdout_as().is_err());
3449 assert!(node.send_logs_as().is_err());
3450 }
3451
3452 #[test]
3453 fn send_as_accepts_valid_names() {
3454 let yaml = r#"
3455nodes:
3456 - id: rt
3457 operators:
3458 - id: op1
3459 python: op.py
3460 send_stdout_as: stdout
3461 send_logs_as: logs
3462 outputs: [stdout, logs]
3463 - id: talker
3464 path: talker
3465 send_stdout_as: stdout
3466 send_logs_as: logs
3467 outputs: [stdout, logs]
3468"#;
3469 let descriptor: Descriptor = serde_yaml::from_str(yaml).unwrap();
3470 let nodes = descriptor.resolve_aliases_and_set_defaults().unwrap();
3471 let rt = &nodes[&NodeId::from("rt".to_string())];
3472 assert_eq!(rt.send_stdout_as().unwrap().as_deref(), Some("op1/stdout"));
3473 assert_eq!(rt.send_logs_as().unwrap().as_deref(), Some("op1/logs"));
3474 let talker = &nodes[&NodeId::from("talker".to_string())];
3475 assert_eq!(talker.send_stdout_as().unwrap().as_deref(), Some("stdout"));
3476 assert_eq!(talker.send_logs_as().unwrap().as_deref(), Some("logs"));
3477 }
3478
3479 #[test]
3482 fn parse_log_level_all_levels() {
3483 use dora_message::common::LogLevelOrStdout;
3484
3485 assert!(matches!(
3486 parse_log_level("error").unwrap(),
3487 LogLevelOrStdout::LogLevel(log::Level::Error)
3488 ));
3489 assert!(matches!(
3490 parse_log_level("warn").unwrap(),
3491 LogLevelOrStdout::LogLevel(log::Level::Warn)
3492 ));
3493 assert!(matches!(
3494 parse_log_level("info").unwrap(),
3495 LogLevelOrStdout::LogLevel(log::Level::Info)
3496 ));
3497 assert!(matches!(
3498 parse_log_level("debug").unwrap(),
3499 LogLevelOrStdout::LogLevel(log::Level::Debug)
3500 ));
3501 assert!(matches!(
3502 parse_log_level("trace").unwrap(),
3503 LogLevelOrStdout::LogLevel(log::Level::Trace)
3504 ));
3505 assert!(matches!(
3506 parse_log_level("stdout").unwrap(),
3507 LogLevelOrStdout::Stdout
3508 ));
3509 }
3510
3511 #[test]
3512 fn parse_log_level_case_insensitive() {
3513 use dora_message::common::LogLevelOrStdout;
3514
3515 for variant in ["ERROR", "Error", "error", "ErRoR"] {
3516 assert!(matches!(
3517 parse_log_level(variant).unwrap(),
3518 LogLevelOrStdout::LogLevel(log::Level::Error)
3519 ));
3520 }
3521 for variant in ["STDOUT", "Stdout", "stdout"] {
3522 assert!(matches!(
3523 parse_log_level(variant).unwrap(),
3524 LogLevelOrStdout::Stdout
3525 ));
3526 }
3527 }
3528
3529 #[test]
3530 fn parse_log_level_rejects_unknown() {
3531 assert!(parse_log_level("").is_err());
3532 assert!(parse_log_level("INVALID").is_err());
3533 assert!(parse_log_level("fatal").is_err());
3534 assert!(parse_log_level("log").is_err());
3535 }
3536
3537 #[test]
3538 fn parse_log_level_error_message_lists_options() {
3539 let err = parse_log_level("bogus").unwrap_err().to_string();
3540 for expected in ["error", "warn", "info", "debug", "trace", "stdout"] {
3544 assert!(
3545 err.contains(expected),
3546 "expected '{expected}' to be mentioned in error, got: {err}"
3547 );
3548 }
3549 }
3550
3551 #[test]
3552 fn max_rotated_files_accepts_zero_and_still_caps_at_100() {
3553 let node = |n: u32| -> ResolvedNode {
3554 let mut custom = custom_node();
3555 custom.max_rotated_files = Some(n);
3556 ResolvedNode::new(NodeId::from("n".to_owned()), CoreNodeKind::Custom(custom))
3557 };
3558
3559 assert_eq!(node(0).max_rotated_files().unwrap(), Some(0));
3563 assert_eq!(node(100).max_rotated_files().unwrap(), Some(100));
3565 assert!(node(101).max_rotated_files().is_err());
3566 }
3567}
3568
3569#[cfg(test)]
3573mod proptest_properties {
3574 use super::{parse_byte_size, validate_ros2_name};
3575 use dora_message::id::NodeId;
3576 use proptest::prelude::*;
3577
3578 fn node_id() -> NodeId {
3579 NodeId::from("prop".to_owned())
3580 }
3581
3582 fn valid_ros2_name() -> impl Strategy<Value = String> {
3585 (
3586 any::<bool>(),
3587 prop::collection::vec("[A-Za-z_][A-Za-z0-9_]{0,8}", 1..4),
3588 )
3589 .prop_map(|(absolute, tokens)| {
3590 let joined = tokens.join("/");
3591 if absolute {
3592 format!("/{joined}")
3593 } else {
3594 joined
3595 }
3596 })
3597 }
3598
3599 proptest! {
3600 #[test]
3602 fn ros2_name_validation_never_panics(name in ".{0,32}") {
3603 let _ = validate_ros2_name(&node_id(), "topic", &name);
3604 }
3605
3606 #[test]
3608 fn ros2_name_accepts_valid_names(name in valid_ros2_name()) {
3609 prop_assert!(validate_ros2_name(&node_id(), "topic", &name).is_ok());
3610 }
3611
3612 #[test]
3619 fn ros2_name_accepted_implies_well_formed(name in "[a-zA-Z0-9_/. -]{0,16}") {
3620 if validate_ros2_name(&node_id(), "topic", &name).is_ok() {
3621 prop_assert!(!name.is_empty());
3622 prop_assert!(name.chars().all(|c| c.is_ascii_alphanumeric()
3623 || c == '_'
3624 || c == '/'));
3625 prop_assert!(!name.contains("//"));
3626 prop_assert!(!name.ends_with('/'));
3627 for (i, token) in name.split('/').enumerate() {
3628 if i == 0 && token.is_empty() {
3629 continue;
3630 }
3631 prop_assert!(!token.starts_with(|c: char| c.is_ascii_digit()));
3632 }
3633 }
3634 }
3635
3636 #[test]
3638 fn byte_size_parsing_never_panics(s in ".{0,24}") {
3639 let _ = parse_byte_size(&s);
3640 }
3641
3642 #[test]
3645 fn byte_size_integer_units_are_exact(
3646 num in any::<u64>(),
3647 unit_idx in 0usize..7,
3648 lowercase in any::<bool>(),
3649 ) {
3650 let (unit, multiplier) =
3653 [("B", 1u64), ("KB", 1 << 10), ("K", 1 << 10), ("MB", 1 << 20),
3654 ("M", 1 << 20), ("GB", 1 << 30), ("G", 1 << 30)][unit_idx];
3655 let unit = if lowercase { unit.to_lowercase() } else { unit.to_string() };
3656 match parse_byte_size(&format!("{num}{unit}")) {
3657 Ok(bytes) => prop_assert_eq!(Some(bytes), num.checked_mul(multiplier)),
3658 Err(_) => prop_assert!(num.checked_mul(multiplier).is_none()),
3659 }
3660 }
3661
3662 #[test]
3664 fn byte_size_bare_integer_is_identity(num in any::<u64>()) {
3665 prop_assert_eq!(parse_byte_size(&num.to_string()).unwrap(), num);
3666 }
3667
3668 #[test]
3671 fn byte_size_fractional_inputs_parse(
3672 int_part in 0u32..1_000_000,
3673 frac in 0u32..100,
3674 unit_idx in 0usize..7,
3675 ) {
3676 let unit = ["B", "KB", "K", "MB", "M", "GB", "G"][unit_idx];
3677 let input = format!("{int_part}.{frac:02}{unit}");
3678 prop_assert!(parse_byte_size(&input).is_ok(), "failed to parse '{input}'");
3679 }
3680 }
3681}