Skip to main content

stint_core/
dateparse.rs

1//! Relative and absolute date parsing.
2
3use time::format_description::FormatItem;
4use time::macros::format_description;
5use time::{Duration, OffsetDateTime, Weekday};
6
7/// ISO date format: YYYY-MM-DD.
8const ISO_DATE_FMT: &[FormatItem<'static>] = format_description!("[year]-[month]-[day]");
9
10/// Parses a date string into an `OffsetDateTime` at midnight UTC.
11///
12/// Supported formats:
13/// - ISO date: "2026-01-15"
14/// - Relative: "today", "yesterday"
15/// - Day reference: "last monday", "last tuesday", etc.
16///
17/// The `now` parameter is injected for testability.
18pub fn parse_date(input: &str, now: OffsetDateTime) -> Result<OffsetDateTime, String> {
19    let input = input.trim().to_lowercase();
20
21    match input.as_str() {
22        "today" => Ok(midnight(now)),
23        "yesterday" => Ok(midnight(now) - Duration::days(1)),
24        _ => {
25            // Try "last <weekday>"
26            if let Some(day_str) = input.strip_prefix("last ") {
27                let target = parse_weekday(day_str.trim())?;
28                return Ok(last_weekday(now, target));
29            }
30
31            // Try ISO date
32            let date = time::Date::parse(&input, ISO_DATE_FMT)
33                .map_err(|_| format!("unrecognized date format: '{input}'"))?;
34            Ok(date.midnight().assume_utc())
35        }
36    }
37}
38
39/// Returns the given datetime truncated to midnight UTC.
40fn midnight(dt: OffsetDateTime) -> OffsetDateTime {
41    dt.date().midnight().assume_utc()
42}
43
44/// Parses a weekday name string.
45fn parse_weekday(s: &str) -> Result<Weekday, String> {
46    match s {
47        "monday" | "mon" => Ok(Weekday::Monday),
48        "tuesday" | "tue" | "tues" => Ok(Weekday::Tuesday),
49        "wednesday" | "wed" => Ok(Weekday::Wednesday),
50        "thursday" | "thu" | "thurs" => Ok(Weekday::Thursday),
51        "friday" | "fri" => Ok(Weekday::Friday),
52        "saturday" | "sat" => Ok(Weekday::Saturday),
53        "sunday" | "sun" => Ok(Weekday::Sunday),
54        _ => Err(format!("unknown day: '{s}'")),
55    }
56}
57
58/// Returns midnight of the most recent occurrence of the given weekday before `now`.
59///
60/// If today is the target weekday, returns 7 days ago (always goes back).
61fn last_weekday(now: OffsetDateTime, target: Weekday) -> OffsetDateTime {
62    let today = now.weekday();
63    let days_back = match (today.number_days_from_monday() as i64)
64        - (target.number_days_from_monday() as i64)
65    {
66        diff if diff > 0 => diff,
67        diff if diff <= 0 => diff + 7,
68        _ => unreachable!(),
69    };
70    midnight(now) - Duration::days(days_back)
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use time::macros::datetime;
77
78    // Wednesday, 2026-03-11 14:30:00 UTC
79    const NOW: OffsetDateTime = datetime!(2026-03-11 14:30:00 UTC);
80
81    #[test]
82    fn parse_today() {
83        let result = parse_date("today", NOW).unwrap();
84        assert_eq!(result, datetime!(2026-03-11 0:00 UTC));
85    }
86
87    #[test]
88    fn parse_yesterday() {
89        let result = parse_date("yesterday", NOW).unwrap();
90        assert_eq!(result, datetime!(2026-03-10 0:00 UTC));
91    }
92
93    #[test]
94    fn parse_iso_date() {
95        let result = parse_date("2026-01-15", NOW).unwrap();
96        assert_eq!(result, datetime!(2026-01-15 0:00 UTC));
97    }
98
99    #[test]
100    fn parse_last_monday() {
101        // NOW is Wednesday, so last Monday is 2 days ago
102        let result = parse_date("last monday", NOW).unwrap();
103        assert_eq!(result, datetime!(2026-03-09 0:00 UTC));
104    }
105
106    #[test]
107    fn parse_last_friday() {
108        // NOW is Wednesday, so last Friday is 5 days ago
109        let result = parse_date("last friday", NOW).unwrap();
110        assert_eq!(result, datetime!(2026-03-06 0:00 UTC));
111    }
112
113    #[test]
114    fn parse_last_wednesday_goes_back_7() {
115        // NOW is Wednesday, so "last wednesday" = 7 days ago
116        let result = parse_date("last wednesday", NOW).unwrap();
117        assert_eq!(result, datetime!(2026-03-04 0:00 UTC));
118    }
119
120    #[test]
121    fn parse_abbreviated_day() {
122        let result = parse_date("last mon", NOW).unwrap();
123        assert_eq!(result, datetime!(2026-03-09 0:00 UTC));
124    }
125
126    #[test]
127    fn parse_case_insensitive() {
128        let result = parse_date("Yesterday", NOW).unwrap();
129        assert_eq!(result, datetime!(2026-03-10 0:00 UTC));
130    }
131
132    #[test]
133    fn parse_with_whitespace() {
134        let result = parse_date("  today  ", NOW).unwrap();
135        assert_eq!(result, datetime!(2026-03-11 0:00 UTC));
136    }
137
138    #[test]
139    fn parse_invalid_errors() {
140        assert!(parse_date("not-a-date", NOW).is_err());
141    }
142
143    #[test]
144    fn parse_unknown_day_errors() {
145        assert!(parse_date("last blorpday", NOW).is_err());
146    }
147}