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