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  let total = hours
62    .checked_mul(3600)?
63    .checked_add(minutes.checked_mul(60)?)?
64    .checked_add(seconds)?;
65  Some(Duration::seconds(total))
66}
67
68/// Parse compact duration: `1d2h30m`, `2h`, `45m`, `1h30m`.
69fn try_compact_format(input: &str) -> Option<Duration> {
70  let caps = RE_COMPACT.captures(input)?;
71
72  let days: i64 = caps.get(1).map_or(0, |m| m.as_str().parse().unwrap_or(0));
73  let hours: i64 = caps.get(2).map_or(0, |m| m.as_str().parse().unwrap_or(0));
74  let minutes: i64 = caps.get(3).map_or(0, |m| m.as_str().parse().unwrap_or(0));
75
76  if days == 0 && hours == 0 && minutes == 0 {
77    return None;
78  }
79
80  let total = days
81    .checked_mul(86400)?
82    .checked_add(hours.checked_mul(3600)?)?
83    .checked_add(minutes.checked_mul(60)?)?;
84  Some(Duration::seconds(total))
85}
86
87/// Parse decimal duration: `1.5h`, `2.5d`, `0.5m`.
88fn try_decimal_format(input: &str) -> Option<Duration> {
89  let caps = RE_DECIMAL.captures(input)?;
90
91  let amount: f64 = caps[1].parse().ok()?;
92  let unit = &caps[2];
93
94  let seconds = match unit {
95    "d" => amount * 86400.0,
96    "h" => amount * 3600.0,
97    "m" => amount * 60.0,
98    _ => return None,
99  };
100
101  if !seconds.is_finite() || seconds > i64::MAX as f64 {
102    return None;
103  }
104  Some(Duration::seconds(seconds as i64))
105}
106
107/// Parse natural language duration: `1 hour 30 minutes`, `2 days`, `90 minutes`.
108fn try_natural_format(input: &str) -> Option<Duration> {
109  let mut total_seconds: i64 = 0;
110  let mut matched = false;
111
112  for caps in RE_NATURAL.captures_iter(input) {
113    matched = true;
114    let amount: i64 = caps[1].parse().ok()?;
115    let unit = &caps[2];
116
117    let unit_seconds = match unit {
118      u if u.starts_with("day") => amount.checked_mul(86400)?,
119      u if u.starts_with('h') => amount.checked_mul(3600)?,
120      u if u.starts_with("mi") => amount.checked_mul(60)?,
121      u if u.starts_with('s') => amount,
122      _ => return None,
123    };
124    total_seconds = total_seconds.checked_add(unit_seconds)?;
125  }
126
127  if matched {
128    Some(Duration::seconds(total_seconds))
129  } else {
130    None
131  }
132}
133
134/// Parse a plain number as minutes.
135fn try_plain_number(input: &str) -> Option<Duration> {
136  let caps = RE_PLAIN_NUMBER.captures(input)?;
137
138  let minutes: i64 = caps[1].parse().ok()?;
139  Some(Duration::minutes(minutes))
140}
141
142#[cfg(test)]
143mod test {
144  use super::*;
145
146  mod parse_duration {
147    use pretty_assertions::assert_eq;
148
149    use super::*;
150
151    #[test]
152    fn it_parses_clock_format_hh_mm() {
153      let result = parse_duration("1:30").unwrap();
154
155      assert_eq!(result, Duration::seconds(5400));
156    }
157
158    #[test]
159    fn it_parses_clock_format_hh_mm_ss() {
160      let result = parse_duration("1:30:45").unwrap();
161
162      assert_eq!(result, Duration::seconds(5445));
163    }
164
165    #[test]
166    fn it_parses_compact_days_hours_minutes() {
167      let result = parse_duration("1d2h30m").unwrap();
168
169      assert_eq!(result, Duration::seconds(86400 + 7200 + 1800));
170    }
171
172    #[test]
173    fn it_parses_compact_hours_only() {
174      let result = parse_duration("2h").unwrap();
175
176      assert_eq!(result, Duration::hours(2));
177    }
178
179    #[test]
180    fn it_parses_compact_hours_minutes() {
181      let result = parse_duration("1h30m").unwrap();
182
183      assert_eq!(result, Duration::seconds(5400));
184    }
185
186    #[test]
187    fn it_parses_compact_minutes_only() {
188      let result = parse_duration("45m").unwrap();
189
190      assert_eq!(result, Duration::minutes(45));
191    }
192
193    #[test]
194    fn it_parses_decimal_days() {
195      let result = parse_duration("2.5d").unwrap();
196
197      assert_eq!(result, Duration::seconds(216000));
198    }
199
200    #[test]
201    fn it_parses_decimal_hours() {
202      let result = parse_duration("1.5h").unwrap();
203
204      assert_eq!(result, Duration::seconds(5400));
205    }
206
207    #[test]
208    fn it_parses_natural_combined() {
209      let result = parse_duration("1 hour 30 minutes").unwrap();
210
211      assert_eq!(result, Duration::seconds(5400));
212    }
213
214    #[test]
215    fn it_parses_natural_days() {
216      let result = parse_duration("2 days").unwrap();
217
218      assert_eq!(result, Duration::days(2));
219    }
220
221    #[test]
222    fn it_parses_natural_hours() {
223      let result = parse_duration("2 hours").unwrap();
224
225      assert_eq!(result, Duration::hours(2));
226    }
227
228    #[test]
229    fn it_parses_natural_minutes() {
230      let result = parse_duration("90 minutes").unwrap();
231
232      assert_eq!(result, Duration::minutes(90));
233    }
234
235    #[test]
236    fn it_parses_natural_with_abbreviations() {
237      let result = parse_duration("2 hrs 15 mins").unwrap();
238
239      assert_eq!(result, Duration::seconds(8100));
240    }
241
242    #[test]
243    fn it_parses_plain_number_as_minutes() {
244      let result = parse_duration("90").unwrap();
245
246      assert_eq!(result, Duration::minutes(90));
247    }
248
249    #[test]
250    fn it_rejects_clock_format_overflow() {
251      let err = parse_duration("99999999999999999:00").unwrap_err();
252
253      assert!(matches!(err, Error::InvalidTimeExpression(_)));
254    }
255
256    #[test]
257    fn it_rejects_compact_format_overflow() {
258      let err = parse_duration("99999999999999999h").unwrap_err();
259
260      assert!(matches!(err, Error::InvalidTimeExpression(_)));
261    }
262
263    #[test]
264    fn it_rejects_decimal_format_overflow() {
265      let err = parse_duration("99999999999999999999999999999999999999.0h").unwrap_err();
266
267      assert!(matches!(err, Error::InvalidTimeExpression(_)));
268    }
269
270    #[test]
271    fn it_rejects_empty_input() {
272      let err = parse_duration("").unwrap_err();
273
274      assert!(matches!(err, Error::InvalidTimeExpression(_)));
275    }
276
277    #[test]
278    fn it_rejects_invalid_input() {
279      let err = parse_duration("not a duration").unwrap_err();
280
281      assert!(matches!(err, Error::InvalidTimeExpression(_)));
282    }
283
284    #[test]
285    fn it_trims_whitespace() {
286      let result = parse_duration("  2h  ").unwrap();
287
288      assert_eq!(result, Duration::hours(2));
289    }
290  }
291}