Skip to main content

made_core/value_objects/
duration.rs

1//! [`DurationMs`] value object — durations in whole milliseconds.
2//!
3//! The domain uses millisecond granularity for all observable durations
4//! (task runtime, deadlines, statistics) to match the gRPC/AsyncAPI
5//! contracts, which are already millisecond-typed.
6
7use std::fmt;
8use std::ops::Add;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::DomainError;
13
14/// A duration measured in whole milliseconds.
15#[derive(
16    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
17)]
18#[serde(transparent)]
19pub struct DurationMs(u64);
20
21impl DurationMs {
22    pub const ZERO: Self = Self(0);
23
24    #[must_use]
25    pub const fn from_millis(value: u64) -> Self {
26        Self(value)
27    }
28
29    #[must_use]
30    pub fn get(self) -> u64 {
31        self.0
32    }
33
34    /// Saturating addition so that aggregation of durations cannot
35    /// overflow and silently wrap.
36    #[must_use]
37    pub fn saturating_add(self, other: Self) -> Self {
38        Self(self.0.saturating_add(other.0))
39    }
40}
41
42impl Add for DurationMs {
43    type Output = Self;
44    fn add(self, rhs: Self) -> Self {
45        self.saturating_add(rhs)
46    }
47}
48
49impl fmt::Display for DurationMs {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(f, "{}ms", self.0)
52    }
53}
54
55impl From<u64> for DurationMs {
56    fn from(value: u64) -> Self {
57        Self::from_millis(value)
58    }
59}
60
61impl TryFrom<i64> for DurationMs {
62    type Error = DomainError;
63    fn try_from(value: i64) -> Result<Self, Self::Error> {
64        if value < 0 {
65            return Err(DomainError::OutOfRange {
66                field: "duration_ms",
67                value: value as f64,
68                min: 0.0,
69                max: f64::from(u32::MAX),
70            });
71        }
72        Ok(Self(value as u64))
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn zero_constant_is_zero() {
82        assert_eq!(DurationMs::ZERO.get(), 0);
83    }
84
85    #[test]
86    fn from_millis_is_identity() {
87        assert_eq!(DurationMs::from_millis(250).get(), 250);
88    }
89
90    #[test]
91    fn negative_i64_is_rejected() {
92        assert!(DurationMs::try_from(-1_i64).is_err());
93    }
94
95    #[test]
96    fn non_negative_i64_is_accepted() {
97        assert_eq!(DurationMs::try_from(42_i64).unwrap().get(), 42);
98    }
99
100    #[test]
101    fn saturating_add_cannot_overflow() {
102        let big = DurationMs::from_millis(u64::MAX);
103        assert_eq!(
104            big.saturating_add(DurationMs::from_millis(1)).get(),
105            u64::MAX
106        );
107    }
108
109    #[test]
110    fn add_uses_saturating_semantics() {
111        let a = DurationMs::from_millis(u64::MAX - 1);
112        let b = DurationMs::from_millis(10);
113        assert_eq!((a + b).get(), u64::MAX);
114    }
115
116    #[test]
117    fn ordering_is_natural() {
118        assert!(DurationMs::from_millis(1) < DurationMs::from_millis(2));
119    }
120
121    #[test]
122    fn display_includes_unit() {
123        assert_eq!(DurationMs::from_millis(5).to_string(), "5ms");
124    }
125
126    #[test]
127    fn serde_is_transparent() {
128        assert_eq!(
129            serde_json::to_string(&DurationMs::from_millis(7)).unwrap(),
130            "7"
131        );
132    }
133}