reminder_cli/
time_parser.rs1use anyhow::{bail, Result};
2use chrono::{DateTime, Datelike, Duration, Local, NaiveDateTime, NaiveTime, Weekday};
3use regex::Regex;
4
5pub fn parse_time(input: &str) -> Result<DateTime<Local>> {
10 let input = input.trim().to_lowercase();
11
12 if let Ok(dt) = parse_absolute(&input) {
14 return Ok(dt);
15 }
16
17 if let Ok(dt) = parse_relative(&input) {
19 return Ok(dt);
20 }
21
22 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 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 (input, "")
79 };
80
81 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 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 let target_time = if time_part.is_empty() {
108 NaiveTime::from_hms_opt(9, 0, 0).unwrap() } 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 }
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}