Skip to main content

doing_time/
duration.rs

1use std::sync::LazyLock;
2
3use chrono::Duration;
4use doing_error::{Error, Result};
5use regex::Regex;
6
7static RE_CLOCK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d+):(\d{2})(?::(\d{2}))?$").unwrap());
8static RE_COMPACT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(?:(\d+)d)? *(?:(\d+)h)? *(?:(\d+)m)?$").unwrap());
9static RE_DECIMAL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d+(?:\.\d+)?)\s*([dhm])$").unwrap());
10static RE_NATURAL: LazyLock<Regex> =
11  LazyLock::new(|| Regex::new(r"(\d+)\s*(days?|hours?|hrs?|minutes?|mins?|seconds?|secs?)").unwrap());
12static RE_PLAIN_NUMBER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\d+)$").unwrap());
13
14/// Parse a duration string into a `chrono::Duration`.
15///
16/// Supports compact (`1h30m`, `2h`, `45m`, `1d2h30m`), decimal (`1.5h`, `2.5d`),
17/// natural language (`1 hour 30 minutes`, `90 minutes`), clock format (`HH:MM:SS`,
18/// `HH:MM`), and plain numbers interpreted as minutes (`90`).
19pub fn parse_duration(input: &str) -> Result<Duration> {
20  let input = input.trim().to_lowercase();
21
22  if input.is_empty() {
23    return Err(Error::InvalidTimeExpression("empty duration input".into()));
24  }
25
26  if let Some(d) = try_clock_format(&input) {
27    return Ok(d);
28  }
29
30  if let Some(d) = try_compact_format(&input) {
31    return Ok(d);
32  }
33
34  if let Some(d) = try_natural_format(&input) {
35    return Ok(d);
36  }
37
38  if let Some(d) = try_decimal_format(&input) {
39    return Ok(d);
40  }
41
42  if let Some(d) = try_plain_number(&input) {
43    return Ok(d);
44  }
45
46  Err(Error::InvalidTimeExpression(format!("invalid duration: {input:?}")))
47}
48
49/// Parse `HH:MM:SS` or `HH:MM` clock format.
50fn try_clock_format(input: &str) -> Option<Duration> {
51  let caps = RE_CLOCK.captures(input)?;
52
53  let hours: i64 = caps[1].parse().ok()?;
54  let minutes: i64 = caps[2].parse().ok()?;
55  let seconds: i64 = caps.get(3).map_or(0, |m| m.as_str().parse().unwrap_or(0));
56
57  if minutes > 59 || seconds > 59 {
58    return None;
59  }
60
61  Some(Duration::seconds(hours * 3600 + minutes * 60 + seconds))
62}
63
64/// Parse compact duration: `1d2h30m`, `2h`, `45m`, `1h30m`.
65fn try_compact_format(input: &str) -> Option<Duration> {
66  let caps = RE_COMPACT.captures(input)?;
67
68  let days: i64 = caps.get(1).map_or(0, |m| m.as_str().parse().unwrap_or(0));
69  let hours: i64 = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0));
70  let minutes: i64 = caps.get(3).map_or(0, |m| m.as_str().parse().unwrap_or(0));
71
72  if days == 0 && hours == 0 && minutes == 0 {
73    return None;
74  }
75
76  Some(Duration::seconds(days * 86400 + hours * 3600 + minutes * 60))
77}
78
79/// Parse decimal duration: `1.5h`, `2.5d`, `0.5m`.
80fn try_decimal_format(input: &str) -> Option<Duration> {
81  let caps = RE_DECIMAL.captures(input)?;
82
83  let amount: f64 = caps[1].parse().ok()?;
84  let unit = &caps[2];
85
86  let seconds = match unit {
87    "d" => amount * 86400.0,
88    "h" => amount * 3600.0,
89    "m" => amount * 60.0,
90    _ => return None,
91  };
92
93  Some(Duration::seconds(seconds as i64))
94}
95
96/// Parse natural language duration: `1 hour 30 minutes`, `2 days`, `90 minutes`.
97fn try_natural_format(input: &str) -> Option<Duration> {
98  let mut total_seconds: i64 = 0;
99  let mut matched = false;
100
101  for caps in RE_NATURAL.captures_iter(input) {
102    matched = true;
103    let amount: i64 = caps[1].parse().ok()?;
104    let unit = &caps[2];
105
106    total_seconds += match unit {
107      u if u.starts_with("day") => amount * 86400,
108      u if u.starts_with('h') => amount * 3600,
109      u if u.starts_with("mi") => amount * 60,
110      u if u.starts_with('s') => amount,
111      _ => return None,
112    };
113  }
114
115  if matched {
116    Some(Duration::seconds(total_seconds))
117  } else {
118    None
119  }
120}
121
122/// Parse a plain number as minutes.
123fn try_plain_number(input: &str) -> Option<Duration> {
124  let caps = RE_PLAIN_NUMBER.captures(input)?;
125
126  let minutes: i64 = caps[1].parse().ok()?;
127  Some(Duration::minutes(minutes))
128}
129
130#[cfg(test)]
131mod test {
132  use super::*;
133
134  mod parse_duration {
135    use pretty_assertions::assert_eq;
136
137    use super::*;
138
139    #[test]
140    fn it_parses_clock_format_hh_mm() {
141      let result = parse_duration("1:30").unwrap();
142
143      assert_eq!(result, Duration::seconds(5400));
144    }
145
146    #[test]
147    fn it_parses_clock_format_hh_mm_ss() {
148      let result = parse_duration("1:30:45").unwrap();
149
150      assert_eq!(result, Duration::seconds(5445));
151    }
152
153    #[test]
154    fn it_parses_compact_days_hours_minutes() {
155      let result = parse_duration("1d2h30m").unwrap();
156
157      assert_eq!(result, Duration::seconds(86400 + 7200 + 1800));
158    }
159
160    #[test]
161    fn it_parses_compact_hours_only() {
162      let result = parse_duration("2h").unwrap();
163
164      assert_eq!(result, Duration::hours(2));
165    }
166
167    #[test]
168    fn it_parses_compact_hours_minutes() {
169      let result = parse_duration("1h30m").unwrap();
170
171      assert_eq!(result, Duration::seconds(5400));
172    }
173
174    #[test]
175    fn it_parses_compact_minutes_only() {
176      let result = parse_duration("45m").unwrap();
177
178      assert_eq!(result, Duration::minutes(45));
179    }
180
181    #[test]
182    fn it_parses_decimal_days() {
183      let result = parse_duration("2.5d").unwrap();
184
185      assert_eq!(result, Duration::seconds(216000));
186    }
187
188    #[test]
189    fn it_parses_decimal_hours() {
190      let result = parse_duration("1.5h").unwrap();
191
192      assert_eq!(result, Duration::seconds(5400));
193    }
194
195    #[test]
196    fn it_parses_natural_combined() {
197      let result = parse_duration("1 hour 30 minutes").unwrap();
198
199      assert_eq!(result, Duration::seconds(5400));
200    }
201
202    #[test]
203    fn it_parses_natural_days() {
204      let result = parse_duration("2 days").unwrap();
205
206      assert_eq!(result, Duration::days(2));
207    }
208
209    #[test]
210    fn it_parses_natural_hours() {
211      let result = parse_duration("2 hours").unwrap();
212
213      assert_eq!(result, Duration::hours(2));
214    }
215
216    #[test]
217    fn it_parses_natural_minutes() {
218      let result = parse_duration("90 minutes").unwrap();
219
220      assert_eq!(result, Duration::minutes(90));
221    }
222
223    #[test]
224    fn it_parses_natural_with_abbreviations() {
225      let result = parse_duration("2 hrs 15 mins").unwrap();
226
227      assert_eq!(result, Duration::seconds(8100));
228    }
229
230    #[test]
231    fn it_parses_plain_number_as_minutes() {
232      let result = parse_duration("90").unwrap();
233
234      assert_eq!(result, Duration::minutes(90));
235    }
236
237    #[test]
238    fn it_rejects_empty_input() {
239      let err = parse_duration("").unwrap_err();
240
241      assert!(matches!(err, Error::InvalidTimeExpression(_)));
242    }
243
244    #[test]
245    fn it_rejects_invalid_input() {
246      let err = parse_duration("not a duration").unwrap_err();
247
248      assert!(matches!(err, Error::InvalidTimeExpression(_)));
249    }
250
251    #[test]
252    fn it_trims_whitespace() {
253      let result = parse_duration("  2h  ").unwrap();
254
255      assert_eq!(result, Duration::hours(2));
256    }
257  }
258}