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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
16pub struct LogSubscriptionFilter {
17 #[schemars(with = "Option<String>")]
19 pub min_level: Option<crate::common::LogLevelOrStdout>,
20 pub node_filter: Option<NodeId>,
22}
23
24pub const DEFAULT_QUEUE_SIZE: usize = 10;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
29#[serde(rename_all = "snake_case")]
30pub enum QueuePolicy {
31 #[default]
33 DropOldest,
34 Backpressure,
36}
37
38impl QueuePolicy {
39 pub fn effective_cap(&self, queue_size: usize) -> usize {
44 match self {
45 Self::DropOldest => queue_size,
46 Self::Backpressure => queue_size.saturating_mul(10).max(100),
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
53pub struct NodeRunConfig {
54 #[serde(default)]
63 pub inputs: BTreeMap<DataId, Input>,
64 #[serde(default)]
74 pub outputs: BTreeSet<DataId>,
75 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
77 pub output_types: BTreeMap<DataId, String>,
78 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
80 pub output_framing: BTreeMap<DataId, descriptor::OutputFraming>,
81 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
83 pub input_types: BTreeMap<DataId, String>,
84
85 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub shared_memory_pool_size: Option<ByteSize>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
98#[serde(from = "InputDef", into = "InputDef")]
99pub struct Input {
100 pub mapping: InputMapping,
101 pub queue_size: Option<usize>,
102 pub input_timeout: Option<f64>,
103 pub queue_policy: Option<QueuePolicy>,
104}
105
106impl PartialEq for Input {
107 fn eq(&self, other: &Self) -> bool {
108 self.mapping == other.mapping
109 && self.queue_size == other.queue_size
110 && self.input_timeout.map(f64::to_bits) == other.input_timeout.map(f64::to_bits)
111 && self.queue_policy == other.queue_policy
112 }
113}
114
115impl Eq for Input {}
116
117#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
118#[serde(untagged)]
119pub enum InputDef {
120 MappingOnly(InputMapping),
121 WithOptions {
122 source: InputMapping,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 queue_size: Option<usize>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
126 input_timeout: Option<f64>,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 queue_policy: Option<QueuePolicy>,
129 },
130}
131
132impl PartialEq for InputDef {
133 fn eq(&self, other: &Self) -> bool {
134 match (self, other) {
135 (Self::MappingOnly(a), Self::MappingOnly(b)) => a == b,
136 (
137 Self::WithOptions {
138 source: s1,
139 queue_size: q1,
140 input_timeout: t1,
141 queue_policy: p1,
142 },
143 Self::WithOptions {
144 source: s2,
145 queue_size: q2,
146 input_timeout: t2,
147 queue_policy: p2,
148 },
149 ) => s1 == s2 && q1 == q2 && t1.map(f64::to_bits) == t2.map(f64::to_bits) && p1 == p2,
150 _ => false,
151 }
152 }
153}
154
155impl Eq for InputDef {}
156
157impl From<Input> for InputDef {
158 fn from(input: Input) -> Self {
159 if input.queue_size.is_none()
160 && input.input_timeout.is_none()
161 && input.queue_policy.is_none()
162 {
163 Self::MappingOnly(input.mapping)
164 } else {
165 Self::WithOptions {
166 source: input.mapping,
167 queue_size: input.queue_size,
168 input_timeout: input.input_timeout,
169 queue_policy: input.queue_policy,
170 }
171 }
172 }
173}
174
175impl From<InputDef> for Input {
176 fn from(value: InputDef) -> Self {
177 match value {
178 InputDef::MappingOnly(mapping) => Self {
179 mapping,
180 queue_size: None,
181 input_timeout: None,
182 queue_policy: None,
183 },
184 InputDef::WithOptions {
185 source,
186 queue_size,
187 input_timeout,
188 queue_policy,
189 } => Self {
190 mapping: source,
191 queue_size,
192 input_timeout,
193 queue_policy,
194 },
195 }
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
200pub enum InputMapping {
201 Timer {
202 interval: Duration,
203 },
204 Logs(LogSubscriptionFilter),
208 User(UserInputMapping),
209}
210
211impl fmt::Display for InputMapping {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 match self {
214 InputMapping::Timer { interval } => {
215 let duration = format_duration(*interval);
216 write!(f, "dora/timer/{duration}")
217 }
218 InputMapping::Logs(filter) => {
219 write!(f, "dora/logs")?;
220 if let Some(level) = &filter.min_level {
221 write!(f, "/{}", format_log_level(level))?;
222 if let Some(node) = &filter.node_filter {
223 write!(f, "/{node}")?;
224 }
225 }
226 Ok(())
227 }
228 InputMapping::User(mapping) => {
229 write!(f, "{}/{}", mapping.source, mapping.output)
230 }
231 }
232 }
233}
234
235impl FromStr for InputMapping {
236 type Err = String;
237
238 fn from_str(s: &str) -> Result<Self, Self::Err> {
239 let (source, output) = s
240 .split_once('/')
241 .ok_or("input must start with `<source>/`")?;
242
243 let mapping = match source {
244 "dora" => match output.split_once('/') {
245 Some(("timer", output)) => {
246 let (unit, value) = output.split_once('/').ok_or(
247 "timer input must specify unit and value (e.g. `secs/5`, `millis/100`, or `hz/30`)",
248 )?;
249 let interval = match unit {
250 "secs" => {
251 let value = value
252 .parse()
253 .map_err(|_| format!("secs must be an integer (got `{value}`)"))?;
254 Duration::from_secs(value)
255 }
256 "millis" => {
257 let value = value.parse().map_err(|_| {
258 format!("millis must be an integer (got `{value}`)")
259 })?;
260 Duration::from_millis(value)
261 }
262 "micros" => {
263 let value = value.parse().map_err(|_| {
264 format!("micros must be an integer (got `{value}`)")
265 })?;
266 Duration::from_micros(value)
267 }
268 "nanos" => {
269 let value = value
270 .parse()
271 .map_err(|_| format!("nanos must be an integer (got `{value}`)"))?;
272 Duration::from_nanos(value)
273 }
274 "hz" => {
275 let hz: f64 = value.parse().map_err(|_| {
276 format!("hz must be a positive number (got `{value}`)")
277 })?;
278 if !hz.is_finite() || hz <= 0.0 {
279 return Err(format!(
280 "hz must be a positive finite number (got `{value}`)"
281 ));
282 }
283 Duration::try_from_secs_f64(1.0 / hz).map_err(|e| {
284 format!("hz `{value}` produces an out-of-range interval: {e}")
285 })?
286 }
287 other => {
288 return Err(format!(
289 "timer unit must be `secs`, `millis`, `micros`, `nanos`, or `hz` (got `{other}`)"
290 ));
291 }
292 };
293 Self::Timer { interval }
294 }
295 Some(("logs", rest)) => {
296 let (level_str, node_filter) = match rest.split_once('/') {
298 Some((level, node)) => {
299 let node_id = node.parse::<NodeId>().map_err(|e| e.to_string())?;
307 (Some(level), Some(node_id))
308 }
309 None => {
310 if rest.is_empty() {
311 (None, None)
312 } else {
313 (Some(rest), None)
314 }
315 }
316 };
317 let min_level = level_str.map(parse_log_level_str).transpose()?;
318 Self::Logs(LogSubscriptionFilter {
319 min_level,
320 node_filter,
321 })
322 }
323 Some((other, _)) => {
324 return Err(format!("unknown dora input `{other}`"));
325 }
326 None if output == "logs" => Self::Logs(LogSubscriptionFilter {
328 min_level: None,
329 node_filter: None,
330 }),
331 None => return Err("dora input has invalid format".into()),
332 },
333 _ => {
334 let source = source.parse::<NodeId>().map_err(|e| e.to_string())?;
340 let output = output.parse::<DataId>().map_err(|e| e.to_string())?;
341 Self::User(UserInputMapping { source, output })
342 }
343 };
344
345 Ok(mapping)
346 }
347}
348
349fn parse_log_level_str(s: &str) -> Result<crate::common::LogLevelOrStdout, String> {
350 use crate::common::{LogLevel, LogLevelOrStdout};
351 match s.to_lowercase().as_str() {
352 "stdout" => Ok(LogLevelOrStdout::Stdout),
353 "error" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Error)),
354 "warn" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Warn)),
355 "info" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Info)),
356 "debug" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Debug)),
357 "trace" => Ok(LogLevelOrStdout::LogLevel(LogLevel::Trace)),
358 other => Err(format!(
359 "unknown log level `{other}` (expected: stdout, error, warn, info, debug, trace)"
360 )),
361 }
362}
363
364fn format_log_level(level: &crate::common::LogLevelOrStdout) -> &'static str {
365 use crate::common::{LogLevel, LogLevelOrStdout};
366 match level {
367 LogLevelOrStdout::Stdout => "stdout",
368 LogLevelOrStdout::LogLevel(l) => match *l {
369 LogLevel::Error => "error",
370 LogLevel::Warn => "warn",
371 LogLevel::Info => "info",
372 LogLevel::Debug => "debug",
373 LogLevel::Trace => "trace",
374 },
375 }
376}
377
378pub struct FormattedDuration(pub Duration);
379
380impl fmt::Display for FormattedDuration {
381 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
382 let nanos = self.0.as_nanos();
387 if nanos.is_multiple_of(1_000_000_000) {
388 write!(f, "secs/{}", self.0.as_secs())
389 } else if nanos.is_multiple_of(1_000_000) {
390 write!(f, "millis/{}", self.0.as_millis())
391 } else if nanos.is_multiple_of(1_000) {
392 write!(f, "micros/{}", self.0.as_micros())
393 } else {
394 write!(f, "nanos/{nanos}")
395 }
396 }
397}
398
399pub fn format_duration(interval: Duration) -> FormattedDuration {
400 FormattedDuration(interval)
401}
402
403impl Serialize for InputMapping {
404 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
405 where
406 S: serde::Serializer,
407 {
408 serializer.collect_str(self)
409 }
410}
411
412impl<'de> Deserialize<'de> for InputMapping {
413 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
414 where
415 D: serde::Deserializer<'de>,
416 {
417 let string = String::deserialize(deserializer)?;
418 string.parse().map_err(serde::de::Error::custom)
419 }
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
423pub struct UserInputMapping {
424 pub source: NodeId,
425 pub output: DataId,
426}
427
428#[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Clone)]
429#[serde(deny_unknown_fields, rename_all = "lowercase")]
430pub struct CommunicationConfig {
431 #[serde(
433 default,
434 with = "serde_yaml::with::singleton_map",
435 rename = "_unstable_local"
436 )]
437 #[schemars(with = "String")]
438 pub local: LocalCommunicationConfig,
439 #[serde(
440 default,
441 with = "serde_yaml::with::singleton_map",
442 rename = "_unstable_remote"
443 )]
444 #[schemars(with = "String")]
445 pub remote: RemoteCommunicationConfig,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
449#[serde(rename_all = "lowercase")]
450pub enum LocalCommunicationConfig {
451 #[default]
452 Tcp,
453}
454
455impl fmt::Display for LocalCommunicationConfig {
456 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
457 match self {
458 Self::Tcp => write!(f, "tcp"),
459 }
460 }
461}
462
463#[derive(Debug, Serialize, Deserialize, Clone)]
464#[serde(deny_unknown_fields, rename_all = "lowercase")]
465#[derive(Default)]
466pub enum RemoteCommunicationConfig {
467 #[default]
468 Tcp,
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
487pub struct ByteSize(pub usize);
488
489impl ByteSize {
490 pub fn as_bytes(&self) -> usize {
492 self.0
493 }
494}
495
496impl FromStr for ByteSize {
497 type Err = String;
498
499 fn from_str(s: &str) -> Result<Self, Self::Err> {
500 let s = s.trim();
501 let (num_part, unit_part) = match s.find(|c: char| c.is_alphabetic()) {
502 Some(pos) => (s[..pos].trim(), s[pos..].trim()),
503 None => {
504 let bytes: usize = s.parse().map_err(|_| format!("invalid byte size: `{s}`"))?;
505 return Ok(ByteSize(bytes));
506 }
507 };
508
509 let multiplier: usize = match unit_part.to_uppercase().as_str() {
510 "B" => 1,
511 "KB" | "K" => 1024,
512 "MB" | "M" => 1024 * 1024,
513 "GB" | "G" => 1024 * 1024 * 1024,
514 other => return Err(format!("unknown byte size unit: `{other}`")),
515 };
516
517 if let Ok(num) = num_part.parse::<usize>() {
519 return num
520 .checked_mul(multiplier)
521 .map(ByteSize)
522 .ok_or_else(|| format!("byte size `{s}` is too large"));
523 }
524
525 let num: f64 = num_part
526 .parse()
527 .map_err(|_| format!("invalid number in byte size: `{num_part}`"))?;
528
529 if !num.is_finite() || num < 0.0 {
533 return Err(format!(
534 "byte size must be a non-negative, finite number: `{s}`"
535 ));
536 }
537 let bytes = num * multiplier as f64;
538 if bytes >= usize::MAX as f64 {
542 return Err(format!("byte size `{s}` is too large"));
543 }
544 Ok(ByteSize(bytes as usize))
545 }
546}
547
548impl fmt::Display for ByteSize {
549 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
550 let bytes = self.0;
551 if bytes == 0 {
552 write!(f, "0B")
553 } else if bytes.is_multiple_of(1024 * 1024 * 1024) {
554 write!(f, "{}GB", bytes / (1024 * 1024 * 1024))
555 } else if bytes.is_multiple_of(1024 * 1024) {
556 write!(f, "{}MB", bytes / (1024 * 1024))
557 } else if bytes.is_multiple_of(1024) {
558 write!(f, "{}KB", bytes / 1024)
559 } else {
560 write!(f, "{bytes}")
561 }
562 }
563}
564
565impl Serialize for ByteSize {
566 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
567 where
568 S: serde::Serializer,
569 {
570 self.0.serialize(serializer)
571 }
572}
573
574impl<'de> Deserialize<'de> for ByteSize {
575 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
576 where
577 D: serde::Deserializer<'de>,
578 {
579 use serde::de;
580
581 struct ByteSizeVisitor;
582
583 impl de::Visitor<'_> for ByteSizeVisitor {
584 type Value = ByteSize;
585
586 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
587 formatter.write_str("a byte size as integer or string (e.g. 67108864, \"64MB\")")
588 }
589
590 fn visit_u64<E: de::Error>(self, v: u64) -> Result<ByteSize, E> {
591 usize::try_from(v)
592 .map(ByteSize)
593 .map_err(|_| E::custom(format!("byte size `{v}` is too large")))
594 }
595
596 fn visit_i64<E: de::Error>(self, v: i64) -> Result<ByteSize, E> {
597 if v < 0 {
598 return Err(E::custom("byte size cannot be negative"));
599 }
600 usize::try_from(v)
601 .map(ByteSize)
602 .map_err(|_| E::custom(format!("byte size `{v}` is too large")))
603 }
604
605 fn visit_str<E: de::Error>(self, v: &str) -> Result<ByteSize, E> {
606 v.parse().map_err(E::custom)
607 }
608 }
609
610 deserializer.deserialize_any(ByteSizeVisitor)
611 }
612}
613
614impl JsonSchema for ByteSize {
615 fn schema_name() -> std::borrow::Cow<'static, str> {
616 "ByteSize".into()
617 }
618
619 fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
620 schemars::json_schema!({
621 "anyOf": [
622 { "type": "integer" },
623 { "type": "string" }
624 ],
625 "description": "Byte size: integer (raw bytes) or string with unit (e.g. \"128MB\", \"1GB\")"
626 })
627 }
628}
629
630#[cfg(test)]
631mod tests {
632 use super::*;
633
634 #[test]
635 fn parse_input_without_queue_policy() {
636 let yaml = "source: node_a/output_1\nqueue_size: 5\n";
637 let input: Input = serde_yaml::from_str(yaml).unwrap();
638 assert_eq!(input.queue_size, Some(5));
639 assert_eq!(input.queue_policy, None);
640 }
641
642 #[test]
643 fn parse_user_mapping_rejects_invalid_ids() {
644 let result: Result<InputMapping, _> = "bad node/output".parse();
648 assert!(result.is_err(), "invalid source node id must be rejected");
649
650 let result: Result<InputMapping, _> = "node_a/bad output".parse();
652 assert!(result.is_err(), "invalid output data id must be rejected");
653
654 let mapping: InputMapping = "node_a/output_1".parse().unwrap();
656 assert!(matches!(mapping, InputMapping::User(_)));
657 }
658
659 #[test]
660 fn parse_input_with_drop_oldest_policy() {
661 let yaml = "source: node_a/output_1\nqueue_size: 5\nqueue_policy: drop_oldest\n";
662 let input: Input = serde_yaml::from_str(yaml).unwrap();
663 assert_eq!(input.queue_policy, Some(QueuePolicy::DropOldest));
664 }
665
666 #[test]
667 fn parse_input_with_backpressure_policy() {
668 let yaml = "source: node_a/output_1\nqueue_size: 10\nqueue_policy: backpressure\n";
669 let input: Input = serde_yaml::from_str(yaml).unwrap();
670 assert_eq!(input.queue_policy, Some(QueuePolicy::Backpressure));
671 }
672
673 #[test]
674 fn parse_short_form_input_has_no_policy() {
675 let yaml = "node_a/output_1";
676 let input: Input = serde_yaml::from_str(yaml).unwrap();
677 assert_eq!(input.queue_policy, None);
678 assert_eq!(input.queue_size, None);
679 }
680
681 #[test]
682 fn roundtrip_input_with_policy() {
683 let input = Input {
684 mapping: "node_a/output_1".parse().unwrap(),
685 queue_size: Some(3),
686 input_timeout: None,
687 queue_policy: Some(QueuePolicy::Backpressure),
688 };
689 let yaml = serde_yaml::to_string(&input).unwrap();
690 let parsed: Input = serde_yaml::from_str(&yaml).unwrap();
691 assert_eq!(input, parsed);
692 }
693
694 #[test]
699 fn parse_timer_hz_integer() {
700 let mapping: InputMapping = "dora/timer/hz/30".parse().unwrap();
701 match mapping {
702 InputMapping::Timer { interval } => {
703 assert_eq!(interval, Duration::from_secs_f64(1.0 / 30.0));
705 }
706 other => panic!("expected Timer, got {other:?}"),
707 }
708 }
709
710 #[test]
711 fn parse_timer_hz_fractional() {
712 let mapping: InputMapping = "dora/timer/hz/0.5".parse().unwrap();
714 match mapping {
715 InputMapping::Timer { interval } => {
716 assert_eq!(interval, Duration::from_secs(2));
717 }
718 other => panic!("expected Timer, got {other:?}"),
719 }
720 }
721
722 #[test]
723 fn parse_timer_hz_rejects_zero() {
724 let err = "dora/timer/hz/0".parse::<InputMapping>().unwrap_err();
725 assert!(err.contains("hz"), "error should mention hz: {err}");
726 }
727
728 #[test]
729 fn parse_timer_hz_rejects_negative() {
730 let err = "dora/timer/hz/-1".parse::<InputMapping>().unwrap_err();
731 assert!(err.contains("hz"), "error should mention hz: {err}");
732 }
733
734 #[test]
735 fn parse_timer_hz_rejects_non_numeric() {
736 let err = "dora/timer/hz/foo".parse::<InputMapping>().unwrap_err();
737 assert!(err.contains("hz"), "error should mention hz: {err}");
738 }
739
740 #[test]
741 fn parse_timer_hz_rejects_overflow() {
742 let err = "dora/timer/hz/0.00000000000000000001"
746 .parse::<InputMapping>()
747 .unwrap_err();
748 assert!(err.contains("hz"), "error should mention hz: {err}");
749 }
750
751 #[test]
756 fn timer_subms_interval_roundtrips() {
757 let cases = [
758 "dora/timer/hz/3000", "dora/timer/micros/1", "dora/timer/nanos/1", "dora/timer/nanos/500",
762 "dora/timer/micros/250",
763 ];
764 for case in cases {
765 let mapping: InputMapping = case.parse().unwrap();
766 let InputMapping::Timer { interval } = mapping else {
767 panic!("expected Timer for `{case}`, got {mapping:?}");
768 };
769 assert!(!interval.is_zero(), "`{case}` parsed to a zero interval");
770 let rendered = mapping.to_string();
771 let reparsed: InputMapping = rendered.parse().unwrap();
772 assert_eq!(
773 mapping, reparsed,
774 "`{case}` did not round-trip (rendered as `{rendered}`)"
775 );
776 }
777 }
778
779 #[test]
780 fn timer_display_uses_coarsest_exact_unit() {
781 let render = |d: Duration| format_duration(d).to_string();
782 assert_eq!(render(Duration::from_secs(5)), "secs/5");
783 assert_eq!(render(Duration::from_millis(100)), "millis/100");
784 assert_eq!(render(Duration::from_micros(250)), "micros/250");
785 assert_eq!(render(Duration::from_nanos(500)), "nanos/500");
786 assert_eq!(render(Duration::from_nanos(333_333)), "nanos/333333");
788 }
789
790 #[test]
791 fn parse_timer_micros_and_nanos() {
792 let micros: InputMapping = "dora/timer/micros/250".parse().unwrap();
793 assert_eq!(
794 micros,
795 InputMapping::Timer {
796 interval: Duration::from_micros(250)
797 }
798 );
799 let nanos: InputMapping = "dora/timer/nanos/500".parse().unwrap();
800 assert_eq!(
801 nanos,
802 InputMapping::Timer {
803 interval: Duration::from_nanos(500)
804 }
805 );
806 }
807
808 #[test]
809 fn timer_whole_second_and_milli_still_roundtrip() {
810 for case in ["dora/timer/secs/2", "dora/timer/millis/100"] {
811 let mapping: InputMapping = case.parse().unwrap();
812 let reparsed: InputMapping = mapping.to_string().parse().unwrap();
813 assert_eq!(mapping, reparsed);
814 }
815 assert_eq!(
817 "dora/timer/secs/2"
818 .parse::<InputMapping>()
819 .unwrap()
820 .to_string(),
821 "dora/timer/secs/2"
822 );
823 assert_eq!(
824 "dora/timer/millis/100"
825 .parse::<InputMapping>()
826 .unwrap()
827 .to_string(),
828 "dora/timer/millis/100"
829 );
830 }
831
832 #[test]
833 fn roundtrip_input_without_policy_uses_short_form() {
834 let input = Input {
835 mapping: "node_a/output_1".parse().unwrap(),
836 queue_size: None,
837 input_timeout: None,
838 queue_policy: None,
839 };
840 let yaml = serde_yaml::to_string(&input).unwrap();
841 assert!(!yaml.contains("source:"));
843 let parsed: Input = serde_yaml::from_str(&yaml).unwrap();
844 assert_eq!(input, parsed);
845 }
846
847 #[test]
848 fn queue_policy_default_is_drop_oldest() {
849 assert_eq!(QueuePolicy::default(), QueuePolicy::DropOldest);
850 }
851
852 #[test]
853 fn parse_logs_all() {
854 let mapping: InputMapping = "dora/logs".parse().unwrap();
855 assert!(matches!(
856 mapping,
857 InputMapping::Logs(LogSubscriptionFilter {
858 min_level: None,
859 node_filter: None,
860 })
861 ));
862 }
863
864 #[test]
865 fn parse_logs_with_level() {
866 use crate::common::{LogLevel, LogLevelOrStdout};
867 let mapping: InputMapping = "dora/logs/info".parse().unwrap();
868 match mapping {
869 InputMapping::Logs(f) => {
870 assert_eq!(
871 f.min_level,
872 Some(LogLevelOrStdout::LogLevel(LogLevel::Info))
873 );
874 assert_eq!(f.node_filter, None);
875 }
876 _ => panic!("expected Logs variant"),
877 }
878 }
879
880 #[test]
881 fn parse_logs_with_level_and_node() {
882 use crate::common::{LogLevel, LogLevelOrStdout};
883 let mapping: InputMapping = "dora/logs/error/sensor".parse().unwrap();
884 match mapping {
885 InputMapping::Logs(f) => {
886 assert_eq!(
887 f.min_level,
888 Some(LogLevelOrStdout::LogLevel(LogLevel::Error))
889 );
890 assert_eq!(f.node_filter, Some(NodeId("sensor".to_string())));
891 }
892 _ => panic!("expected Logs variant"),
893 }
894 }
895
896 #[test]
897 fn parse_logs_invalid_level() {
898 let result: Result<InputMapping, _> = "dora/logs/banana".parse();
899 assert!(result.is_err());
900 }
901
902 #[test]
903 fn parse_logs_rejects_invalid_node_filter() {
904 let result: Result<InputMapping, _> = "dora/logs/info/a/b".parse();
908 assert!(result.is_err(), "node filter `a/b` must be rejected");
909
910 let result: Result<InputMapping, _> = "dora/logs/info/bad node".parse();
912 assert!(result.is_err(), "node filter `bad node` must be rejected");
913 }
914
915 #[test]
916 fn display_roundtrip_logs_all() {
917 let mapping: InputMapping = "dora/logs".parse().unwrap();
918 assert_eq!(mapping.to_string(), "dora/logs");
919 }
920
921 #[test]
922 fn display_roundtrip_logs_with_level() {
923 let mapping: InputMapping = "dora/logs/warn".parse().unwrap();
924 assert_eq!(mapping.to_string(), "dora/logs/warn");
925 }
926
927 #[test]
928 fn display_roundtrip_logs_with_level_and_node() {
929 let mapping: InputMapping = "dora/logs/debug/camera".parse().unwrap();
930 assert_eq!(mapping.to_string(), "dora/logs/debug/camera");
931 }
932
933 #[test]
934 fn parse_logs_trailing_slash() {
935 let mapping: InputMapping = "dora/logs/".parse().unwrap();
936 assert!(matches!(
937 mapping,
938 InputMapping::Logs(LogSubscriptionFilter {
939 min_level: None,
940 node_filter: None,
941 })
942 ));
943 }
944
945 #[test]
946 fn communication_config_local_tcp_lowercase() {
947 let yaml = "_unstable_local: tcp\n";
948 let config: CommunicationConfig = serde_yaml::from_str(yaml).unwrap();
949 assert_eq!(config.local, LocalCommunicationConfig::Tcp);
950 }
951
952 #[test]
953 fn communication_config_remote_tcp_lowercase() {
954 let yaml = "_unstable_remote: tcp\n";
955 let config: CommunicationConfig = serde_yaml::from_str(yaml).unwrap();
956 assert!(matches!(config.remote, RemoteCommunicationConfig::Tcp));
957 }
958
959 #[test]
960 fn communication_config_unknown_local_variant_errors() {
961 let yaml = "_unstable_local: unixdomain\n";
962 assert!(serde_yaml::from_str::<CommunicationConfig>(yaml).is_err());
963 }
964
965 #[test]
966 fn local_communication_config_display() {
967 assert_eq!(LocalCommunicationConfig::Tcp.to_string(), "tcp");
968 }
969
970 #[test]
971 fn communication_config_roundtrip_local() {
972 let config = CommunicationConfig {
973 local: LocalCommunicationConfig::Tcp,
974 remote: RemoteCommunicationConfig::Tcp,
975 };
976 let yaml = serde_yaml::to_string(&config).unwrap();
977 let parsed: CommunicationConfig = serde_yaml::from_str(&yaml).unwrap();
978 assert_eq!(parsed.local, LocalCommunicationConfig::Tcp);
979 }
980
981 #[test]
982 fn byte_size_parses_raw_bytes() {
983 assert_eq!("1024".parse::<ByteSize>().unwrap(), ByteSize(1024));
984 assert_eq!("0".parse::<ByteSize>().unwrap(), ByteSize(0));
985 }
986
987 #[test]
988 fn byte_size_parses_units_case_insensitively() {
989 assert_eq!("1KB".parse::<ByteSize>().unwrap(), ByteSize(1024));
990 assert_eq!("1kb".parse::<ByteSize>().unwrap(), ByteSize(1024));
991 assert_eq!("1MB".parse::<ByteSize>().unwrap(), ByteSize(1024 * 1024));
992 assert_eq!(
993 "1GB".parse::<ByteSize>().unwrap(),
994 ByteSize(1024 * 1024 * 1024)
995 );
996 assert_eq!(
997 "128 MB".parse::<ByteSize>().unwrap(),
998 ByteSize(128 * 1024 * 1024)
999 );
1000 assert_eq!("512B".parse::<ByteSize>().unwrap(), ByteSize(512));
1001 }
1002
1003 #[test]
1004 fn byte_size_rejects_unknown_unit() {
1005 assert!("1TB".parse::<ByteSize>().is_err());
1006 assert!("abc".parse::<ByteSize>().is_err());
1007 }
1008
1009 #[test]
1010 fn byte_size_rejects_negative() {
1011 assert!("-1KB".parse::<ByteSize>().is_err());
1014 assert!("-0.5MB".parse::<ByteSize>().is_err());
1015 assert!("-1".parse::<ByteSize>().is_err());
1016 assert!(serde_yaml::from_str::<ByteSize>("-1").is_err());
1019 assert!(serde_yaml::from_str::<ByteSize>(r#""-1KB""#).is_err());
1020 }
1021
1022 #[test]
1023 fn byte_size_rejects_overflow() {
1024 assert!("99999999999999999999999GB".parse::<ByteSize>().is_err());
1027 assert!(format!("{}KB", usize::MAX).parse::<ByteSize>().is_err());
1029 assert!("18014398509481984.0KB".parse::<ByteSize>().is_err());
1033 }
1034
1035 #[test]
1036 fn byte_size_integer_values_parse_exactly() {
1037 assert_eq!(
1040 "9007199254740993B".parse::<ByteSize>().unwrap(),
1041 ByteSize(9007199254740993)
1042 );
1043 }
1044
1045 #[test]
1046 fn byte_size_float_path_still_works() {
1047 assert_eq!("1.5KB".parse::<ByteSize>().unwrap(), ByteSize(1536));
1048 assert_eq!("0.5MB".parse::<ByteSize>().unwrap(), ByteSize(512 * 1024));
1049 assert!("x.5KB".parse::<ByteSize>().is_err());
1050 }
1051
1052 #[test]
1053 fn byte_size_deserializes_int_or_string() {
1054 let from_int: ByteSize = serde_yaml::from_str("67108864").unwrap();
1055 assert_eq!(from_int, ByteSize(64 * 1024 * 1024));
1056
1057 let from_str: ByteSize = serde_yaml::from_str(r#""64MB""#).unwrap();
1058 assert_eq!(from_str, ByteSize(64 * 1024 * 1024));
1059 }
1060
1061 #[test]
1062 fn byte_size_serializes_as_integer() {
1063 let yaml = serde_yaml::to_string(&ByteSize(1024)).unwrap();
1064 assert_eq!(yaml.trim(), "1024");
1065 }
1066
1067 #[test]
1068 fn byte_size_display_uses_largest_exact_unit() {
1069 assert_eq!(ByteSize(1024).to_string(), "1KB");
1070 assert_eq!(ByteSize(1024 * 1024).to_string(), "1MB");
1071 assert_eq!(ByteSize(2 * 1024 * 1024 * 1024).to_string(), "2GB");
1072 assert_eq!(ByteSize(1500).to_string(), "1500");
1073 }
1074
1075 #[test]
1076 fn node_run_config_parses_shared_memory_pool_size() {
1077 let yaml = "shared_memory_pool_size: 128MB\n";
1078 let config: NodeRunConfig = serde_yaml::from_str(yaml).unwrap();
1079 assert_eq!(
1080 config.shared_memory_pool_size,
1081 Some(ByteSize(128 * 1024 * 1024))
1082 );
1083 }
1084
1085 #[test]
1086 fn node_run_config_shared_memory_pool_size_optional() {
1087 let yaml = "outputs:\n - foo\n";
1088 let config: NodeRunConfig = serde_yaml::from_str(yaml).unwrap();
1089 assert_eq!(config.shared_memory_pool_size, None);
1090 }
1091}