Skip to main content

grafeo_common/types/
duration.rs

1//! ISO 8601 duration type with separate month, day, and nanosecond components.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6const NANOS_PER_SECOND: i64 = 1_000_000_000;
7const NANOS_PER_MINUTE: i64 = 60 * NANOS_PER_SECOND;
8const NANOS_PER_HOUR: i64 = 60 * NANOS_PER_MINUTE;
9
10/// An ISO 8601 duration with separate month, day, and nanosecond components.
11///
12/// The three components are kept separate because months have variable length
13/// and cannot be converted to days without a reference date. This means
14/// durations are only partially ordered.
15///
16/// # Examples
17///
18/// ```
19/// use grafeo_common::types::Duration;
20///
21/// let d = Duration::parse("P1Y2M3DT4H5M6S").unwrap();
22/// assert_eq!(d.months(), 14); // 1 year + 2 months
23/// assert_eq!(d.days(), 3);
24/// assert_eq!(d.to_string(), "P1Y2M3DT4H5M6S");
25/// ```
26#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
27pub struct Duration {
28    /// Total months (years * 12 + months).
29    months: i64,
30    /// Days component.
31    days: i64,
32    /// Sub-day component in nanoseconds.
33    nanos: i64,
34}
35
36impl Duration {
37    /// Creates a duration from months, days, and nanoseconds.
38    #[must_use]
39    pub const fn new(months: i64, days: i64, nanos: i64) -> Self {
40        Self {
41            months,
42            days,
43            nanos,
44        }
45    }
46
47    /// Creates a duration from a number of months.
48    #[must_use]
49    pub const fn from_months(months: i64) -> Self {
50        Self {
51            months,
52            days: 0,
53            nanos: 0,
54        }
55    }
56
57    /// Creates a duration from a number of days.
58    #[must_use]
59    pub const fn from_days(days: i64) -> Self {
60        Self {
61            months: 0,
62            days,
63            nanos: 0,
64        }
65    }
66
67    /// Creates a duration from nanoseconds.
68    #[must_use]
69    pub const fn from_nanos(nanos: i64) -> Self {
70        Self {
71            months: 0,
72            days: 0,
73            nanos,
74        }
75    }
76
77    /// Creates a duration from seconds.
78    #[must_use]
79    pub const fn from_seconds(secs: i64) -> Self {
80        Self {
81            months: 0,
82            days: 0,
83            nanos: secs * NANOS_PER_SECOND,
84        }
85    }
86
87    /// Returns the months component.
88    #[must_use]
89    pub const fn months(&self) -> i64 {
90        self.months
91    }
92
93    /// Returns the days component.
94    #[must_use]
95    pub const fn days(&self) -> i64 {
96        self.days
97    }
98
99    /// Returns the nanoseconds component.
100    #[must_use]
101    pub const fn nanos(&self) -> i64 {
102        self.nanos
103    }
104
105    /// Returns true if all components are zero.
106    #[must_use]
107    pub const fn is_zero(&self) -> bool {
108        self.months == 0 && self.days == 0 && self.nanos == 0
109    }
110
111    /// Negates all components.
112    #[must_use]
113    pub const fn neg(self) -> Self {
114        Self {
115            months: -self.months,
116            days: -self.days,
117            nanos: -self.nanos,
118        }
119    }
120
121    /// Adds two durations component-wise.
122    #[must_use]
123    pub const fn add(self, other: Self) -> Self {
124        Self {
125            months: self.months + other.months,
126            days: self.days + other.days,
127            nanos: self.nanos + other.nanos,
128        }
129    }
130
131    /// Subtracts another duration component-wise.
132    #[must_use]
133    pub const fn sub(self, other: Self) -> Self {
134        Self {
135            months: self.months - other.months,
136            days: self.days - other.days,
137            nanos: self.nanos - other.nanos,
138        }
139    }
140
141    /// Multiplies all components by a factor.
142    #[must_use]
143    pub const fn mul(self, factor: i64) -> Self {
144        Self {
145            months: self.months * factor,
146            days: self.days * factor,
147            nanos: self.nanos * factor,
148        }
149    }
150
151    /// Divides all components by a divisor (truncating).
152    #[must_use]
153    pub const fn div(self, divisor: i64) -> Self {
154        Self {
155            months: self.months / divisor,
156            days: self.days / divisor,
157            nanos: self.nanos / divisor,
158        }
159    }
160
161    /// Parses a duration from ISO 8601 format "OnYnMnDTnHnMnS".
162    ///
163    /// Examples: "P1Y", "P2M3D", "PT4H5M6S", "P1Y2M3DT4H5M6S", "PT0.5S"
164    #[must_use]
165    pub fn parse(s: &str) -> Option<Self> {
166        let (negative, s) = if let Some(rest) = s.strip_prefix('-') {
167            (true, rest)
168        } else {
169            (false, s)
170        };
171
172        let s = s.strip_prefix('P')?;
173        let mut months: i64 = 0;
174        let mut days: i64 = 0;
175        let mut nanos: i64 = 0;
176        let mut in_time = false;
177        let mut num_start = 0;
178        let mut has_content = false;
179
180        let bytes = s.as_bytes();
181        let mut i = 0;
182        while i < bytes.len() {
183            match bytes[i] {
184                b'T' => {
185                    in_time = true;
186                    i += 1;
187                    num_start = i;
188                }
189                b'Y' if !in_time => {
190                    let n: i64 = s[num_start..i].parse().ok()?;
191                    months += n * 12;
192                    has_content = true;
193                    i += 1;
194                    num_start = i;
195                }
196                b'M' if !in_time => {
197                    let n: i64 = s[num_start..i].parse().ok()?;
198                    months += n;
199                    has_content = true;
200                    i += 1;
201                    num_start = i;
202                }
203                b'W' if !in_time => {
204                    let n: i64 = s[num_start..i].parse().ok()?;
205                    days += n * 7;
206                    has_content = true;
207                    i += 1;
208                    num_start = i;
209                }
210                b'D' if !in_time => {
211                    let n: i64 = s[num_start..i].parse().ok()?;
212                    days += n;
213                    has_content = true;
214                    i += 1;
215                    num_start = i;
216                }
217                b'H' if in_time => {
218                    let n: i64 = s[num_start..i].parse().ok()?;
219                    nanos += n * NANOS_PER_HOUR;
220                    has_content = true;
221                    i += 1;
222                    num_start = i;
223                }
224                b'M' if in_time => {
225                    let n: i64 = s[num_start..i].parse().ok()?;
226                    nanos += n * NANOS_PER_MINUTE;
227                    has_content = true;
228                    i += 1;
229                    num_start = i;
230                }
231                b'S' if in_time => {
232                    let text = &s[num_start..i];
233                    if let Some(dot_pos) = text.find('.') {
234                        let int_part: i64 = text[..dot_pos].parse().ok()?;
235                        let frac_str = &text[dot_pos + 1..];
236                        let frac_len = frac_str.len().min(9);
237                        let frac: i64 = frac_str[..frac_len].parse().ok()?;
238                        let scale = 10i64.pow(9 - frac_len as u32);
239                        nanos += int_part * NANOS_PER_SECOND + frac * scale;
240                    } else {
241                        let n: i64 = text.parse().ok()?;
242                        nanos += n * NANOS_PER_SECOND;
243                    }
244                    has_content = true;
245                    i += 1;
246                    num_start = i;
247                }
248                _ => {
249                    i += 1;
250                }
251            }
252        }
253
254        if !has_content {
255            return None;
256        }
257
258        let dur = Self {
259            months,
260            days,
261            nanos,
262        };
263        Some(if negative { dur.neg() } else { dur })
264    }
265}
266
267// Duration is deliberately NOT Ord (only PartialOrd).
268// Comparing "P1M" vs "P30D" is ambiguous without a reference date.
269impl PartialOrd for Duration {
270    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
271        // Only comparable when all components are individually comparable
272        // and the relationship is consistent across components.
273        let m = self.months.cmp(&other.months);
274        let d = self.days.cmp(&other.days);
275        let n = self.nanos.cmp(&other.nanos);
276
277        if m == d && d == n {
278            Some(m)
279        } else if (m == std::cmp::Ordering::Equal || m == d)
280            && (d == std::cmp::Ordering::Equal || d == n)
281            && (m == std::cmp::Ordering::Equal || m == n)
282        {
283            // All non-equal components agree on direction
284            if m != std::cmp::Ordering::Equal {
285                Some(m)
286            } else if d != std::cmp::Ordering::Equal {
287                Some(d)
288            } else {
289                Some(n)
290            }
291        } else {
292            None // Incomparable
293        }
294    }
295}
296
297impl fmt::Debug for Duration {
298    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
299        write!(f, "Duration({})", self)
300    }
301}
302
303impl fmt::Display for Duration {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        let (neg, months, days, nanos) = if self.months < 0 || self.days < 0 || self.nanos < 0 {
306            // If any component is negative, display as negative duration
307            if self.months <= 0 && self.days <= 0 && self.nanos <= 0 {
308                (true, -self.months, -self.days, -self.nanos)
309            } else {
310                // Mixed signs: display as-is (unusual but valid)
311                (false, self.months, self.days, self.nanos)
312            }
313        } else {
314            (false, self.months, self.days, self.nanos)
315        };
316
317        if neg {
318            write!(f, "-")?;
319        }
320        write!(f, "P")?;
321
322        let years = months / 12;
323        let m = months % 12;
324
325        if years != 0 {
326            write!(f, "{years}Y")?;
327        }
328        if m != 0 {
329            write!(f, "{m}M")?;
330        }
331        if days != 0 {
332            write!(f, "{days}D")?;
333        }
334
335        // Time part
336        let hours = nanos / NANOS_PER_HOUR;
337        let remaining = nanos % NANOS_PER_HOUR;
338        let minutes = remaining / NANOS_PER_MINUTE;
339        let remaining = remaining % NANOS_PER_MINUTE;
340        let secs = remaining / NANOS_PER_SECOND;
341        let sub_nanos = remaining % NANOS_PER_SECOND;
342
343        if hours != 0 || minutes != 0 || secs != 0 || sub_nanos != 0 {
344            write!(f, "T")?;
345            if hours != 0 {
346                write!(f, "{hours}H")?;
347            }
348            if minutes != 0 {
349                write!(f, "{minutes}M")?;
350            }
351            if secs != 0 || sub_nanos != 0 {
352                if sub_nanos != 0 {
353                    let frac = format!("{:09}", sub_nanos);
354                    let trimmed = frac.trim_end_matches('0');
355                    write!(f, "{secs}.{trimmed}S")?;
356                } else {
357                    write!(f, "{secs}S")?;
358                }
359            }
360        }
361
362        // Handle zero duration
363        if !neg && years == 0 && m == 0 && days == 0 && nanos == 0 {
364            write!(f, "T0S")?;
365        }
366
367        Ok(())
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_constructors() {
377        let d = Duration::new(14, 3, 0);
378        assert_eq!(d.months(), 14);
379        assert_eq!(d.days(), 3);
380        assert_eq!(d.nanos(), 0);
381
382        assert_eq!(Duration::from_months(6).months(), 6);
383        assert_eq!(Duration::from_days(10).days(), 10);
384        assert_eq!(Duration::from_nanos(1_000_000_000).nanos(), 1_000_000_000);
385    }
386
387    #[test]
388    fn test_parse_full() {
389        let d = Duration::parse("P1Y2M3DT4H5M6S").unwrap();
390        assert_eq!(d.months(), 14); // 12 + 2
391        assert_eq!(d.days(), 3);
392        assert_eq!(
393            d.nanos(),
394            4 * NANOS_PER_HOUR + 5 * NANOS_PER_MINUTE + 6 * NANOS_PER_SECOND
395        );
396    }
397
398    #[test]
399    fn test_parse_partial() {
400        let d = Duration::parse("P1Y").unwrap();
401        assert_eq!(d.months(), 12);
402        assert_eq!(d.days(), 0);
403
404        let d = Duration::parse("PT30S").unwrap();
405        assert_eq!(d.months(), 0);
406        assert_eq!(d.nanos(), 30 * NANOS_PER_SECOND);
407
408        let d = Duration::parse("P2W").unwrap();
409        assert_eq!(d.days(), 14);
410    }
411
412    #[test]
413    fn test_parse_fractional_seconds() {
414        let d = Duration::parse("PT0.5S").unwrap();
415        assert_eq!(d.nanos(), 500_000_000);
416
417        let d = Duration::parse("PT1.123S").unwrap();
418        assert_eq!(d.nanos(), 1_123_000_000);
419    }
420
421    #[test]
422    fn test_parse_negative() {
423        let d = Duration::parse("-P1Y").unwrap();
424        assert_eq!(d.months(), -12);
425    }
426
427    #[test]
428    fn test_parse_invalid() {
429        assert!(Duration::parse("").is_none());
430        assert!(Duration::parse("P").is_none());
431        assert!(Duration::parse("not-a-duration").is_none());
432    }
433
434    #[test]
435    fn test_display_roundtrip() {
436        let cases = ["P1Y2M3DT4H5M6S", "P1Y", "P3D", "PT30S", "PT0.5S", "P2W"];
437        for case in cases {
438            let d = Duration::parse(case).unwrap();
439            let reparsed = Duration::parse(&d.to_string()).unwrap();
440            assert_eq!(d, reparsed, "roundtrip failed for {case}");
441        }
442    }
443
444    #[test]
445    fn test_arithmetic() {
446        let a = Duration::new(1, 2, 3);
447        let b = Duration::new(4, 5, 6);
448        assert_eq!(a.add(b), Duration::new(5, 7, 9));
449        assert_eq!(a.sub(b), Duration::new(-3, -3, -3));
450        assert_eq!(a.mul(3), Duration::new(3, 6, 9));
451        assert_eq!(a.neg(), Duration::new(-1, -2, -3));
452    }
453
454    #[test]
455    fn test_partial_ord() {
456        let a = Duration::new(1, 2, 3);
457        let b = Duration::new(2, 3, 4);
458        assert!(a < b);
459
460        // Incomparable: more months but fewer days
461        let c = Duration::new(2, 1, 0);
462        let d = Duration::new(1, 100, 0);
463        assert_eq!(c.partial_cmp(&d), None);
464    }
465
466    #[test]
467    fn test_zero() {
468        let z = Duration::default();
469        assert!(z.is_zero());
470        assert_eq!(z.to_string(), "PT0S");
471    }
472}