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)]
29pub struct LogSubscriptionFilter {
30 #[schemars(with = "Option<String>")]
32 pub min_level: Option<crate::common::LogLevelOrStdout>,
33 pub node_filter: Option<NodeId>,
35}
36
37pub const DEFAULT_QUEUE_SIZE: usize = 10;
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
42#[serde(rename_all = "snake_case")]
43pub enum QueuePolicy {
44 #[default]
46 DropOldest,
47 Backpressure,
49}
50
51impl QueuePolicy {
52 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
84#[non_exhaustive]
91pub struct NodeRunConfig {
92 #[serde(default)]
101 pub inputs: BTreeMap<DataId, Input>,
102 #[serde(default)]
112 pub outputs: BTreeSet<DataId>,
113 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
115 pub output_types: BTreeMap<DataId, String>,
116 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
118 pub output_framing: BTreeMap<DataId, descriptor::OutputFraming>,
119 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
121 pub input_types: BTreeMap<DataId, String>,
122
123 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub shared_memory_pool_size: Option<ByteSize>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
142#[serde(from = "InputDef", into = "InputDef")]
143pub struct Input {
144 pub mapping: InputMapping,
147 pub queue_size: Option<usize>,
150 pub input_timeout: Option<f64>,
153 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
271pub enum InputMapping {
272 Timer {
276 interval: Duration,
278 },
279 Logs(LogSubscriptionFilter),
283 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 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 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 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 let (level_str, node_filter) = match rest.split_once('/') {
407 Some((level, node)) => {
408 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 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 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
487pub struct FormattedDuration(pub Duration);
513
514impl fmt::Display for FormattedDuration {
515 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
516 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
533pub 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, JsonSchema)]
561pub struct UserInputMapping {
562 pub source: NodeId,
564 pub output: DataId,
566}
567
568#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
584pub struct ByteSize(pub usize);
585
586impl ByteSize {
587 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
604pub 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 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 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 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 assert_eq!(QueuePolicy::DropOldest.effective_cap(0), 1);
759 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 let result: Result<InputMapping, _> = "bad node/output".parse();
777 assert!(result.is_err(), "invalid source node id must be rejected");
778
779 let result: Result<InputMapping, _> = "node_a/bad output".parse();
781 assert!(result.is_err(), "invalid output data id must be rejected");
782
783 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 #[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 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 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 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 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 assert_eq!(
903 Duration::try_from_secs_f64(1.0 / 1_000_000_000_000.0),
904 Ok(Duration::ZERO)
905 );
906 }
907
908 #[test]
913 fn timer_subms_interval_roundtrips() {
914 let cases = [
915 "dora/timer/hz/3000", "dora/timer/micros/1", "dora/timer/nanos/1", "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 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 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 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 let result: Result<InputMapping, _> = "dora/logs/info/a/b".parse();
1065 assert!(result.is_err(), "node filter `a/b` must be rejected");
1066
1067 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 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 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 assert!("-1KB".parse::<ByteSize>().is_err());
1165 assert!("-0.5MB".parse::<ByteSize>().is_err());
1166 assert!("-1".parse::<ByteSize>().is_err());
1167 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 assert!("99999999999999999999999GB".parse::<ByteSize>().is_err());
1178 assert!(format!("{}KB", usize::MAX).parse::<ByteSize>().is_err());
1180 assert!("18014398509481984.0KB".parse::<ByteSize>().is_err());
1184 }
1185
1186 #[test]
1187 fn byte_size_integer_values_parse_exactly() {
1188 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}