Skip to main content

dora_message/
config.rs

1use core::fmt;
2use std::{
3    collections::{BTreeMap, BTreeSet},
4    str::FromStr,
5    time::Duration,
6};
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10
11use crate::descriptor;
12pub use crate::id::{DataId, NodeId, OperatorId};
13
14/// Filter for the `dora/logs` virtual input.
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
16pub struct LogSubscriptionFilter {
17    /// Minimum log level to receive. `None` means all levels (including stdout).
18    #[schemars(with = "Option<String>")]
19    pub min_level: Option<crate::common::LogLevelOrStdout>,
20    /// Only receive logs from this specific node. `None` means all nodes.
21    pub node_filter: Option<NodeId>,
22}
23
24/// Default queue size when none is configured.
25pub const DEFAULT_QUEUE_SIZE: usize = 10;
26
27/// Queue overflow policy for an input.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum QueuePolicy {
31    /// Drop the oldest queued message when the queue is full (default).
32    #[default]
33    DropOldest,
34    /// Buffer up to 10x `queue_size` without dropping. Drops with ERROR log at hard cap.
35    Backpressure,
36}
37
38impl QueuePolicy {
39    /// Returns the effective capacity for a given configured queue size.
40    ///
41    /// - `DropOldest`: returns `queue_size` as-is.
42    /// - `Backpressure`: returns `10 * queue_size` (min 100) as a hard safety cap.
43    pub fn effective_cap(&self, queue_size: usize) -> usize {
44        match self {
45            Self::DropOldest => queue_size,
46            Self::Backpressure => queue_size.saturating_mul(10).max(100),
47        }
48    }
49}
50
51/// Contains the input and output configuration of the node.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53pub struct NodeRunConfig {
54    /// Inputs for the nodes as a map from input ID to `node_id/output_id`.
55    ///
56    /// e.g.
57    ///
58    /// inputs:
59    ///
60    ///   example_input: example_node/example_output1
61    ///
62    #[serde(default)]
63    pub inputs: BTreeMap<DataId, Input>,
64    /// List of output IDs.
65    ///
66    /// e.g.
67    ///
68    /// outputs:
69    ///
70    ///  - output_1
71    ///
72    ///  - output_2
73    #[serde(default)]
74    pub outputs: BTreeSet<DataId>,
75    /// Optional type annotations for outputs
76    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
77    pub output_types: BTreeMap<DataId, String>,
78    /// Per-output framing overrides (default: Raw for all).
79    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
80    pub output_framing: BTreeMap<DataId, descriptor::OutputFraming>,
81    /// Optional type annotations for inputs
82    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83    pub input_types: BTreeMap<DataId, String>,
84
85    /// Size of the zenoh shared memory pool for zero-copy output publishing.
86    ///
87    /// Accepts an integer (raw bytes) or a string with a unit suffix
88    /// (`KB`, `MB`, `GB`, case-insensitive).
89    ///
90    /// e.g.
91    ///
92    /// shared_memory_pool_size: 128MB
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub shared_memory_pool_size: Option<ByteSize>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
98#[serde(from = "InputDef", into = "InputDef")]
99pub struct Input {
100    pub mapping: InputMapping,
101    pub queue_size: Option<usize>,
102    pub input_timeout: Option<f64>,
103    pub queue_policy: Option<QueuePolicy>,
104}
105
106impl PartialEq for Input {
107    fn eq(&self, other: &Self) -> bool {
108        self.mapping == other.mapping
109            && self.queue_size == other.queue_size
110            && self.input_timeout.map(f64::to_bits) == other.input_timeout.map(f64::to_bits)
111            && self.queue_policy == other.queue_policy
112    }
113}
114
115impl Eq for Input {}
116
117#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
118#[serde(untagged)]
119pub enum InputDef {
120    MappingOnly(InputMapping),
121    WithOptions {
122        source: InputMapping,
123        #[serde(default, skip_serializing_if = "Option::is_none")]
124        queue_size: Option<usize>,
125        #[serde(default, skip_serializing_if = "Option::is_none")]
126        input_timeout: Option<f64>,
127        #[serde(default, skip_serializing_if = "Option::is_none")]
128        queue_policy: Option<QueuePolicy>,
129    },
130}
131
132impl PartialEq for InputDef {
133    fn eq(&self, other: &Self) -> bool {
134        match (self, other) {
135            (Self::MappingOnly(a), Self::MappingOnly(b)) => a == b,
136            (
137                Self::WithOptions {
138                    source: s1,
139                    queue_size: q1,
140                    input_timeout: t1,
141                    queue_policy: p1,
142                },
143                Self::WithOptions {
144                    source: s2,
145                    queue_size: q2,
146                    input_timeout: t2,
147                    queue_policy: p2,
148                },
149            ) => s1 == s2 && q1 == q2 && t1.map(f64::to_bits) == t2.map(f64::to_bits) && p1 == p2,
150            _ => false,
151        }
152    }
153}
154
155impl Eq for InputDef {}
156
157impl From<Input> for InputDef {
158    fn from(input: Input) -> Self {
159        if input.queue_size.is_none()
160            && input.input_timeout.is_none()
161            && input.queue_policy.is_none()
162        {
163            Self::MappingOnly(input.mapping)
164        } else {
165            Self::WithOptions {
166                source: input.mapping,
167                queue_size: input.queue_size,
168                input_timeout: input.input_timeout,
169                queue_policy: input.queue_policy,
170            }
171        }
172    }
173}
174
175impl From<InputDef> for Input {
176    fn from(value: InputDef) -> Self {
177        match value {
178            InputDef::MappingOnly(mapping) => Self {
179                mapping,
180                queue_size: None,
181                input_timeout: None,
182                queue_policy: None,
183            },
184            InputDef::WithOptions {
185                source,
186                queue_size,
187                input_timeout,
188                queue_policy,
189            } => Self {
190                mapping: source,
191                queue_size,
192                input_timeout,
193                queue_policy,
194            },
195        }
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
200pub enum InputMapping {
201    Timer {
202        interval: Duration,
203    },
204    /// Subscribe to log messages from all (or filtered) nodes in the dataflow.
205    ///
206    /// Syntax: `dora/logs`, `dora/logs/{level}`, `dora/logs/{level}/{node_id}`
207    Logs(LogSubscriptionFilter),
208    User(UserInputMapping),
209}
210
211impl fmt::Display for InputMapping {
212    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213        match self {
214            InputMapping::Timer { interval } => {
215                let duration = format_duration(*interval);
216                write!(f, "dora/timer/{duration}")
217            }
218            InputMapping::Logs(filter) => {
219                write!(f, "dora/logs")?;
220                if let Some(level) = &filter.min_level {
221                    write!(f, "/{}", format_log_level(level))?;
222                    if let Some(node) = &filter.node_filter {
223                        write!(f, "/{node}")?;
224                    }
225                }
226                Ok(())
227            }
228            InputMapping::User(mapping) => {
229                write!(f, "{}/{}", mapping.source, mapping.output)
230            }
231        }
232    }
233}
234
235impl FromStr for InputMapping {
236    type Err = String;
237
238    fn from_str(s: &str) -> Result<Self, Self::Err> {
239        let (source, output) = s
240            .split_once('/')
241            .ok_or("input must start with `<source>/`")?;
242
243        let mapping = match source {
244            "dora" => match output.split_once('/') {
245                Some(("timer", output)) => {
246                    let (unit, value) = output.split_once('/').ok_or(
247                        "timer input must specify unit and value (e.g. `secs/5`, `millis/100`, or `hz/30`)",
248                    )?;
249                    let interval = match unit {
250                        "secs" => {
251                            let value = value
252                                .parse()
253                                .map_err(|_| format!("secs must be an integer (got `{value}`)"))?;
254                            Duration::from_secs(value)
255                        }
256                        "millis" => {
257                            let value = value.parse().map_err(|_| {
258                                format!("millis must be an integer (got `{value}`)")
259                            })?;
260                            Duration::from_millis(value)
261                        }
262                        "micros" => {
263                            let value = value.parse().map_err(|_| {
264                                format!("micros must be an integer (got `{value}`)")
265                            })?;
266                            Duration::from_micros(value)
267                        }
268                        "nanos" => {
269                            let value = value
270                                .parse()
271                                .map_err(|_| format!("nanos must be an integer (got `{value}`)"))?;
272                            Duration::from_nanos(value)
273                        }
274                        "hz" => {
275                            let hz: f64 = value.parse().map_err(|_| {
276                                format!("hz must be a positive number (got `{value}`)")
277                            })?;
278                            if !hz.is_finite() || hz <= 0.0 {
279                                return Err(format!(
280                                    "hz must be a positive finite number (got `{value}`)"
281                                ));
282                            }
283                            Duration::try_from_secs_f64(1.0 / hz).map_err(|e| {
284                                format!("hz `{value}` produces an out-of-range interval: {e}")
285                            })?
286                        }
287                        other => {
288                            return Err(format!(
289                                "timer unit must be `secs`, `millis`, `micros`, `nanos`, or `hz` (got `{other}`)"
290                            ));
291                        }
292                    };
293                    Self::Timer { interval }
294                }
295                Some(("logs", rest)) => {
296                    // dora/logs/{level} or dora/logs/{level}/{node_id}
297                    let (level_str, node_filter) = match rest.split_once('/') {
298                        Some((level, node)) => {
299                            // Validate the node segment instead of constructing a
300                            // `NodeId` directly: `rest.split_once('/')` keeps every
301                            // slash after the first inside `node`, so an input such
302                            // as `dora/logs/info/a/b` would otherwise build a NodeId
303                            // containing `/` -- a value `validate_node_id` forbids and
304                            // that no real node id can ever equal, silently producing
305                            // a filter that never matches.
306                            let node_id = node.parse::<NodeId>().map_err(|e| e.to_string())?;
307                            (Some(level), Some(node_id))
308                        }
309                        None => {
310                            if rest.is_empty() {
311                                (None, None)
312                            } else {
313                                (Some(rest), None)
314                            }
315                        }
316                    };
317                    let min_level = level_str.map(parse_log_level_str).transpose()?;
318                    Self::Logs(LogSubscriptionFilter {
319                        min_level,
320                        node_filter,
321                    })
322                }
323                Some((other, _)) => {
324                    return Err(format!("unknown dora input `{other}`"));
325                }
326                // "dora/logs" with no sub-path
327                None if output == "logs" => Self::Logs(LogSubscriptionFilter {
328                    min_level: None,
329                    node_filter: None,
330                }),
331                None => return Err("dora input has invalid format".into()),
332            },
333            _ => {
334                // Validate the source/output segments instead of using the
335                // panicking `From<String>` impls (`.into()`): an input mapping
336                // string comes straight from user-authored descriptor YAML, so an
337                // invalid identifier (e.g. containing a space) must surface as a
338                // clean deserialization error rather than panicking the parser.
339                let source = source.parse::<NodeId>().map_err(|e| e.to_string())?;
340                let output = output.parse::<DataId>().map_err(|e| e.to_string())?;
341                Self::User(UserInputMapping { source, output })
342            }
343        };
344
345        Ok(mapping)
346    }
347}
348
349fn parse_log_level_str(s: &str) -> Result<crate::common::LogLevelOrStdout, String> {
350    use crate::common::{LogLevel, LogLevelOrStdout};
351    match s.to_lowercase().as_str() {
352        "stdout" => Ok(LogLevelOrStdout::Stdout),
353        "error" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Error)),
354        "warn" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Warn)),
355        "info" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Info)),
356        "debug" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Debug)),
357        "trace" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Trace)),
358        other => Err(format!(
359            "unknown log level `{other}` (expected: stdout, error, warn, info, debug, trace)"
360        )),
361    }
362}
363
364fn format_log_level(level: &crate::common::LogLevelOrStdout) -> &'static str {
365    use crate::common::{LogLevel, LogLevelOrStdout};
366    match level {
367        LogLevelOrStdout::Stdout => "stdout",
368        LogLevelOrStdout::LogLevel(l) => match *l {
369            LogLevel::Error => "error",
370            LogLevel::Warn => "warn",
371            LogLevel::Info => "info",
372            LogLevel::Debug => "debug",
373            LogLevel::Trace => "trace",
374        },
375    }
376}
377
378pub struct FormattedDuration(pub Duration);
379
380impl fmt::Display for FormattedDuration {
381    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
382        // Emit the coarsest unit that represents the interval exactly, so the
383        // value round-trips through `FromStr` without loss. Using `millis` for
384        // sub-millisecond intervals (e.g. `dora/timer/hz/3000` ~= 333µs) would
385        // truncate to `millis/0`, i.e. a 0ms busy-loop interval (dora-rs#2031).
386        let nanos = self.0.as_nanos();
387        if nanos.is_multiple_of(1_000_000_000) {
388            write!(f, "secs/{}", self.0.as_secs())
389        } else if nanos.is_multiple_of(1_000_000) {
390            write!(f, "millis/{}", self.0.as_millis())
391        } else if nanos.is_multiple_of(1_000) {
392            write!(f, "micros/{}", self.0.as_micros())
393        } else {
394            write!(f, "nanos/{nanos}")
395        }
396    }
397}
398
399pub fn format_duration(interval: Duration) -> FormattedDuration {
400    FormattedDuration(interval)
401}
402
403impl Serialize for InputMapping {
404    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
405    where
406        S: serde::Serializer,
407    {
408        serializer.collect_str(self)
409    }
410}
411
412impl<'de> Deserialize<'de> for InputMapping {
413    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
414    where
415        D: serde::Deserializer<'de>,
416    {
417        let string = String::deserialize(deserializer)?;
418        string.parse().map_err(serde::de::Error::custom)
419    }
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
423pub struct UserInputMapping {
424    pub source: NodeId,
425    pub output: DataId,
426}
427
428#[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Clone)]
429#[serde(deny_unknown_fields, rename_all = "lowercase")]
430pub struct CommunicationConfig {
431    // see https://github.com/dtolnay/serde-yaml/issues/298
432    #[serde(
433        default,
434        with = "serde_yaml::with::singleton_map",
435        rename = "_unstable_local"
436    )]
437    #[schemars(with = "String")]
438    pub local: LocalCommunicationConfig,
439    #[serde(
440        default,
441        with = "serde_yaml::with::singleton_map",
442        rename = "_unstable_remote"
443    )]
444    #[schemars(with = "String")]
445    pub remote: RemoteCommunicationConfig,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
449#[serde(rename_all = "lowercase")]
450pub enum LocalCommunicationConfig {
451    #[default]
452    Tcp,
453}
454
455impl fmt::Display for LocalCommunicationConfig {
456    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
457        match self {
458            Self::Tcp => write!(f, "tcp"),
459        }
460    }
461}
462
463#[derive(Debug, Serialize, Deserialize, Clone)]
464#[serde(deny_unknown_fields, rename_all = "lowercase")]
465#[derive(Default)]
466pub enum RemoteCommunicationConfig {
467    #[default]
468    Tcp,
469}
470
471/// A byte size that can be deserialized from either an integer (raw bytes) or a
472/// string with a unit suffix (`KB`, `MB`, `GB`, case-insensitive).
473///
474/// Examples: `67108864`, `"64MB"`, `"1 GB"`, `"512kb"`.
475///
476/// Negative, non-finite, and overflowing values are rejected:
477///
478/// ```
479/// use dora_message::config::ByteSize;
480///
481/// assert_eq!("64MB".parse::<ByteSize>().unwrap().as_bytes(), 64 * 1024 * 1024);
482/// assert_eq!("1.5 KB".parse::<ByteSize>().unwrap().as_bytes(), 1536);
483/// assert!("-1KB".parse::<ByteSize>().is_err());
484/// assert!("1TB".parse::<ByteSize>().is_err());
485/// ```
486#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
487pub struct ByteSize(pub usize);
488
489impl ByteSize {
490    /// Returns the size in raw bytes.
491    pub fn as_bytes(&self) -> usize {
492        self.0
493    }
494}
495
496impl FromStr for ByteSize {
497    type Err = String;
498
499    fn from_str(s: &str) -> Result<Self, Self::Err> {
500        let s = s.trim();
501        let (num_part, unit_part) = match s.find(|c: char| c.is_alphabetic()) {
502            Some(pos) => (s[..pos].trim(), s[pos..].trim()),
503            None => {
504                let bytes: usize = s.parse().map_err(|_| format!("invalid byte size: `{s}`"))?;
505                return Ok(ByteSize(bytes));
506            }
507        };
508
509        let multiplier: usize = match unit_part.to_uppercase().as_str() {
510            "B" => 1,
511            "KB" | "K" => 1024,
512            "MB" | "M" => 1024 * 1024,
513            "GB" | "G" => 1024 * 1024 * 1024,
514            other => return Err(format!("unknown byte size unit: `{other}`")),
515        };
516
517        // Use integer parse when possible to avoid f64 rounding above 2^53.
518        if let Ok(num) = num_part.parse::<usize>() {
519            return num
520                .checked_mul(multiplier)
521                .map(ByteSize)
522                .ok_or_else(|| format!("byte size `{s}` is too large"));
523        }
524
525        let num: f64 = num_part
526            .parse()
527            .map_err(|_| format!("invalid number in byte size: `{num_part}`"))?;
528
529        // Casting a negative or non-finite f64 to usize saturates (negatives
530        // and NaN to 0, +inf to usize::MAX) instead of erroring, so reject
531        // them up front.
532        if !num.is_finite() || num < 0.0 {
533            return Err(format!(
534                "byte size must be a non-negative, finite number: `{s}`"
535            ));
536        }
537        let bytes = num * multiplier as f64;
538        // `usize::MAX as f64` rounds up to 2^64, and no f64 values exist
539        // between usize::MAX and 2^64, so `>=` rejects exactly the results
540        // that exceed usize::MAX.
541        if bytes >= usize::MAX as f64 {
542            return Err(format!("byte size `{s}` is too large"));
543        }
544        Ok(ByteSize(bytes as usize))
545    }
546}
547
548impl fmt::Display for ByteSize {
549    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
550        let bytes = self.0;
551        if bytes == 0 {
552            write!(f, "0B")
553        } else if bytes.is_multiple_of(1024 * 1024 * 1024) {
554            write!(f, "{}GB", bytes / (1024 * 1024 * 1024))
555        } else if bytes.is_multiple_of(1024 * 1024) {
556            write!(f, "{}MB", bytes / (1024 * 1024))
557        } else if bytes.is_multiple_of(1024) {
558            write!(f, "{}KB", bytes / 1024)
559        } else {
560            write!(f, "{bytes}")
561        }
562    }
563}
564
565impl Serialize for ByteSize {
566    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
567    where
568        S: serde::Serializer,
569    {
570        self.0.serialize(serializer)
571    }
572}
573
574impl<'de> Deserialize<'de> for ByteSize {
575    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
576    where
577        D: serde::Deserializer<'de>,
578    {
579        use serde::de;
580
581        struct ByteSizeVisitor;
582
583        impl de::Visitor<'_> for ByteSizeVisitor {
584            type Value = ByteSize;
585
586            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
587                formatter.write_str("a byte size as integer or string (e.g. 67108864, \"64MB\")")
588            }
589
590            fn visit_u64<E: de::Error>(self, v: u64) -> Result<ByteSize, E> {
591                usize::try_from(v)
592                    .map(ByteSize)
593                    .map_err(|_| E::custom(format!("byte size `{v}` is too large")))
594            }
595
596            fn visit_i64<E: de::Error>(self, v: i64) -> Result<ByteSize, E> {
597                if v < 0 {
598                    return Err(E::custom("byte size cannot be negative"));
599                }
600                usize::try_from(v)
601                    .map(ByteSize)
602                    .map_err(|_| E::custom(format!("byte size `{v}` is too large")))
603            }
604
605            fn visit_str<E: de::Error>(self, v: &str) -> Result<ByteSize, E> {
606                v.parse().map_err(E::custom)
607            }
608        }
609
610        deserializer.deserialize_any(ByteSizeVisitor)
611    }
612}
613
614impl JsonSchema for ByteSize {
615    fn schema_name() -> std::borrow::Cow<'static, str> {
616        "ByteSize".into()
617    }
618
619    fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
620        schemars::json_schema!({
621            "anyOf": [
622                { "type": "integer" },
623                { "type": "string" }
624            ],
625            "description": "Byte size: integer (raw bytes) or string with unit (e.g. \"128MB\", \"1GB\")"
626        })
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use super::*;
633
634    #[test]
635    fn parse_input_without_queue_policy() {
636        let yaml = "source: node_a/output_1\nqueue_size: 5\n";
637        let input: Input = serde_yaml::from_str(yaml).unwrap();
638        assert_eq!(input.queue_size, Some(5));
639        assert_eq!(input.queue_policy, None);
640    }
641
642    #[test]
643    fn parse_user_mapping_rejects_invalid_ids() {
644        // A source node id with a space is not a valid `NodeId` and must be
645        // rejected with a clean error rather than panicking the parser via the
646        // `From<String>` impl.
647        let result: Result<InputMapping, _> = "bad node/output".parse();
648        assert!(result.is_err(), "invalid source node id must be rejected");
649
650        // An output data id with an invalid character must likewise be rejected.
651        let result: Result<InputMapping, _> = "node_a/bad output".parse();
652        assert!(result.is_err(), "invalid output data id must be rejected");
653
654        // A valid mapping still parses successfully.
655        let mapping: InputMapping = "node_a/output_1".parse().unwrap();
656        assert!(matches!(mapping, InputMapping::User(_)));
657    }
658
659    #[test]
660    fn parse_input_with_drop_oldest_policy() {
661        let yaml = "source: node_a/output_1\nqueue_size: 5\nqueue_policy: drop_oldest\n";
662        let input: Input = serde_yaml::from_str(yaml).unwrap();
663        assert_eq!(input.queue_policy, Some(QueuePolicy::DropOldest));
664    }
665
666    #[test]
667    fn parse_input_with_backpressure_policy() {
668        let yaml = "source: node_a/output_1\nqueue_size: 10\nqueue_policy: backpressure\n";
669        let input: Input = serde_yaml::from_str(yaml).unwrap();
670        assert_eq!(input.queue_policy, Some(QueuePolicy::Backpressure));
671    }
672
673    #[test]
674    fn parse_short_form_input_has_no_policy() {
675        let yaml = "node_a/output_1";
676        let input: Input = serde_yaml::from_str(yaml).unwrap();
677        assert_eq!(input.queue_policy, None);
678        assert_eq!(input.queue_size, None);
679    }
680
681    #[test]
682    fn roundtrip_input_with_policy() {
683        let input = Input {
684            mapping: "node_a/output_1".parse().unwrap(),
685            queue_size: Some(3),
686            input_timeout: None,
687            queue_policy: Some(QueuePolicy::Backpressure),
688        };
689        let yaml = serde_yaml::to_string(&input).unwrap();
690        let parsed: Input = serde_yaml::from_str(&yaml).unwrap();
691        assert_eq!(input, parsed);
692    }
693
694    /// Regression tests for dora-rs/adora#144: `dora/timer/hz/N` is
695    /// documented across README, guide, schema, and real examples
696    /// (`streaming-example`, `dynamic-agent-tools`) but the parser
697    /// previously only accepted `secs` / `millis`.
698    #[test]
699    fn parse_timer_hz_integer() {
700        let mapping: InputMapping = "dora/timer/hz/30".parse().unwrap();
701        match mapping {
702            InputMapping::Timer { interval } => {
703                // 1 / 30 Hz ≈ 33.333 ms
704                assert_eq!(interval, Duration::from_secs_f64(1.0 / 30.0));
705            }
706            other => panic!("expected Timer, got {other:?}"),
707        }
708    }
709
710    #[test]
711    fn parse_timer_hz_fractional() {
712        // Used in examples/streaming-example/dataflow.yml
713        let mapping: InputMapping = "dora/timer/hz/0.5".parse().unwrap();
714        match mapping {
715            InputMapping::Timer { interval } => {
716                assert_eq!(interval, Duration::from_secs(2));
717            }
718            other => panic!("expected Timer, got {other:?}"),
719        }
720    }
721
722    #[test]
723    fn parse_timer_hz_rejects_zero() {
724        let err = "dora/timer/hz/0".parse::<InputMapping>().unwrap_err();
725        assert!(err.contains("hz"), "error should mention hz: {err}");
726    }
727
728    #[test]
729    fn parse_timer_hz_rejects_negative() {
730        let err = "dora/timer/hz/-1".parse::<InputMapping>().unwrap_err();
731        assert!(err.contains("hz"), "error should mention hz: {err}");
732    }
733
734    #[test]
735    fn parse_timer_hz_rejects_non_numeric() {
736        let err = "dora/timer/hz/foo".parse::<InputMapping>().unwrap_err();
737        assert!(err.contains("hz"), "error should mention hz: {err}");
738    }
739
740    #[test]
741    fn parse_timer_hz_rejects_overflow() {
742        // A pathologically small hz makes 1/hz overflow Duration; this must
743        // return an error, not panic (was `Duration::from_secs_f64`, which
744        // panics on out-of-range input).
745        let err = "dora/timer/hz/0.00000000000000000001"
746            .parse::<InputMapping>()
747            .unwrap_err();
748        assert!(err.contains("hz"), "error should mention hz: {err}");
749    }
750
751    /// Regression test for dora-rs#2031: the `Display` impl previously emitted
752    /// `millis/0` (or `secs/0`) for any sub-millisecond interval, so a valid
753    /// high-rate timer like `dora/timer/hz/3000` (~=333µs) round-tripped into a
754    /// 0ms busy-loop interval. Sub-ms intervals must survive Display -> parse.
755    #[test]
756    fn timer_subms_interval_roundtrips() {
757        let cases = [
758            "dora/timer/hz/3000",  // ~= 333_333 ns, not a whole µs
759            "dora/timer/micros/1", // 1µs
760            "dora/timer/nanos/1",  // 1ns
761            "dora/timer/nanos/500",
762            "dora/timer/micros/250",
763        ];
764        for case in cases {
765            let mapping: InputMapping = case.parse().unwrap();
766            let InputMapping::Timer { interval } = mapping else {
767                panic!("expected Timer for `{case}`, got {mapping:?}");
768            };
769            assert!(!interval.is_zero(), "`{case}` parsed to a zero interval");
770            let rendered = mapping.to_string();
771            let reparsed: InputMapping = rendered.parse().unwrap();
772            assert_eq!(
773                mapping, reparsed,
774                "`{case}` did not round-trip (rendered as `{rendered}`)"
775            );
776        }
777    }
778
779    #[test]
780    fn timer_display_uses_coarsest_exact_unit() {
781        let render = |d: Duration| format_duration(d).to_string();
782        assert_eq!(render(Duration::from_secs(5)), "secs/5");
783        assert_eq!(render(Duration::from_millis(100)), "millis/100");
784        assert_eq!(render(Duration::from_micros(250)), "micros/250");
785        assert_eq!(render(Duration::from_nanos(500)), "nanos/500");
786        // 1/3000 Hz = 333_333 ns (not a whole microsecond) -> nanos.
787        assert_eq!(render(Duration::from_nanos(333_333)), "nanos/333333");
788    }
789
790    #[test]
791    fn parse_timer_micros_and_nanos() {
792        let micros: InputMapping = "dora/timer/micros/250".parse().unwrap();
793        assert_eq!(
794            micros,
795            InputMapping::Timer {
796                interval: Duration::from_micros(250)
797            }
798        );
799        let nanos: InputMapping = "dora/timer/nanos/500".parse().unwrap();
800        assert_eq!(
801            nanos,
802            InputMapping::Timer {
803                interval: Duration::from_nanos(500)
804            }
805        );
806    }
807
808    #[test]
809    fn timer_whole_second_and_milli_still_roundtrip() {
810        for case in ["dora/timer/secs/2", "dora/timer/millis/100"] {
811            let mapping: InputMapping = case.parse().unwrap();
812            let reparsed: InputMapping = mapping.to_string().parse().unwrap();
813            assert_eq!(mapping, reparsed);
814        }
815        // Whole seconds/millis still render with their original unit.
816        assert_eq!(
817            "dora/timer/secs/2"
818                .parse::<InputMapping>()
819                .unwrap()
820                .to_string(),
821            "dora/timer/secs/2"
822        );
823        assert_eq!(
824            "dora/timer/millis/100"
825                .parse::<InputMapping>()
826                .unwrap()
827                .to_string(),
828            "dora/timer/millis/100"
829        );
830    }
831
832    #[test]
833    fn roundtrip_input_without_policy_uses_short_form() {
834        let input = Input {
835            mapping: "node_a/output_1".parse().unwrap(),
836            queue_size: None,
837            input_timeout: None,
838            queue_policy: None,
839        };
840        let yaml = serde_yaml::to_string(&input).unwrap();
841        // Short form should not contain "source:" key
842        assert!(!yaml.contains("source:"));
843        let parsed: Input = serde_yaml::from_str(&yaml).unwrap();
844        assert_eq!(input, parsed);
845    }
846
847    #[test]
848    fn queue_policy_default_is_drop_oldest() {
849        assert_eq!(QueuePolicy::default(), QueuePolicy::DropOldest);
850    }
851
852    #[test]
853    fn parse_logs_all() {
854        let mapping: InputMapping = "dora/logs".parse().unwrap();
855        assert!(matches!(
856            mapping,
857            InputMapping::Logs(LogSubscriptionFilter {
858                min_level: None,
859                node_filter: None,
860            })
861        ));
862    }
863
864    #[test]
865    fn parse_logs_with_level() {
866        use crate::common::{LogLevel, LogLevelOrStdout};
867        let mapping: InputMapping = "dora/logs/info".parse().unwrap();
868        match mapping {
869            InputMapping::Logs(f) => {
870                assert_eq!(
871                    f.min_level,
872                    Some(LogLevelOrStdout::LogLevel(LogLevel::Info))
873                );
874                assert_eq!(f.node_filter, None);
875            }
876            _ => panic!("expected Logs variant"),
877        }
878    }
879
880    #[test]
881    fn parse_logs_with_level_and_node() {
882        use crate::common::{LogLevel, LogLevelOrStdout};
883        let mapping: InputMapping = "dora/logs/error/sensor".parse().unwrap();
884        match mapping {
885            InputMapping::Logs(f) => {
886                assert_eq!(
887                    f.min_level,
888                    Some(LogLevelOrStdout::LogLevel(LogLevel::Error))
889                );
890                assert_eq!(f.node_filter, Some(NodeId("sensor".to_string())));
891            }
892            _ => panic!("expected Logs variant"),
893        }
894    }
895
896    #[test]
897    fn parse_logs_invalid_level() {
898        let result: Result<InputMapping, _> = "dora/logs/banana".parse();
899        assert!(result.is_err());
900    }
901
902    #[test]
903    fn parse_logs_rejects_invalid_node_filter() {
904        // A node segment with an extra `/` (kept by `split_once`) is not a valid
905        // NodeId and must be rejected rather than silently yielding a filter that
906        // can never match a real (validated) node id.
907        let result: Result<InputMapping, _> = "dora/logs/info/a/b".parse();
908        assert!(result.is_err(), "node filter `a/b` must be rejected");
909
910        // A node segment containing a space is likewise invalid.
911        let result: Result<InputMapping, _> = "dora/logs/info/bad node".parse();
912        assert!(result.is_err(), "node filter `bad node` must be rejected");
913    }
914
915    #[test]
916    fn display_roundtrip_logs_all() {
917        let mapping: InputMapping = "dora/logs".parse().unwrap();
918        assert_eq!(mapping.to_string(), "dora/logs");
919    }
920
921    #[test]
922    fn display_roundtrip_logs_with_level() {
923        let mapping: InputMapping = "dora/logs/warn".parse().unwrap();
924        assert_eq!(mapping.to_string(), "dora/logs/warn");
925    }
926
927    #[test]
928    fn display_roundtrip_logs_with_level_and_node() {
929        let mapping: InputMapping = "dora/logs/debug/camera".parse().unwrap();
930        assert_eq!(mapping.to_string(), "dora/logs/debug/camera");
931    }
932
933    #[test]
934    fn parse_logs_trailing_slash() {
935        let mapping: InputMapping = "dora/logs/".parse().unwrap();
936        assert!(matches!(
937            mapping,
938            InputMapping::Logs(LogSubscriptionFilter {
939                min_level: None,
940                node_filter: None,
941            })
942        ));
943    }
944
945    #[test]
946    fn communication_config_local_tcp_lowercase() {
947        let yaml = "_unstable_local: tcp\n";
948        let config: CommunicationConfig = serde_yaml::from_str(yaml).unwrap();
949        assert_eq!(config.local, LocalCommunicationConfig::Tcp);
950    }
951
952    #[test]
953    fn communication_config_remote_tcp_lowercase() {
954        let yaml = "_unstable_remote: tcp\n";
955        let config: CommunicationConfig = serde_yaml::from_str(yaml).unwrap();
956        assert!(matches!(config.remote, RemoteCommunicationConfig::Tcp));
957    }
958
959    #[test]
960    fn communication_config_unknown_local_variant_errors() {
961        let yaml = "_unstable_local: unixdomain\n";
962        assert!(serde_yaml::from_str::<CommunicationConfig>(yaml).is_err());
963    }
964
965    #[test]
966    fn local_communication_config_display() {
967        assert_eq!(LocalCommunicationConfig::Tcp.to_string(), "tcp");
968    }
969
970    #[test]
971    fn communication_config_roundtrip_local() {
972        let config = CommunicationConfig {
973            local: LocalCommunicationConfig::Tcp,
974            remote: RemoteCommunicationConfig::Tcp,
975        };
976        let yaml = serde_yaml::to_string(&config).unwrap();
977        let parsed: CommunicationConfig = serde_yaml::from_str(&yaml).unwrap();
978        assert_eq!(parsed.local, LocalCommunicationConfig::Tcp);
979    }
980
981    #[test]
982    fn byte_size_parses_raw_bytes() {
983        assert_eq!("1024".parse::<ByteSize>().unwrap(), ByteSize(1024));
984        assert_eq!("0".parse::<ByteSize>().unwrap(), ByteSize(0));
985    }
986
987    #[test]
988    fn byte_size_parses_units_case_insensitively() {
989        assert_eq!("1KB".parse::<ByteSize>().unwrap(), ByteSize(1024));
990        assert_eq!("1kb".parse::<ByteSize>().unwrap(), ByteSize(1024));
991        assert_eq!("1MB".parse::<ByteSize>().unwrap(), ByteSize(1024 * 1024));
992        assert_eq!(
993            "1GB".parse::<ByteSize>().unwrap(),
994            ByteSize(1024 * 1024 * 1024)
995        );
996        assert_eq!(
997            "128 MB".parse::<ByteSize>().unwrap(),
998            ByteSize(128 * 1024 * 1024)
999        );
1000        assert_eq!("512B".parse::<ByteSize>().unwrap(), ByteSize(512));
1001    }
1002
1003    #[test]
1004    fn byte_size_rejects_unknown_unit() {
1005        assert!("1TB".parse::<ByteSize>().is_err());
1006        assert!("abc".parse::<ByteSize>().is_err());
1007    }
1008
1009    #[test]
1010    fn byte_size_rejects_negative() {
1011        // Negative f64 → usize casts saturate to 0, so without an explicit
1012        // check `-1KB` silently parsed as a zero-byte pool size.
1013        assert!("-1KB".parse::<ByteSize>().is_err());
1014        assert!("-0.5MB".parse::<ByteSize>().is_err());
1015        assert!("-1".parse::<ByteSize>().is_err());
1016        // The integer deserialize path already rejected negatives; the string
1017        // path must agree.
1018        assert!(serde_yaml::from_str::<ByteSize>("-1").is_err());
1019        assert!(serde_yaml::from_str::<ByteSize>(r#""-1KB""#).is_err());
1020    }
1021
1022    #[test]
1023    fn byte_size_rejects_overflow() {
1024        // f64 path: finite but larger than usize::MAX must error, not
1025        // saturate silently.
1026        assert!("99999999999999999999999GB".parse::<ByteSize>().is_err());
1027        // Integer path: checked_mul must catch the overflow.
1028        assert!(format!("{}KB", usize::MAX).parse::<ByteSize>().is_err());
1029        // Exactly 2^64 (= 2^54 * 1024) on the float path: `usize::MAX as f64`
1030        // rounds up to 2^64, so a `>` comparison would let this saturate to
1031        // usize::MAX silently.
1032        assert!("18014398509481984.0KB".parse::<ByteSize>().is_err());
1033    }
1034
1035    #[test]
1036    fn byte_size_integer_values_parse_exactly() {
1037        // 2^53 + 1 is not representable as f64; the integer fast path must
1038        // preserve it exactly (mirrors dora-core's parse_byte_size).
1039        assert_eq!(
1040            "9007199254740993B".parse::<ByteSize>().unwrap(),
1041            ByteSize(9007199254740993)
1042        );
1043    }
1044
1045    #[test]
1046    fn byte_size_float_path_still_works() {
1047        assert_eq!("1.5KB".parse::<ByteSize>().unwrap(), ByteSize(1536));
1048        assert_eq!("0.5MB".parse::<ByteSize>().unwrap(), ByteSize(512 * 1024));
1049        assert!("x.5KB".parse::<ByteSize>().is_err());
1050    }
1051
1052    #[test]
1053    fn byte_size_deserializes_int_or_string() {
1054        let from_int: ByteSize = serde_yaml::from_str("67108864").unwrap();
1055        assert_eq!(from_int, ByteSize(64 * 1024 * 1024));
1056
1057        let from_str: ByteSize = serde_yaml::from_str(r#""64MB""#).unwrap();
1058        assert_eq!(from_str, ByteSize(64 * 1024 * 1024));
1059    }
1060
1061    #[test]
1062    fn byte_size_serializes_as_integer() {
1063        let yaml = serde_yaml::to_string(&ByteSize(1024)).unwrap();
1064        assert_eq!(yaml.trim(), "1024");
1065    }
1066
1067    #[test]
1068    fn byte_size_display_uses_largest_exact_unit() {
1069        assert_eq!(ByteSize(1024).to_string(), "1KB");
1070        assert_eq!(ByteSize(1024 * 1024).to_string(), "1MB");
1071        assert_eq!(ByteSize(2 * 1024 * 1024 * 1024).to_string(), "2GB");
1072        assert_eq!(ByteSize(1500).to_string(), "1500");
1073    }
1074
1075    #[test]
1076    fn node_run_config_parses_shared_memory_pool_size() {
1077        let yaml = "shared_memory_pool_size: 128MB\n";
1078        let config: NodeRunConfig = serde_yaml::from_str(yaml).unwrap();
1079        assert_eq!(
1080            config.shared_memory_pool_size,
1081            Some(ByteSize(128 * 1024 * 1024))
1082        );
1083    }
1084
1085    #[test]
1086    fn node_run_config_shared_memory_pool_size_optional() {
1087        let yaml = "outputs:\n  - foo\n";
1088        let config: NodeRunConfig = serde_yaml::from_str(yaml).unwrap();
1089        assert_eq!(config.shared_memory_pool_size, None);
1090    }
1091}