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