Skip to main content

nodedb_types/datetime/
duration.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Microseconds-precision signed duration type.
4
5use serde::{Deserialize, Serialize};
6
7use super::error::NdbDateTimeError;
8
9/// Microseconds-precision duration (signed).
10///
11/// String format: human-readable `"1h30m15s"` or `"500ms"`.
12///
13/// `#[non_exhaustive]` — a `months` field for calendar-interval semantics
14/// may be added alongside the microsecond component.
15#[non_exhaustive]
16#[derive(
17    Debug,
18    Clone,
19    Copy,
20    PartialEq,
21    Eq,
22    PartialOrd,
23    Ord,
24    Hash,
25    Serialize,
26    Deserialize,
27    zerompk::ToMessagePack,
28    zerompk::FromMessagePack,
29)]
30pub struct NdbDuration {
31    /// Microseconds (signed: negative = past).
32    pub micros: i64,
33}
34
35impl NdbDuration {
36    pub fn from_micros(micros: i64) -> Self {
37        Self { micros }
38    }
39
40    /// Create from milliseconds.
41    ///
42    /// Returns `Err` if `millis * 1_000` overflows `i64`.
43    pub fn from_millis(millis: i64) -> Result<Self, NdbDateTimeError> {
44        let micros = millis
45            .checked_mul(1_000)
46            .ok_or(NdbDateTimeError::Overflow {
47                input: millis,
48                unit: "millis",
49            })?;
50        Ok(Self { micros })
51    }
52
53    /// Create from seconds.
54    ///
55    /// Returns `Err` if `secs * 1_000_000` overflows `i64`.
56    pub fn from_secs(secs: i64) -> Result<Self, NdbDateTimeError> {
57        let micros = secs
58            .checked_mul(1_000_000)
59            .ok_or(NdbDateTimeError::Overflow {
60                input: secs,
61                unit: "secs",
62            })?;
63        Ok(Self { micros })
64    }
65
66    /// Create from minutes.
67    ///
68    /// Returns `Err` if `mins * 60_000_000` overflows `i64`.
69    pub fn from_minutes(mins: i64) -> Result<Self, NdbDateTimeError> {
70        let micros = mins
71            .checked_mul(60_000_000)
72            .ok_or(NdbDateTimeError::Overflow {
73                input: mins,
74                unit: "minutes",
75            })?;
76        Ok(Self { micros })
77    }
78
79    /// Create from hours.
80    ///
81    /// Returns `Err` if `hours * 3_600_000_000` overflows `i64`.
82    pub fn from_hours(hours: i64) -> Result<Self, NdbDateTimeError> {
83        let micros = hours
84            .checked_mul(3_600_000_000)
85            .ok_or(NdbDateTimeError::Overflow {
86                input: hours,
87                unit: "hours",
88            })?;
89        Ok(Self { micros })
90    }
91
92    /// Create from days.
93    ///
94    /// Returns `Err` if `days * 86_400_000_000` overflows `i64`.
95    pub fn from_days(days: i64) -> Result<Self, NdbDateTimeError> {
96        let micros = days
97            .checked_mul(86_400_000_000)
98            .ok_or(NdbDateTimeError::Overflow {
99                input: days,
100                unit: "days",
101            })?;
102        Ok(Self { micros })
103    }
104
105    pub fn as_secs_f64(&self) -> f64 {
106        self.micros as f64 / 1_000_000.0
107    }
108
109    pub fn as_millis(&self) -> i64 {
110        self.micros / 1_000
111    }
112
113    /// Format as human-readable string.
114    pub fn to_human(&self) -> String {
115        let abs = self.micros.unsigned_abs();
116        let sign = if self.micros < 0 { "-" } else { "" };
117
118        if abs < 1_000 {
119            return format!("{sign}{abs}us");
120        }
121        if abs < 1_000_000 {
122            return format!("{sign}{}ms", abs / 1_000);
123        }
124
125        let total_secs = abs / 1_000_000;
126        let hours = total_secs / 3600;
127        let mins = (total_secs % 3600) / 60;
128        let secs = total_secs % 60;
129
130        if hours > 0 {
131            if mins > 0 || secs > 0 {
132                format!("{sign}{hours}h{mins}m{secs}s")
133            } else {
134                format!("{sign}{hours}h")
135            }
136        } else if mins > 0 {
137            if secs > 0 {
138                format!("{sign}{mins}m{secs}s")
139            } else {
140                format!("{sign}{mins}m")
141            }
142        } else {
143            format!("{sign}{secs}s")
144        }
145    }
146
147    /// Parse from human-readable string: "1h30m", "500ms", "30s", "2d".
148    ///
149    /// Returns `None` if the string is malformed or any multiplication overflows.
150    pub fn parse(s: &str) -> Option<Self> {
151        let s = s.trim();
152        if s.is_empty() {
153            return None;
154        }
155
156        let (neg, s) = if let Some(rest) = s.strip_prefix('-') {
157            (true, rest)
158        } else {
159            (false, s)
160        };
161
162        // Simple suffix parsing.
163        if let Some(n) = s.strip_suffix("us") {
164            let v: i64 = n.trim().parse().ok()?;
165            return Some(Self::from_micros(if neg { -v } else { v }));
166        }
167        if let Some(n) = s.strip_suffix("ms") {
168            let v: i64 = n.trim().parse().ok()?;
169            let d = Self::from_millis(if neg { -v } else { v }).ok()?;
170            return Some(d);
171        }
172        if let Some(n) = s.strip_suffix('d') {
173            let v: i64 = n.trim().parse().ok()?;
174            let d = Self::from_days(if neg { -v } else { v }).ok()?;
175            return Some(d);
176        }
177
178        // Compound: "1h30m15s"
179        let mut total_micros: i64 = 0;
180        let mut num_buf = String::new();
181        for c in s.chars() {
182            if c.is_ascii_digit() {
183                num_buf.push(c);
184            } else {
185                let n: i64 = num_buf.parse().ok()?;
186                num_buf.clear();
187                let part = match c {
188                    'h' => n.checked_mul(3_600_000_000)?,
189                    'm' => n.checked_mul(60_000_000)?,
190                    's' => n.checked_mul(1_000_000)?,
191                    _ => return None,
192                };
193                total_micros = total_micros.checked_add(part)?;
194            }
195        }
196        // Trailing number without suffix = seconds.
197        if !num_buf.is_empty() {
198            let n: i64 = num_buf.parse().ok()?;
199            let part = n.checked_mul(1_000_000)?;
200            total_micros = total_micros.checked_add(part)?;
201        }
202
203        if total_micros == 0 {
204            return None;
205        }
206
207        Some(Self::from_micros(if neg {
208            -total_micros
209        } else {
210            total_micros
211        }))
212    }
213}
214
215impl std::fmt::Display for NdbDuration {
216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217        f.write_str(&self.to_human())
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn duration_human_format() {
227        assert_eq!(
228            NdbDuration::from_secs(90).expect("90s in range").to_human(),
229            "1m30s"
230        );
231        assert_eq!(
232            NdbDuration::from_hours(2).expect("2h in range").to_human(),
233            "2h"
234        );
235        assert_eq!(
236            NdbDuration::from_millis(500)
237                .expect("500ms in range")
238                .to_human(),
239            "500ms"
240        );
241        assert_eq!(NdbDuration::from_micros(42).to_human(), "42us");
242        assert_eq!(
243            NdbDuration::from_secs(3661)
244                .expect("3661s in range")
245                .to_human(),
246            "1h1m1s"
247        );
248    }
249
250    #[test]
251    fn duration_parse() {
252        assert_eq!(NdbDuration::parse("30s").unwrap().micros, 30_000_000);
253        assert_eq!(NdbDuration::parse("1h30m").unwrap().micros, 5_400_000_000);
254        assert_eq!(NdbDuration::parse("500ms").unwrap().micros, 500_000);
255        assert_eq!(NdbDuration::parse("2d").unwrap().micros, 172_800_000_000);
256        assert_eq!(NdbDuration::parse("-5s").unwrap().micros, -5_000_000);
257    }
258
259    #[test]
260    fn duration_roundtrip() {
261        let d = NdbDuration::from_secs(3661).expect("3661s in range");
262        let s = d.to_human();
263        let parsed = NdbDuration::parse(&s).unwrap();
264        assert_eq!(d.micros, parsed.micros);
265    }
266
267    #[test]
268    fn duration_from_millis_overflow() {
269        assert!(NdbDuration::from_millis(i64::MAX).is_err());
270    }
271
272    #[test]
273    fn duration_from_secs_overflow() {
274        assert!(NdbDuration::from_secs(i64::MAX).is_err());
275    }
276
277    #[test]
278    fn duration_from_minutes_overflow() {
279        assert!(NdbDuration::from_minutes(i64::MAX).is_err());
280    }
281
282    #[test]
283    fn duration_from_hours_overflow() {
284        assert!(NdbDuration::from_hours(i64::MAX).is_err());
285    }
286
287    #[test]
288    fn duration_from_days_overflow() {
289        assert!(NdbDuration::from_days(i64::MAX).is_err());
290    }
291
292    #[test]
293    fn duration_parse_overflow() {
294        // Enough hours to overflow: i64::MAX / 3_600_000_000 ≈ 2_562_047_788
295        let overflow_str = "9999999999999999h";
296        assert!(NdbDuration::parse(overflow_str).is_none());
297    }
298}