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