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(
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 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 (input, "")
81 };
82
83 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 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 let target_time = if time_part.is_empty() {
110 NaiveTime::from_hms_opt(9, 0, 0).unwrap() } 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 }
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}