use std::sync::LazyLock;
use chrono::{Datelike, Days, Duration, Months, NaiveDate, NaiveTime, Weekday};
use regex::Regex;
use crate::{AgdError, Change, ErrorCode, PlanningTimestamp};
static ISO_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(\d{4}-\d{2}-\d{2})(?:t(\d{1,2}:\d{2}))?$").expect("ISO date regex")
});
static RELATIVE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([+-])(\d*)([a-z]*)$").expect("relative date regex"));
static CLOCK_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(\d{1,2})(?::(\d{2}))?(am|pm)?$").expect("clock time regex"));
static REPEATER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^(?:\+|\+\+|\.\+)\d+[hdwmy]$").expect("repeater regex"));
static WARNING_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^-\d+[hdwmy]$").expect("warning regex"));
const DATE_FORMS: &str = "use 2026-10-30 (or 2026-10-30 14:00, 2026-10-30T14:00), oct 30 or \
30 oct, 14 (the next 14th), today, tomorrow, '.' (today), a weekday (fri), +2tue or -wed, \
+3d -1w +2m +1y (+3 alone means days), a time for today (14:00, 3pm, 9am), \
or a day then a time (fri 9am, mon 10:00), then a repeater and a warning (fri +1w -3d)";
pub fn parse_date_input(input: &str, today: NaiveDate) -> Result<PlanningTimestamp, AgdError> {
let words: Vec<&str> = input.split_whitespace().collect();
match read_date(&words, today)? {
Some((timestamp, taken)) if taken == words.len() => Ok(timestamp),
_ => Err(unsupported_date(input)),
}
}
pub(crate) fn unsupported_date(input: &str) -> AgdError {
date_error(format!("unsupported date '{}'", input.trim()))
}
pub fn parse_date_change(
input: &str,
today: NaiveDate,
) -> Result<Change<PlanningTimestamp>, AgdError> {
if input.trim().eq_ignore_ascii_case("none") {
return Ok(Change::Clear);
}
parse_date_input(input, today).map(Change::Set)
}
pub fn read_date(
words: &[&str],
today: NaiveDate,
) -> Result<Option<(PlanningTimestamp, usize)>, AgdError> {
let words: Vec<String> = words.iter().map(|word| word.to_ascii_lowercase()).collect();
let Some(first) = words.first() else {
return Ok(None);
};
let (date, mut time, mut taken) = if let Some(captures) = ISO_RE.captures(first) {
let date = parse_org_date(&captures[1]).map_err(|error| date_error(error.to_string()))?;
let time = captures
.get(2)
.map(|time| clock_time(time.as_str()))
.transpose()?
.flatten();
(date, time, 1)
} else if let Some(time) = clock_time(first)? {
(today, Some(time), 1)
} else if let Some(date) = words
.get(1)
.map(|second| month_day(first, second, today))
.transpose()?
.flatten()
{
(date, None, 2)
} else if let Some(date) = day_word(first, today)? {
(date, None, 1)
} else {
return Ok(None);
};
if time.is_none()
&& let Some(word) = words.get(taken)
&& let Some(clock) = clock_time(word)?
{
time = Some(clock);
taken += 1;
}
let mut timestamp = PlanningTimestamp {
time: time.map(|time| time.format("%H:%M").to_string()),
..simple_date(date)
};
if let Some(word) = words.get(taken).filter(|word| REPEATER_RE.is_match(word)) {
timestamp.repeater = Some(word.clone());
taken += 1;
}
if let Some(word) = words.get(taken).filter(|word| WARNING_RE.is_match(word)) {
timestamp.warning = Some(word.clone());
taken += 1;
}
Ok(Some((timestamp, taken)))
}
fn date_error(detail: String) -> AgdError {
AgdError::new(ErrorCode::InvalidDate, format!("{detail}; {DATE_FORMS}"))
}
fn day_word(day: &str, today: NaiveDate) -> Result<Option<NaiveDate>, AgdError> {
let invalid = || date_error(format!("invalid date '{day}'"));
match day {
"." | "today" => return Ok(Some(today)),
"tomorrow" => return Ok(Some(today + Duration::days(1))),
_ => {}
}
if let Some(weekday) = parse_weekday(day) {
let distance = (weekday.num_days_from_monday() as i64
- today.weekday().num_days_from_monday() as i64)
.rem_euclid(7);
let distance = if distance == 0 { 7 } else { distance };
return Ok(Some(today + Duration::days(distance)));
}
if RELATIVE_RE.is_match(day) {
return relative_day(day, today);
}
if day.len() <= 2
&& let Ok(number) = day.parse::<u32>()
{
let month = today.with_day(1).expect("first of the month");
let month = if number < today.day() {
month
.checked_add_months(Months::new(1))
.ok_or_else(invalid)?
} else {
month
};
return month.with_day(number).map(Some).ok_or_else(invalid);
}
Ok(None)
}
fn month_day(first: &str, second: &str, today: NaiveDate) -> Result<Option<NaiveDate>, AgdError> {
let (name, number) = match (parse_month(first), parse_month(second)) {
(Some(_), _) => (first, second),
(None, Some(_)) => (second, first),
(None, None) => return Ok(None),
};
let (Some(month), Ok(number)) = (parse_month(name), number.parse::<u32>()) else {
return Ok(None);
};
let invalid = || date_error(format!("invalid date '{first} {second}'"));
let this_year = NaiveDate::from_ymd_opt(today.year(), month, number).ok_or_else(invalid)?;
if this_year >= today {
return Ok(Some(this_year));
}
NaiveDate::from_ymd_opt(today.year() + 1, month, number)
.map(Some)
.ok_or_else(invalid)
}
fn relative_day(input: &str, today: NaiveDate) -> Result<Option<NaiveDate>, AgdError> {
let Some(captures) = RELATIVE_RE.captures(input) else {
return Ok(None);
};
let out_of_range = || date_error(format!("date '{input}' is out of range"));
let forward = &captures[1] == "+";
let (count, unit) = (&captures[2], &captures[3]);
if count.is_empty() && unit.is_empty() {
return Ok(None);
}
let amount = match count {
"" => 1,
count => count.parse::<u32>().map_err(|_| out_of_range())?,
};
if let Some(weekday) = parse_weekday(unit) {
let mut delta = (weekday.num_days_from_monday() as i64
- today.weekday().num_days_from_monday() as i64)
.rem_euclid(7);
if delta == 0 {
delta = 7;
}
if !forward {
delta -= 7;
if delta == 0 {
delta = -7;
}
}
delta += (i64::from(amount) - 1) * if forward { 7 } else { -7 };
return today
.checked_add_signed(Duration::days(delta))
.map(Some)
.ok_or_else(out_of_range);
}
let (days, months) = match unit {
"" | "d" => (amount, 0),
"w" => (amount.checked_mul(7).ok_or_else(out_of_range)?, 0),
"m" => (0, amount),
"y" => (0, amount.checked_mul(12).ok_or_else(out_of_range)?),
_ => return Ok(None),
};
let date = if forward {
today
.checked_add_days(Days::new(days.into()))
.and_then(|date| date.checked_add_months(Months::new(months)))
} else {
today
.checked_sub_days(Days::new(days.into()))
.and_then(|date| date.checked_sub_months(Months::new(months)))
};
date.map(Some).ok_or_else(out_of_range)
}
fn clock_time(word: &str) -> Result<Option<NaiveTime>, AgdError> {
let Some(captures) = CLOCK_RE.captures(word) else {
return Ok(None);
};
let (minute, meridiem) = (captures.get(2), captures.get(3));
if minute.is_none() && meridiem.is_none() {
return Ok(None);
}
let invalid = || date_error(format!("invalid time '{word}'"));
let hour = captures[1].parse::<u32>().map_err(|_| invalid())?;
let minute = minute.map_or(Ok(0), |minute| {
minute.as_str().parse::<u32>().map_err(|_| invalid())
})?;
let hour = match meridiem.map(|meridiem| meridiem.as_str()) {
Some(_) if !(1..=12).contains(&hour) => return Err(invalid()),
Some("am") => hour % 12,
Some(_) => hour % 12 + 12,
None => hour,
};
NaiveTime::from_hms_opt(hour, minute, 0)
.map(Some)
.ok_or_else(invalid)
}
pub(crate) fn parse_org_date(input: &str) -> Result<NaiveDate, AgdError> {
NaiveDate::parse_from_str(input, "%Y-%m-%d")
.map_err(|_| AgdError::new(ErrorCode::InvalidDate, format!("invalid date '{input}'")))
}
pub fn format_active_timestamp(timestamp: &PlanningTimestamp) -> Result<String, AgdError> {
let date = parse_org_date(×tamp.date)?;
let mut value = format!("<{} {}", timestamp.date, date.format("%a"));
if let Some(time) = ×tamp.time {
value.push(' ');
value.push_str(time);
if let Some(end_time) = ×tamp.end_time {
value.push('-');
value.push_str(end_time);
}
}
if let Some(repeater) = ×tamp.repeater {
value.push(' ');
value.push_str(repeater);
}
if let Some(warning) = ×tamp.warning {
value.push(' ');
value.push_str(warning);
}
value.push('>');
Ok(value)
}
pub(crate) fn format_inactive_timestamp(timestamp: &PlanningTimestamp) -> Result<String, AgdError> {
let active = format_active_timestamp(timestamp)?;
Ok(format!("[{}]", &active[1..active.len() - 1]))
}
pub(crate) fn simple_date(date: NaiveDate) -> PlanningTimestamp {
PlanningTimestamp {
date: date.format("%Y-%m-%d").to_string(),
time: None,
end_time: None,
repeater: None,
warning: None,
raw: None,
}
}
fn parse_weekday(input: &str) -> Option<Weekday> {
match input.to_ascii_lowercase().as_str() {
"mon" | "monday" => Some(Weekday::Mon),
"tue" | "tues" | "tuesday" => Some(Weekday::Tue),
"wed" | "wednesday" => Some(Weekday::Wed),
"thu" | "thur" | "thurs" | "thursday" => Some(Weekday::Thu),
"fri" | "friday" => Some(Weekday::Fri),
"sat" | "saturday" => Some(Weekday::Sat),
"sun" | "sunday" => Some(Weekday::Sun),
_ => None,
}
}
fn parse_month(name: &str) -> Option<u32> {
let month = match name {
"jan" | "january" => 1,
"feb" | "february" => 2,
"mar" | "march" => 3,
"apr" | "april" => 4,
"may" => 5,
"jun" | "june" => 6,
"jul" | "july" => 7,
"aug" | "august" => 8,
"sep" | "sept" | "september" => 9,
"oct" | "october" => 10,
"nov" | "november" => 11,
"dec" | "december" => 12,
_ => return None,
};
Some(month)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_org_prompt_shorthands_and_times() {
let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
let date = |input: &str| {
let timestamp = parse_date_input(input, today).unwrap();
(timestamp.date, timestamp.time)
};
let day = |value: &str| (value.to_string(), None);
let at = |value: &str, time: &str| (value.to_string(), Some(time.to_string()));
assert_eq!(date("."), day("2026-10-13"));
assert_eq!(date("+3"), day("2026-10-16"));
assert_eq!(date("-1d"), day("2026-10-12"));
assert_eq!(date("-1w"), day("2026-10-06"));
assert_eq!(date("-1m"), day("2026-09-13"));
assert_eq!(date("+1y"), day("2027-10-13"));
assert_eq!(date("14:00"), at("2026-10-13", "14:00"));
assert_eq!(date("mon 10:00"), at("2026-10-19", "10:00"));
assert_eq!(date("tomorrow 9:30"), at("2026-10-14", "09:30"));
assert_eq!(date("+2d 14:00"), at("2026-10-15", "14:00"));
assert_eq!(date("2026-10-30T14:00"), at("2026-10-30", "14:00"));
assert_eq!(date("2026-10-30 14:00"), at("2026-10-30", "14:00"));
let error = parse_date_input("someday", today).unwrap_err();
assert_eq!(error.code().as_str(), "invalid_date");
for form in [
"today",
"tomorrow",
"fri",
"-1w",
"+2tue",
"oct 30",
"3pm",
"2026-10-30T14:00",
"fri 9am",
] {
assert!(error.to_string().contains(form), "{form} missing: {error}");
}
}
#[test]
fn a_date_takes_the_longest_run_of_words_and_refuses_leftovers() {
let friday = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap();
let warned = parse_date_input("fri -3d", friday).unwrap();
assert_eq!(
(warned.date.as_str(), warned.warning.as_deref()),
("2026-10-23", Some("-3d"))
);
let full = parse_date_input("2026-10-30 14:00 +1w -2d", friday).unwrap();
assert_eq!(
format_active_timestamp(&full).unwrap(),
"<2026-10-30 Fri 14:00 +1w -2d>"
);
let (date, taken) = read_date(&["fri", "3", "talks"], friday).unwrap().unwrap();
assert_eq!((date.date.as_str(), taken), ("2026-10-23", 1));
assert_eq!(read_date(&["Review"], friday).unwrap(), None);
for input in ["fri 25:00", "fri talks", "13pm"] {
let error = parse_date_input(input, friday).unwrap_err();
assert_eq!(error.code(), ErrorCode::InvalidDate, "{input}");
assert!(error.to_string().contains("fri 9am"), "{error}");
}
assert_eq!(parse_date_change(" None ", friday).unwrap(), Change::Clear);
assert!(matches!(
parse_date_change("fri", friday).unwrap(),
Change::Set(timestamp) if timestamp.date == "2026-10-23"
));
}
#[test]
fn reads_dates_as_org_read_date_does() {
let friday = NaiveDate::from_ymd_opt(2026, 10, 16).unwrap();
let cases = [
(".", "2026-10-16", None),
("+0", "2026-10-16", None),
("+3", "2026-10-19", None),
("-3", "2026-10-13", None),
("-1w", "2026-10-09", None),
("-wed", "2026-10-14", None),
("-fri", "2026-10-09", None),
("+tue", "2026-10-20", None),
("+fri", "2026-10-23", None),
("+2tue", "2026-10-27", None),
("sat", "2026-10-17", None),
("thu", "2026-10-22", None),
("fri", "2026-10-23", None),
("16", "2026-10-16", None),
("14", "2026-11-14", None),
("oct 30", "2026-10-30", None),
("30 Oct", "2026-10-30", None),
("jan 5", "2027-01-05", None),
("sep 15", "2027-09-15", None),
("14:00", "2026-10-16", Some("14:00")),
("3pm", "2026-10-16", Some("15:00")),
("9am", "2026-10-16", Some("09:00")),
("12am", "2026-10-16", Some("00:00")),
("12pm", "2026-10-16", Some("12:00")),
("mon 10:00", "2026-10-19", Some("10:00")),
("+2d 14:00", "2026-10-18", Some("14:00")),
("oct 30 9am", "2026-10-30", Some("09:00")),
("tomorrow 3pm", "2026-10-17", Some("15:00")),
];
for (input, date, time) in cases {
let timestamp = parse_date_input(input, friday).unwrap();
assert_eq!(
(timestamp.date.as_str(), timestamp.time.as_deref()),
(date, time),
"{input}"
);
}
}
}