calendar_duration/
lib.rs

1//! A library containing a calendar respecting duration that is compatible with the `time` crate.
2//! Supports parsing and displaying to/from strings. Also supports addition and subtraction with `OffsetDateTime`.
3//!
4//! ## Time string syntax
5//! - `y` for years
6//! - `mon` for months
7//! - `w` for weeks
8//! - `d` for days
9//! - `h` for hours
10//! - `m` for minutes
11//! - `s` for seconds
12//!
13//! The string can be prefixed with `-` for negative durations.
14//!
15//! ## Examples
16//! - `1y3mon4d`
17//! - `-3w4m5s`
18
19mod ops;
20
21use std::{
22    fmt::{self, Display, Formatter},
23    ops::Neg,
24};
25
26/// A calendar respecting duration structure.
27#[derive(Debug, Copy, Clone, Default)]
28pub struct CalendarDuration {
29    pub negative: bool,
30    pub years: u16,
31    pub months: u8,
32    pub weeks: u32,
33    pub days: u32,
34    pub hours: u32,
35    pub minutes: u32,
36    pub seconds: u32,
37}
38
39impl CalendarDuration {
40    fn set_unit_value(&mut self, value_str: &str, unit_str: &str) {
41        match unit_str {
42            "y" => self.years = value_str.parse().unwrap_or(0),
43            "mon" => self.months = value_str.parse().unwrap_or(0),
44            "w" => self.weeks = value_str.parse().unwrap_or(0),
45            "d" => self.days = value_str.parse().unwrap_or(0),
46            "h" => self.hours = value_str.parse().unwrap_or(0),
47            "m" => self.minutes = value_str.parse().unwrap_or(0),
48            "s" => self.seconds = value_str.parse().unwrap_or(0),
49            _ => (),
50        }
51    }
52
53    fn actual_unit_val<T: Neg<Output = T>>(&self, value: T) -> T {
54        match self.negative {
55            true => -value,
56            false => value,
57        }
58    }
59
60    /// Returns true if the duration is zero.
61    pub fn is_zero(&self) -> bool {
62        self.years == 0
63            && self.months == 0
64            && self.weeks == 0
65            && self.days == 0
66            && self.hours == 0
67            && self.minutes == 0
68            && self.seconds == 0
69    }
70}
71
72fn format_unit_segment(segments: &mut Vec<String>, value: u32, unit: &str) {
73    if value > 0 {
74        segments.push(format!(
75            "{} {}{}",
76            value,
77            unit,
78            if value > 1 { "s" } else { "" }
79        ));
80    }
81}
82
83impl Display for CalendarDuration {
84    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
85        let mut segments = Vec::new();
86        format_unit_segment(&mut segments, self.years.into(), "year");
87        format_unit_segment(&mut segments, self.months.into(), "month");
88        format_unit_segment(&mut segments, self.weeks, "week");
89        format_unit_segment(&mut segments, self.days, "day");
90        format_unit_segment(&mut segments, self.hours, "hour");
91        format_unit_segment(&mut segments, self.minutes, "minute");
92        format_unit_segment(&mut segments, self.seconds, "second");
93        if segments.len() >= 3 {
94            // Produce a string with commas included. i.e. "1 hour, 2 minutes and 5 seconds"
95            let combined_comma_segments = segments[0..(segments.len() - 1)].join(", ");
96            write!(
97                f,
98                "{} and {}",
99                combined_comma_segments,
100                segments.last().unwrap()
101            )
102        } else {
103            // If there are two or less segments, simply join the elements with 'and'.
104            write!(f, "{}", segments.join(" and "))
105        }
106    }
107}
108
109impl From<&str> for CalendarDuration {
110    fn from(s: &str) -> Self {
111        let mut result = Self::default();
112        let mut value_str = String::new();
113        let mut unit_str = String::new();
114        for (i, ch) in s.chars().enumerate() {
115            if i == 0 && ch == '-' {
116                result.negative = true;
117            }
118            if ch.is_alphabetic() {
119                // If alphabetic, assume that we are examining the unit name.
120                unit_str.push(ch);
121            } else if ch.is_numeric() {
122                // If numeric, assume that we are examining the numerical
123                // value of the unit.
124                // If the unit string is not empty, assume that we recorded
125                // a value and unit previously which needs to be processed.
126                if !unit_str.is_empty() {
127                    result.set_unit_value(&value_str, &unit_str);
128                    value_str.clear();
129                    unit_str.clear();
130                }
131                value_str.push(ch);
132            }
133        }
134        result.set_unit_value(&value_str, &unit_str);
135        result
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::CalendarDuration;
142
143    fn assert_parse_and_display_eq(formatted: &str, displayed: &str) {
144        assert_eq!(CalendarDuration::from(formatted).to_string(), displayed);
145    }
146
147    #[test]
148    fn parse_and_display() {
149        assert_parse_and_display_eq("10s", "10 seconds");
150        assert_parse_and_display_eq("40m1s", "40 minutes and 1 second");
151        assert_parse_and_display_eq("1h20m41s", "1 hour, 20 minutes and 41 seconds");
152        assert_parse_and_display_eq("5y2mon3w6h", "5 years, 2 months, 3 weeks and 6 hours");
153    }
154
155    #[test]
156    fn parse_pos_neg() {
157        let duration = CalendarDuration::from("15s");
158        assert_eq!(duration.seconds, 15);
159        assert!(!duration.negative);
160
161        let duration = CalendarDuration::from("-10s");
162        assert_eq!(duration.seconds, 10);
163        assert!(duration.negative);
164    }
165
166    #[test]
167    fn is_zero() {
168        assert!(CalendarDuration::default().is_zero());
169        assert!(!CalendarDuration {
170            seconds: 1,
171            ..Default::default()
172        }
173        .is_zero());
174    }
175}