1use std::sync::LazyLock;
14
15use chrono::{Datelike, Days, Duration, Months, NaiveDate, NaiveTime, Weekday};
16use regex::Regex;
17
18use crate::{AgdError, Change, ErrorCode, PlanningTimestamp};
19
20static ISO_RE: LazyLock<Regex> = LazyLock::new(|| {
22 Regex::new(r"^(\d{4}-\d{2}-\d{2})(?:t(\d{1,2}:\d{2}))?$").expect("ISO date regex")
23});
24static RELATIVE_RE: LazyLock<Regex> =
26 LazyLock::new(|| Regex::new(r"^([+-])(\d*)([a-z]*)$").expect("relative date regex"));
27static CLOCK_RE: LazyLock<Regex> =
29 LazyLock::new(|| Regex::new(r"^(\d{1,2})(?::(\d{2}))?(am|pm)?$").expect("clock time regex"));
30static REPEATER_RE: LazyLock<Regex> =
32 LazyLock::new(|| Regex::new(r"^(?:\+|\+\+|\.\+)\d+[hdwmy]$").expect("repeater regex"));
33static WARNING_RE: LazyLock<Regex> =
35 LazyLock::new(|| Regex::new(r"^-\d+[hdwmy]$").expect("warning regex"));
36
37const DATE_FORMS: &str = "use 2026-10-30 (or 2026-10-30 14:00, 2026-10-30T14:00), oct 30 or \
4030 oct, 14 (the next 14th), today, tomorrow, '.' (today), a weekday (fri), +2tue or -wed, \
41+3d -1w +2m +1y (+3 alone means days), a time for today (14:00, 3pm, 9am), \
42or a day then a time (fri 9am, mon 10:00), then a repeater and a warning (fri +1w -3d)";
43
44pub fn parse_date_input(input: &str, today: NaiveDate) -> Result<PlanningTimestamp, AgdError> {
47 let words: Vec<&str> = input.split_whitespace().collect();
48 match read_date(&words, today)? {
49 Some((timestamp, taken)) if taken == words.len() => Ok(timestamp),
50 _ => Err(unsupported_date(input)),
51 }
52}
53
54pub(crate) fn unsupported_date(input: &str) -> AgdError {
56 date_error(format!("unsupported date '{}'", input.trim()))
57}
58
59pub fn parse_date_change(
63 input: &str,
64 today: NaiveDate,
65) -> Result<Change<PlanningTimestamp>, AgdError> {
66 if input.trim().eq_ignore_ascii_case("none") {
67 return Ok(Change::Clear);
68 }
69 parse_date_input(input, today).map(Change::Set)
70}
71
72pub fn read_date(
80 words: &[&str],
81 today: NaiveDate,
82) -> Result<Option<(PlanningTimestamp, usize)>, AgdError> {
83 let words: Vec<String> = words.iter().map(|word| word.to_ascii_lowercase()).collect();
84 let Some(first) = words.first() else {
85 return Ok(None);
86 };
87 let (date, mut time, mut taken) = if let Some(captures) = ISO_RE.captures(first) {
88 let date = parse_org_date(&captures[1]).map_err(|error| date_error(error.to_string()))?;
89 let time = captures
90 .get(2)
91 .map(|time| clock_time(time.as_str()))
92 .transpose()?
93 .flatten();
94 (date, time, 1)
95 } else if let Some(time) = clock_time(first)? {
96 (today, Some(time), 1)
97 } else if let Some(date) = words
98 .get(1)
99 .map(|second| month_day(first, second, today))
100 .transpose()?
101 .flatten()
102 {
103 (date, None, 2)
104 } else if let Some(date) = day_word(first, today)? {
105 (date, None, 1)
106 } else {
107 return Ok(None);
108 };
109 if time.is_none()
110 && let Some(word) = words.get(taken)
111 && let Some(clock) = clock_time(word)?
112 {
113 time = Some(clock);
114 taken += 1;
115 }
116 let mut timestamp = PlanningTimestamp {
117 time: time.map(|time| time.format("%H:%M").to_string()),
118 ..simple_date(date)
119 };
120 if let Some(word) = words.get(taken).filter(|word| REPEATER_RE.is_match(word)) {
121 timestamp.repeater = Some(word.clone());
122 taken += 1;
123 }
124 if let Some(word) = words.get(taken).filter(|word| WARNING_RE.is_match(word)) {
125 timestamp.warning = Some(word.clone());
126 taken += 1;
127 }
128 Ok(Some((timestamp, taken)))
129}
130
131fn date_error(detail: String) -> AgdError {
133 AgdError::new(ErrorCode::InvalidDate, format!("{detail}; {DATE_FORMS}"))
134}
135
136fn day_word(day: &str, today: NaiveDate) -> Result<Option<NaiveDate>, AgdError> {
141 let invalid = || date_error(format!("invalid date '{day}'"));
142 match day {
143 "." | "today" => return Ok(Some(today)),
144 "tomorrow" => return Ok(Some(today + Duration::days(1))),
145 _ => {}
146 }
147 if let Some(weekday) = parse_weekday(day) {
148 let distance = (weekday.num_days_from_monday() as i64
149 - today.weekday().num_days_from_monday() as i64)
150 .rem_euclid(7);
151 let distance = if distance == 0 { 7 } else { distance };
152 return Ok(Some(today + Duration::days(distance)));
153 }
154 if RELATIVE_RE.is_match(day) {
155 return relative_day(day, today);
156 }
157 if day.len() <= 2
158 && let Ok(number) = day.parse::<u32>()
159 {
160 let month = today.with_day(1).expect("first of the month");
161 let month = if number < today.day() {
162 month
163 .checked_add_months(Months::new(1))
164 .ok_or_else(invalid)?
165 } else {
166 month
167 };
168 return month.with_day(number).map(Some).ok_or_else(invalid);
169 }
170 Ok(None)
171}
172
173fn month_day(first: &str, second: &str, today: NaiveDate) -> Result<Option<NaiveDate>, AgdError> {
176 let (name, number) = match (parse_month(first), parse_month(second)) {
177 (Some(_), _) => (first, second),
178 (None, Some(_)) => (second, first),
179 (None, None) => return Ok(None),
180 };
181 let (Some(month), Ok(number)) = (parse_month(name), number.parse::<u32>()) else {
182 return Ok(None);
183 };
184 let invalid = || date_error(format!("invalid date '{first} {second}'"));
185 let this_year = NaiveDate::from_ymd_opt(today.year(), month, number).ok_or_else(invalid)?;
186 if this_year >= today {
187 return Ok(Some(this_year));
188 }
189 NaiveDate::from_ymd_opt(today.year() + 1, month, number)
190 .map(Some)
191 .ok_or_else(invalid)
192}
193
194fn relative_day(input: &str, today: NaiveDate) -> Result<Option<NaiveDate>, AgdError> {
198 let Some(captures) = RELATIVE_RE.captures(input) else {
199 return Ok(None);
200 };
201 let out_of_range = || date_error(format!("date '{input}' is out of range"));
202 let forward = &captures[1] == "+";
203 let (count, unit) = (&captures[2], &captures[3]);
204 if count.is_empty() && unit.is_empty() {
205 return Ok(None);
206 }
207 let amount = match count {
208 "" => 1,
209 count => count.parse::<u32>().map_err(|_| out_of_range())?,
210 };
211 if let Some(weekday) = parse_weekday(unit) {
212 let mut delta = (weekday.num_days_from_monday() as i64
214 - today.weekday().num_days_from_monday() as i64)
215 .rem_euclid(7);
216 if delta == 0 {
217 delta = 7;
218 }
219 if !forward {
220 delta -= 7;
221 if delta == 0 {
222 delta = -7;
223 }
224 }
225 delta += (i64::from(amount) - 1) * if forward { 7 } else { -7 };
226 return today
227 .checked_add_signed(Duration::days(delta))
228 .map(Some)
229 .ok_or_else(out_of_range);
230 }
231 let (days, months) = match unit {
232 "" | "d" => (amount, 0),
233 "w" => (amount.checked_mul(7).ok_or_else(out_of_range)?, 0),
234 "m" => (0, amount),
235 "y" => (0, amount.checked_mul(12).ok_or_else(out_of_range)?),
236 _ => return Ok(None),
237 };
238 let date = if forward {
239 today
240 .checked_add_days(Days::new(days.into()))
241 .and_then(|date| date.checked_add_months(Months::new(months)))
242 } else {
243 today
244 .checked_sub_days(Days::new(days.into()))
245 .and_then(|date| date.checked_sub_months(Months::new(months)))
246 };
247 date.map(Some).ok_or_else(out_of_range)
248}
249
250fn clock_time(word: &str) -> Result<Option<NaiveTime>, AgdError> {
254 let Some(captures) = CLOCK_RE.captures(word) else {
255 return Ok(None);
256 };
257 let (minute, meridiem) = (captures.get(2), captures.get(3));
258 if minute.is_none() && meridiem.is_none() {
259 return Ok(None);
260 }
261 let invalid = || date_error(format!("invalid time '{word}'"));
262 let hour = captures[1].parse::<u32>().map_err(|_| invalid())?;
263 let minute = minute.map_or(Ok(0), |minute| {
264 minute.as_str().parse::<u32>().map_err(|_| invalid())
265 })?;
266 let hour = match meridiem.map(|meridiem| meridiem.as_str()) {
267 Some(_) if !(1..=12).contains(&hour) => return Err(invalid()),
268 Some("am") => hour % 12,
269 Some(_) => hour % 12 + 12,
270 None => hour,
271 };
272 NaiveTime::from_hms_opt(hour, minute, 0)
273 .map(Some)
274 .ok_or_else(invalid)
275}
276
277pub(crate) fn parse_org_date(input: &str) -> Result<NaiveDate, AgdError> {
279 NaiveDate::parse_from_str(input, "%Y-%m-%d")
280 .map_err(|_| AgdError::new(ErrorCode::InvalidDate, format!("invalid date '{input}'")))
281}
282
283pub fn format_active_timestamp(timestamp: &PlanningTimestamp) -> Result<String, AgdError> {
284 let date = parse_org_date(×tamp.date)?;
285 let mut value = format!("<{} {}", timestamp.date, date.format("%a"));
286 if let Some(time) = ×tamp.time {
287 value.push(' ');
288 value.push_str(time);
289 if let Some(end_time) = ×tamp.end_time {
290 value.push('-');
291 value.push_str(end_time);
292 }
293 }
294 if let Some(repeater) = ×tamp.repeater {
295 value.push(' ');
296 value.push_str(repeater);
297 }
298 if let Some(warning) = ×tamp.warning {
299 value.push(' ');
300 value.push_str(warning);
301 }
302 value.push('>');
303 Ok(value)
304}
305
306pub(crate) fn format_inactive_timestamp(timestamp: &PlanningTimestamp) -> Result<String, AgdError> {
308 let active = format_active_timestamp(timestamp)?;
309 Ok(format!("[{}]", &active[1..active.len() - 1]))
310}
311
312pub(crate) fn simple_date(date: NaiveDate) -> PlanningTimestamp {
313 PlanningTimestamp {
314 date: date.format("%Y-%m-%d").to_string(),
315 time: None,
316 end_time: None,
317 repeater: None,
318 warning: None,
319 raw: None,
320 }
321}
322
323fn parse_weekday(input: &str) -> Option<Weekday> {
324 match input.to_ascii_lowercase().as_str() {
325 "mon" | "monday" => Some(Weekday::Mon),
326 "tue" | "tues" | "tuesday" => Some(Weekday::Tue),
327 "wed" | "wednesday" => Some(Weekday::Wed),
328 "thu" | "thur" | "thurs" | "thursday" => Some(Weekday::Thu),
329 "fri" | "friday" => Some(Weekday::Fri),
330 "sat" | "saturday" => Some(Weekday::Sat),
331 "sun" | "sunday" => Some(Weekday::Sun),
332 _ => None,
333 }
334}
335
336fn parse_month(name: &str) -> Option<u32> {
338 let month = match name {
339 "jan" | "january" => 1,
340 "feb" | "february" => 2,
341 "mar" | "march" => 3,
342 "apr" | "april" => 4,
343 "may" => 5,
344 "jun" | "june" => 6,
345 "jul" | "july" => 7,
346 "aug" | "august" => 8,
347 "sep" | "sept" | "september" => 9,
348 "oct" | "october" => 10,
349 "nov" | "november" => 11,
350 "dec" | "december" => 12,
351 _ => return None,
352 };
353 Some(month)
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn reads_org_prompt_shorthands_and_times() {
362 let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
363 let date = |input: &str| {
364 let timestamp = parse_date_input(input, today).unwrap();
365 (timestamp.date, timestamp.time)
366 };
367 let day = |value: &str| (value.to_string(), None);
368 let at = |value: &str, time: &str| (value.to_string(), Some(time.to_string()));
369 assert_eq!(date("."), day("2026-10-13"));
370 assert_eq!(date("+3"), day("2026-10-16"));
371 assert_eq!(date("-1d"), day("2026-10-12"));
372 assert_eq!(date("-1w"), day("2026-10-06"));
373 assert_eq!(date("-1m"), day("2026-09-13"));
374 assert_eq!(date("+1y"), day("2027-10-13"));
375 assert_eq!(date("14:00"), at("2026-10-13", "14:00"));
376 assert_eq!(date("mon 10:00"), at("2026-10-19", "10:00"));
377 assert_eq!(date("tomorrow 9:30"), at("2026-10-14", "09:30"));
378 assert_eq!(date("+2d 14:00"), at("2026-10-15", "14:00"));
379 assert_eq!(date("2026-10-30T14:00"), at("2026-10-30", "14:00"));
380 assert_eq!(date("2026-10-30 14:00"), at("2026-10-30", "14:00"));
381
382 let error = parse_date_input("someday", today).unwrap_err();
383 assert_eq!(error.code().as_str(), "invalid_date");
384 for form in [
385 "today",
386 "tomorrow",
387 "fri",
388 "-1w",
389 "+2tue",
390 "oct 30",
391 "3pm",
392 "2026-10-30T14:00",
393 "fri 9am",
394 ] {
395 assert!(error.to_string().contains(form), "{form} missing: {error}");
396 }
397 }
398
399 #[test]
400 fn a_date_takes_the_longest_run_of_words_and_refuses_leftovers() {
401 let friday = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap();
402 let warned = parse_date_input("fri -3d", friday).unwrap();
403 assert_eq!(
404 (warned.date.as_str(), warned.warning.as_deref()),
405 ("2026-10-23", Some("-3d"))
406 );
407 let full = parse_date_input("2026-10-30 14:00 +1w -2d", friday).unwrap();
408 assert_eq!(
409 format_active_timestamp(&full).unwrap(),
410 "<2026-10-30 Fri 14:00 +1w -2d>"
411 );
412 let (date, taken) = read_date(&["fri", "3", "talks"], friday).unwrap().unwrap();
413 assert_eq!((date.date.as_str(), taken), ("2026-10-23", 1));
414 assert_eq!(read_date(&["Review"], friday).unwrap(), None);
415 for input in ["fri 25:00", "fri talks", "13pm"] {
416 let error = parse_date_input(input, friday).unwrap_err();
417 assert_eq!(error.code(), ErrorCode::InvalidDate, "{input}");
418 assert!(error.to_string().contains("fri 9am"), "{error}");
419 }
420 assert_eq!(parse_date_change(" None ", friday).unwrap(), Change::Clear);
421 assert!(matches!(
422 parse_date_change("fri", friday).unwrap(),
423 Change::Set(timestamp) if timestamp.date == "2026-10-23"
424 ));
425 }
426
427 #[test]
431 fn reads_dates_as_org_read_date_does() {
432 let friday = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap();
433 let cases = [
434 (".", "2026-10-16", None),
435 ("+0", "2026-10-16", None),
436 ("+3", "2026-10-19", None),
437 ("-3", "2026-10-13", None),
438 ("-1w", "2026-10-09", None),
439 ("-wed", "2026-10-14", None),
440 ("-fri", "2026-10-09", None),
441 ("+tue", "2026-10-20", None),
442 ("+fri", "2026-10-23", None),
443 ("+2tue", "2026-10-27", None),
444 ("sat", "2026-10-17", None),
445 ("thu", "2026-10-22", None),
446 ("fri", "2026-10-23", None),
447 ("16", "2026-10-16", None),
448 ("14", "2026-11-14", None),
449 ("oct 30", "2026-10-30", None),
450 ("30 Oct", "2026-10-30", None),
451 ("jan 5", "2027-01-05", None),
452 ("sep 15", "2027-09-15", None),
453 ("14:00", "2026-10-16", Some("14:00")),
454 ("3pm", "2026-10-16", Some("15:00")),
455 ("9am", "2026-10-16", Some("09:00")),
456 ("12am", "2026-10-16", Some("00:00")),
457 ("12pm", "2026-10-16", Some("12:00")),
458 ("mon 10:00", "2026-10-19", Some("10:00")),
459 ("+2d 14:00", "2026-10-18", Some("14:00")),
460 ("oct 30 9am", "2026-10-30", Some("09:00")),
461 ("tomorrow 3pm", "2026-10-17", Some("15:00")),
462 ];
463 for (input, date, time) in cases {
464 let timestamp = parse_date_input(input, friday).unwrap();
465 assert_eq!(
466 (timestamp.date.as_str(), timestamp.time.as_deref()),
467 (date, time),
468 "{input}"
469 );
470 }
471 }
472}