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 Some(Duration::seconds(hours * 3600 + minutes * 60 + seconds))
62}
63
64fn 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
79fn 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
96fn 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
122fn 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}