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
14pub 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
49fn 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
68fn 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
87fn 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
107fn 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
134fn 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}