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 {
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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
59#[non_exhaustive]
66pub struct NodeRunConfig {
67 #[serde(default)]
76 pub inputs: BTreeMap<DataId, Input>,
77 #[serde(default)]
87 pub outputs: BTreeSet<DataId>,
88 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90 pub output_types: BTreeMap<DataId, String>,
91 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
93 pub output_framing: BTreeMap<DataId, descriptor::OutputFraming>,
94 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96 pub input_types: BTreeMap<DataId, String>,
97
98 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub shared_memory_pool_size: Option<ByteSize>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117#[serde(from = "InputDef", into = "InputDef")]
118pub struct Input {
119 pub mapping: InputMapping,
122 pub queue_size: Option<usize>,
125 pub input_timeout: Option<f64>,
128 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
246pub enum InputMapping {
247 Timer {
251 interval: Duration,
253 },
254 Logs(LogSubscriptionFilter),
258 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 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 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 let (level_str, node_filter) = match rest.split_once('/') {
366 Some((level, node)) => {
367 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 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 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
446pub struct FormattedDuration(pub Duration);
472
473impl fmt::Display for FormattedDuration {
474 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475 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
492pub 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
520pub struct UserInputMapping {
521 pub source: NodeId,
523 pub output: DataId,
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
543pub struct ByteSize(pub usize);
544
545impl ByteSize {
546 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 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 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 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 assert_eq!(QueuePolicy::DropOldest.effective_cap(0), 1);
703 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 let result: Result<InputMapping, _> = "bad node/output".parse();
721 assert!(result.is_err(), "invalid source node id must be rejected");
722
723 let result: Result<InputMapping, _> = "node_a/bad output".parse();
725 assert!(result.is_err(), "invalid output data id must be rejected");
726
727 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 #[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 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 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 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 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 assert_eq!(
847 Duration::try_from_secs_f64(1.0 / 1_000_000_000_000.0),
848 Ok(Duration::ZERO)
849 );
850 }
851
852 #[test]
857 fn timer_subms_interval_roundtrips() {
858 let cases = [
859 "dora/timer/hz/3000", "dora/timer/micros/1", "dora/timer/nanos/1", "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 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 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 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 let result: Result<InputMapping, _> = "dora/logs/info/a/b".parse();
1009 assert!(result.is_err(), "node filter `a/b` must be rejected");
1010
1011 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 assert!("-1KB".parse::<ByteSize>().is_err());
1079 assert!("-0.5MB".parse::<ByteSize>().is_err());
1080 assert!("-1".parse::<ByteSize>().is_err());
1081 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 assert!("99999999999999999999999GB".parse::<ByteSize>().is_err());
1092 assert!(format!("{}KB", usize::MAX).parse::<ByteSize>().is_err());
1094 assert!("18014398509481984.0KB".parse::<ByteSize>().is_err());
1098 }
1099
1100 #[test]
1101 fn byte_size_integer_values_parse_exactly() {
1102 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}