reminder_cli/
time_parser.rs

1use anyhow::{bail, Result};
2use chrono::{DateTime, Datelike, Duration, Local, NaiveDateTime, NaiveTime, Weekday};
3use regex::Regex;
4
5/// Parse time string supporting multiple formats:
6/// - Absolute: "2025-12-25 10:00"
7/// - Relative: "30m", "2h", "1d", "1w"
8/// - Natural: "tomorrow 9am", "next monday 14:00", "today 18:30"
9pub fn parse_time(input: &str) -> Result<DateTime<Local>> {
10    let input = input.trim().to_lowercase();
11
12    // Try absolute format first
13    if let Ok(dt) = parse_absolute(&input) {
14        return Ok(dt);
15    }
16
17    // Try relative format
18    if let Ok(dt) = parse_relative(&input) {
19        return Ok(dt);
20    }
21
22    // Try natural language
23    if let Ok(dt) = parse_natural(&input) {
24        return Ok(dt);
25    }
26
27    bail!(
28        "Invalid time format: {}\n\
29        Supported formats:\n\
30        - Absolute: \"2025-12-25 10:00\"\n\
31        - Relative: \"30m\", \"2h\", \"1d\", \"1w\"\n\
32        - Natural: \"tomorrow 9am\", \"next monday 14:00\"",
33        input
34    )
35}
36
37fn parse_absolute(input: &str) -> Result<DateTime<Local>> {
38    let naive = NaiveDateTime::parse_from_str(input, "%Y-%m-%d %H:%M")?;
39    naive
40        .and_local_timezone(Local)
41        .single()
42        .ok_or_else(|| anyhow::anyhow!("Invalid local time"))
43}
44
45fn parse_relative(input: &str) -> Result<DateTime<Local>> {
46    let re = Regex::new(
47        r"^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks)$",
48    )?;
49
50    if let Some(caps) = re.captures(input) {
51        let amount: i64 = caps[1].parse()?;
52        let unit = &caps[2];
53
54        let duration = match unit {
55            "m" | "min" | "mins" | "minute" | "minutes" => Duration::minutes(amount),
56            "h" | "hr" | "hrs" | "hour" | "hours" => Duration::hours(amount),
57            "d" | "day" | "days" => Duration::days(amount),
58            "w" | "week" | "weeks" => Duration::weeks(amount),
59            _ => bail!("Unknown time unit: {}", unit),
60        };
61
62        return Ok(Local::now() + duration);
63    }
64
65    bail!("Not a relative time format")
66}
67
68fn parse_natural(input: &str) -> Result<DateTime<Local>> {
69    let now = Local::now();
70    let today = now.date_naive();
71
72    // Parse time part (e.g., "9am", "14:00", "9:30pm")
73    let time_re = Regex::new(r"(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$")?;
74
75    let (date_part, time_part) = if let Some(pos) = input.rfind(char::is_whitespace) {
76        let (d, t) = input.split_at(pos);
77        (d.trim(), t.trim())
78    } else {
79        // Could be just a day reference without time
80        (input, "")
81    };
82
83    // Parse the date part
84    let target_date = match date_part {
85        "today" => today,
86        "tomorrow" => today + Duration::days(1),
87        "yesterday" => today - Duration::days(1),
88        s if s.starts_with("next ") => {
89            let day_name = &s[5..];
90            let target_weekday = parse_weekday(day_name)?;
91            next_weekday(today, target_weekday)
92        }
93        s if s.starts_with("this ") => {
94            let day_name = &s[5..];
95            let target_weekday = parse_weekday(day_name)?;
96            this_weekday(today, target_weekday)
97        }
98        _ => {
99            // Try parsing as weekday directly
100            if let Ok(weekday) = parse_weekday(date_part) {
101                next_weekday(today, weekday)
102            } else {
103                bail!("Unknown date reference: {}", date_part)
104            }
105        }
106    };
107
108    // Parse the time part
109    let target_time = if time_part.is_empty() {
110        NaiveTime::from_hms_opt(9, 0, 0).unwrap() // Default to 9:00 AM
111    } else if let Some(caps) = time_re.captures(time_part) {
112        let mut hour: u32 = caps[1].parse()?;
113        let minute: u32 = caps
114            .get(2)
115            .map(|m| m.as_str().parse().unwrap())
116            .unwrap_or(0);
117        let ampm = caps.get(3).map(|m| m.as_str());
118
119        match ampm {
120            Some("am") => {
121                if hour == 12 {
122                    hour = 0;
123                }
124            }
125            Some("pm") => {
126                if hour != 12 {
127                    hour += 12;
128                }
129            }
130            _ => {
131                // 24-hour format or unknown, no change needed
132            }
133        }
134
135        NaiveTime::from_hms_opt(hour, minute, 0)
136            .ok_or_else(|| anyhow::anyhow!("Invalid time: {}:{}", hour, minute))?
137    } else {
138        bail!("Invalid time format: {}", time_part)
139    };
140
141    let naive_dt = target_date.and_time(target_time);
142    naive_dt
143        .and_local_timezone(Local)
144        .single()
145        .ok_or_else(|| anyhow::anyhow!("Invalid local time"))
146}
147
148fn parse_weekday(s: &str) -> Result<Weekday> {
149    match s {
150        "monday" | "mon" => Ok(Weekday::Mon),
151        "tuesday" | "tue" | "tues" => Ok(Weekday::Tue),
152        "wednesday" | "wed" => Ok(Weekday::Wed),
153        "thursday" | "thu" | "thur" | "thurs" => Ok(Weekday::Thu),
154        "friday" | "fri" => Ok(Weekday::Fri),
155        "saturday" | "sat" => Ok(Weekday::Sat),
156        "sunday" | "sun" => Ok(Weekday::Sun),
157        _ => bail!("Unknown weekday: {}", s),
158    }
159}
160
161fn next_weekday(from: chrono::NaiveDate, target: Weekday) -> chrono::NaiveDate {
162    let current = from.weekday();
163    let days_ahead =
164        (target.num_days_from_monday() as i64 - current.num_days_from_monday() as i64 + 7) % 7;
165    let days_ahead = if days_ahead == 0 { 7 } else { days_ahead };
166    from + Duration::days(days_ahead)
167}
168
169fn this_weekday(from: chrono::NaiveDate, target: Weekday) -> chrono::NaiveDate {
170    let current = from.weekday();
171    let days_ahead =
172        (target.num_days_from_monday() as i64 - current.num_days_from_monday() as i64 + 7) % 7;
173    from + Duration::days(days_ahead)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_relative_time() {
182        let now = Local::now();
183
184        let result = parse_time("30m").unwrap();
185        assert!((result - now).num_minutes() >= 29 && (result - now).num_minutes() <= 31);
186
187        let result = parse_time("2h").unwrap();
188        assert!((result - now).num_hours() >= 1 && (result - now).num_hours() <= 3);
189
190        let result = parse_time("1d").unwrap();
191        assert!((result - now).num_days() == 1);
192    }
193
194    #[test]
195    fn test_natural_time() {
196        let result = parse_time("tomorrow 9am");
197        assert!(result.is_ok());
198
199        let result = parse_time("next monday 14:00");
200        assert!(result.is_ok());
201    }
202}