1use chrono::{DateTime, Duration, NaiveDate, Utc};
2
3#[derive(Debug, Clone)]
4pub enum ParsedTime {
5 DateTime(DateTime<Utc>),
6 DateOnly(NaiveDate),
7}
8
9pub fn parse_time(input: &str) -> Result<ParsedTime, String> {
10 let trimmed = input.trim();
11 if trimmed.is_empty() {
12 return Err("empty time expression".to_string());
13 }
14 let lower = trimmed.to_lowercase();
15
16 match lower.as_str() {
18 "now" => return Ok(ParsedTime::DateTime(Utc::now())),
19 "today" => {
20 let today = chrono::Local::now().date_naive();
21 return Ok(ParsedTime::DateOnly(today));
22 }
23 "tomorrow" => {
24 let tomorrow = chrono::Local::now().date_naive() + Duration::days(1);
25 return Ok(ParsedTime::DateOnly(tomorrow));
26 }
27 "yesterday" => {
28 let yesterday = chrono::Local::now().date_naive() - Duration::days(1);
29 return Ok(ParsedTime::DateOnly(yesterday));
30 }
31 "next week" => {
32 let next_week = chrono::Local::now().date_naive() + Duration::weeks(1);
33 return Ok(ParsedTime::DateOnly(next_week));
34 }
35 "last week" => {
36 let last_week = chrono::Local::now().date_naive() - Duration::weeks(1);
37 return Ok(ParsedTime::DateOnly(last_week));
38 }
39 _ => {}
40 }
41
42 if let Some(dt) = parse_relative_past(&lower) {
44 return Ok(ParsedTime::DateTime(dt));
45 }
46
47 if let Some(dt) = parse_relative_future(&lower) {
49 return Ok(ParsedTime::DateTime(dt));
50 }
51
52 if let Ok(dt) = trimmed.parse::<DateTime<Utc>>() {
54 return Ok(ParsedTime::DateTime(dt));
55 }
56
57 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
59 return Ok(ParsedTime::DateTime(dt.with_timezone(&Utc)));
60 }
61
62 if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
64 return Ok(ParsedTime::DateOnly(d));
65 }
66
67 if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%m/%d/%Y") {
69 return Ok(ParsedTime::DateOnly(d));
70 }
71
72 Err(format!("unrecognized time expression: {:?}", input))
73}
74
75fn parse_relative_past(lower: &str) -> Option<DateTime<Utc>> {
77 let lower = lower.trim();
78 let rest = lower.strip_suffix(" ago")?;
79 let (n, unit) = split_n_unit(rest)?;
80 let now = Utc::now();
81 let dt = apply_offset(now, -n, unit)?;
82 Some(dt)
83}
84
85fn parse_relative_future(lower: &str) -> Option<DateTime<Utc>> {
87 let lower = lower.trim();
88 let rest = lower.strip_prefix("in ")?;
89 let (n, unit) = split_n_unit(rest)?;
90 let now = Utc::now();
91 let dt = apply_offset(now, n, unit)?;
92 Some(dt)
93}
94
95fn split_n_unit(s: &str) -> Option<(i64, &str)> {
97 let s = s.trim();
98 let (num_str, unit) = s.split_once(' ')?;
99 let n: i64 = num_str.trim().parse().ok()?;
100 Some((n, unit.trim()))
101}
102
103fn apply_offset(base: DateTime<Utc>, amount: i64, unit: &str) -> Option<DateTime<Utc>> {
105 let unit = unit.trim_end_matches('s'); let dt = match unit {
107 "second" => base + Duration::seconds(amount),
108 "minute" => base + Duration::minutes(amount),
109 "hour" => base + Duration::hours(amount),
110 "day" => base + Duration::days(amount),
111 "week" => base + Duration::weeks(amount),
112 "month" => {
113 base + Duration::days(amount * 30)
115 }
116 "year" => base + Duration::days(amount * 365),
117 _ => return None,
118 };
119 Some(dt)
120}
121
122#[cfg(test)]
125mod tests {
126 use super::*;
127 use chrono::{Datelike, Local, Timelike};
128
129 fn today() -> NaiveDate {
130 Local::now().date_naive()
131 }
132
133 #[test]
135 fn test_parse_now() {
136 let before = Utc::now();
137 let result = parse_time("now").unwrap();
138 let after = Utc::now();
139 match result {
140 ParsedTime::DateTime(dt) => {
141 assert!(dt >= before, "dt should be >= before");
142 assert!(dt <= after, "dt should be <= after");
143 }
144 _ => panic!("expected DateTime variant"),
145 }
146 }
147
148 #[test]
150 fn test_parse_today() {
151 let result = parse_time("today").unwrap();
152 match result {
153 ParsedTime::DateOnly(d) => assert_eq!(d, today()),
154 _ => panic!("expected DateOnly variant"),
155 }
156 }
157
158 #[test]
160 fn test_parse_tomorrow() {
161 let result = parse_time("tomorrow").unwrap();
162 match result {
163 ParsedTime::DateOnly(d) => assert_eq!(d, today() + Duration::days(1)),
164 _ => panic!("expected DateOnly variant"),
165 }
166 }
167
168 #[test]
170 fn test_parse_yesterday() {
171 let result = parse_time("yesterday").unwrap();
172 match result {
173 ParsedTime::DateOnly(d) => assert_eq!(d, today() - Duration::days(1)),
174 _ => panic!("expected DateOnly variant"),
175 }
176 }
177
178 #[test]
180 fn test_parse_next_week() {
181 let result = parse_time("next week").unwrap();
182 match result {
183 ParsedTime::DateOnly(d) => assert_eq!(d, today() + Duration::weeks(1)),
184 _ => panic!("expected DateOnly variant"),
185 }
186 }
187
188 #[test]
190 fn test_parse_last_week() {
191 let result = parse_time("last week").unwrap();
192 match result {
193 ParsedTime::DateOnly(d) => assert_eq!(d, today() - Duration::weeks(1)),
194 _ => panic!("expected DateOnly variant"),
195 }
196 }
197
198 #[test]
200 fn test_parse_days_ago() {
201 let before = Utc::now();
202 let result = parse_time("3 days ago").unwrap();
203 match result {
204 ParsedTime::DateTime(dt) => {
205 let expected = before - Duration::days(3);
206 assert!(
208 (dt - expected).num_seconds().abs() <= 2,
209 "dt={dt} expected~{expected}"
210 );
211 }
212 _ => panic!("expected DateTime variant"),
213 }
214 }
215
216 #[test]
218 fn test_parse_hours_ago() {
219 let before = Utc::now();
220 let result = parse_time("2 hours ago").unwrap();
221 match result {
222 ParsedTime::DateTime(dt) => {
223 let expected = before - Duration::hours(2);
224 assert!(
225 (dt - expected).num_seconds().abs() <= 2,
226 "dt={dt} expected~{expected}"
227 );
228 }
229 _ => panic!("expected DateTime variant"),
230 }
231 }
232
233 #[test]
235 fn test_parse_in_days() {
236 let before = Utc::now();
237 let result = parse_time("in 5 days").unwrap();
238 match result {
239 ParsedTime::DateTime(dt) => {
240 let expected = before + Duration::days(5);
241 assert!(
242 (dt - expected).num_seconds().abs() <= 2,
243 "dt={dt} expected~{expected}"
244 );
245 }
246 _ => panic!("expected DateTime variant"),
247 }
248 }
249
250 #[test]
252 fn test_parse_in_hours() {
253 let before = Utc::now();
254 let result = parse_time("in 2 hours").unwrap();
255 match result {
256 ParsedTime::DateTime(dt) => {
257 let expected = before + Duration::hours(2);
258 assert!(
259 (dt - expected).num_seconds().abs() <= 2,
260 "dt={dt} expected~{expected}"
261 );
262 }
263 _ => panic!("expected DateTime variant"),
264 }
265 }
266
267 #[test]
269 fn test_parse_iso8601_date() {
270 let result = parse_time("2026-01-15").unwrap();
271 match result {
272 ParsedTime::DateOnly(d) => {
273 assert_eq!(d.year(), 2026);
274 assert_eq!(d.month(), 1);
275 assert_eq!(d.day(), 15);
276 }
277 _ => panic!("expected DateOnly variant"),
278 }
279 }
280
281 #[test]
283 fn test_parse_iso8601_datetime() {
284 let result = parse_time("2026-01-15T10:30:00Z").unwrap();
285 match result {
286 ParsedTime::DateTime(dt) => {
287 assert_eq!(dt.year(), 2026);
288 assert_eq!(dt.month(), 1);
289 assert_eq!(dt.day(), 15);
290 assert_eq!(dt.hour(), 10);
291 assert_eq!(dt.minute(), 30);
292 }
293 _ => panic!("expected DateTime variant"),
294 }
295 }
296
297 #[test]
299 fn test_parse_us_format() {
300 let result = parse_time("01/15/2026").unwrap();
301 match result {
302 ParsedTime::DateOnly(d) => {
303 assert_eq!(d.year(), 2026);
304 assert_eq!(d.month(), 1);
305 assert_eq!(d.day(), 15);
306 }
307 _ => panic!("expected DateOnly variant"),
308 }
309 }
310
311 #[test]
313 fn test_parse_empty_fails() {
314 assert!(parse_time("").is_err());
315 assert!(parse_time(" ").is_err());
316 }
317
318 #[test]
320 fn test_parse_garbage_fails() {
321 assert!(parse_time("not-a-date").is_err());
322 assert!(parse_time("foobar").is_err());
323 }
324
325 #[test]
327 fn test_parse_case_insensitive() {
328 assert!(matches!(parse_time("TODAY").unwrap(), ParsedTime::DateOnly(_)));
329 assert!(matches!(parse_time("Today").unwrap(), ParsedTime::DateOnly(_)));
330 assert!(matches!(parse_time("NOW").unwrap(), ParsedTime::DateTime(_)));
331 assert!(matches!(
332 parse_time("TOMORROW").unwrap(),
333 ParsedTime::DateOnly(_)
334 ));
335 assert!(matches!(
336 parse_time("YESTERDAY").unwrap(),
337 ParsedTime::DateOnly(_)
338 ));
339 assert!(matches!(
340 parse_time("NEXT WEEK").unwrap(),
341 ParsedTime::DateOnly(_)
342 ));
343 assert!(matches!(
344 parse_time("LAST WEEK").unwrap(),
345 ParsedTime::DateOnly(_)
346 ));
347 assert!(matches!(
348 parse_time("3 DAYS AGO").unwrap(),
349 ParsedTime::DateTime(_)
350 ));
351 assert!(matches!(
352 parse_time("IN 5 DAYS").unwrap(),
353 ParsedTime::DateTime(_)
354 ));
355 }
356
357 #[test]
359 fn test_parse_singular_forms() {
360 assert!(matches!(
361 parse_time("1 day ago").unwrap(),
362 ParsedTime::DateTime(_)
363 ));
364 assert!(matches!(
365 parse_time("1 hour ago").unwrap(),
366 ParsedTime::DateTime(_)
367 ));
368 assert!(matches!(
369 parse_time("1 minute ago").unwrap(),
370 ParsedTime::DateTime(_)
371 ));
372 assert!(matches!(
373 parse_time("1 week ago").unwrap(),
374 ParsedTime::DateTime(_)
375 ));
376 assert!(matches!(
377 parse_time("1 month ago").unwrap(),
378 ParsedTime::DateTime(_)
379 ));
380 assert!(matches!(
381 parse_time("in 1 day").unwrap(),
382 ParsedTime::DateTime(_)
383 ));
384 assert!(matches!(
385 parse_time("in 1 hour").unwrap(),
386 ParsedTime::DateTime(_)
387 ));
388 }
389}