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