Skip to main content

gog_core/
timeparse.rs

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    // --- named keywords ---
17    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    // --- relative: "N unit ago" ---
43    if let Some(dt) = parse_relative_past(&lower) {
44        return Ok(ParsedTime::DateTime(dt));
45    }
46
47    // --- relative: "in N unit" ---
48    if let Some(dt) = parse_relative_future(&lower) {
49        return Ok(ParsedTime::DateTime(dt));
50    }
51
52    // --- ISO 8601 datetime with timezone (RFC 3339): "2026-01-15T10:30:00Z" ---
53    if let Ok(dt) = trimmed.parse::<DateTime<Utc>>() {
54        return Ok(ParsedTime::DateTime(dt));
55    }
56
57    // Try parsing RFC3339 with offset
58    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(trimmed) {
59        return Ok(ParsedTime::DateTime(dt.with_timezone(&Utc)));
60    }
61
62    // --- Date-only: YYYY-MM-DD ---
63    if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
64        return Ok(ParsedTime::DateOnly(d));
65    }
66
67    // --- US format: MM/DD/YYYY ---
68    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
75/// Parse "N unit ago" patterns.  Returns None if the string doesn't match.
76fn 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
85/// Parse "in N unit" patterns.  Returns None if the string doesn't match.
86fn 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
95/// Split "3 days" → (3, "days")
96fn 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
103/// Apply a signed offset in the given time unit to `base`.
104fn apply_offset(base: DateTime<Utc>, amount: i64, unit: &str) -> Option<DateTime<Utc>> {
105    let unit = unit.trim_end_matches('s'); // strip plural 's'
106    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            // Approximate: 30 days per month
114            base + Duration::days(amount * 30)
115        }
116        "year" => base + Duration::days(amount * 365),
117        _ => return None,
118    };
119    Some(dt)
120}
121
122// ─────────────────────────── tests ───────────────────────────
123
124#[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    // 1. now
134    #[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    // 2. today
149    #[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    // 3. tomorrow
159    #[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    // 4. yesterday
169    #[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    // 5. next week
179    #[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    // 6. last week
189    #[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    // 7. N days ago
199    #[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                // Allow ±2 seconds for test execution time
207                assert!(
208                    (dt - expected).num_seconds().abs() <= 2,
209                    "dt={dt} expected~{expected}"
210                );
211            }
212            _ => panic!("expected DateTime variant"),
213        }
214    }
215
216    // 8. N hours ago
217    #[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    // 9. in N days
234    #[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    // 10. in N hours
251    #[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    // 11. ISO 8601 date-only
268    #[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    // 12. ISO 8601 datetime with UTC timezone
282    #[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    // 13. US format MM/DD/YYYY
298    #[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    // 14. empty string → error
312    #[test]
313    fn test_parse_empty_fails() {
314        assert!(parse_time("").is_err());
315        assert!(parse_time("   ").is_err());
316    }
317
318    // 15. garbage → error
319    #[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    // 16. case-insensitive keywords
326    #[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    // Extra: singular forms
358    #[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}