Skip to main content

agd_core/
date.rs

1//! Org's date prompt. [`read_date`] reads the date at the start of some words
2//! the way org-read-date does (`fri`, `+2d`, `-wed`, `oct 30`, `3pm`, `mon
3//! 10:00`, an ISO date), with a repeater and warning after it (`fri +1w
4//! -3d`), taking as many words as form one date; [`parse_date_input`] is the
5//! same for a whole answer, and [`parse_date_change`] also takes `none`.
6//! [`format_active_timestamp`] renders a [`PlanningTimestamp`].
7//!
8//! Every surface reads dates here: `agd agenda --from`, a capture line's
9//! `due:` and `on:`, the TUI's prompts and MCP. Dates count from the `today`
10//! the caller passes, so results are deterministic. A date it cannot read is
11//! an `invalid_date` error that lists every accepted form.
12
13use std::sync::LazyLock;
14
15use chrono::{Datelike, Days, Duration, Months, NaiveDate, NaiveTime, Weekday};
16use regex::Regex;
17
18use crate::{AgdError, Change, ErrorCode, PlanningTimestamp};
19
20/// `2026-10-30`, or `2026-10-30t14:00` (lowercased).
21static 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});
24/// `+3`, `-1w`, `+2tue`, `-wed`: a sign, a count, and a unit or weekday.
25static RELATIVE_RE: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"^([+-])(\d*)([a-z]*)$").expect("relative date regex"));
27/// `14:00`, `9:30`, `3pm`, `9am`, `12:30am`.
28static CLOCK_RE: LazyLock<Regex> =
29    LazyLock::new(|| Regex::new(r"^(\d{1,2})(?::(\d{2}))?(am|pm)?$").expect("clock time regex"));
30/// A repeater after a date: `+1w`, `++1m`, `.+2d`.
31static REPEATER_RE: LazyLock<Regex> =
32    LazyLock::new(|| Regex::new(r"^(?:\+|\+\+|\.\+)\d+[hdwmy]$").expect("repeater regex"));
33/// A warning after a date: `-3d`, `-1w`.
34static WARNING_RE: LazyLock<Regex> =
35    LazyLock::new(|| Regex::new(r"^-\d+[hdwmy]$").expect("warning regex"));
36
37/// The date forms agd accepts wherever it asks for a date, listed in every
38/// `invalid_date` error so the fix is in the message.
39const 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
44/// Reads a whole date answer, relative to `today`: one date as
45/// [`read_date`] reads it, with nothing left over.
46pub 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
54/// The `invalid_date` error for an answer that is no date at all.
55pub(crate) fn unsupported_date(input: &str) -> AgdError {
56    date_error(format!("unsupported date '{}'", input.trim()))
57}
58
59/// A date answer for a field that may be cleared: `none` clears it, as in
60/// MCP `plan` and the TUI's date prompts; anything else is a date
61/// ([`parse_date_input`]).
62pub 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
72/// The date at the start of `words` and how many words it takes, as Org's
73/// date prompt reads it: a day (an ISO date, `today`, `tomorrow`, `.`, a
74/// weekday, `+2d`, `-wed`, `14`, `oct 30`) or a time alone (today at that
75/// time), then a time, a repeater (`+1w`) and a warning (`-3d`), each
76/// optional and taken when the next word is one. None when the first word
77/// starts no date. A word after the day that reads as a time but is not one
78/// (`25:00`, `13pm`) is an error, not the end of the date.
79pub 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
131/// An `invalid_date` error: what is wrong, then every accepted form.
132fn date_error(detail: String) -> AgdError {
133    AgdError::new(ErrorCode::InvalidDate, format!("{detail}; {DATE_FORMS}"))
134}
135
136/// A one-word day as Org's date prompt reads it, lowercase: `.` or
137/// `today`, `tomorrow`, a weekday (the next one, a week ahead when today is
138/// one), `+N`/`-N` (see `relative_day`), or a day of the month (`14`: this
139/// month's, or next month's once it has passed). None for anything else.
140fn 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
173/// A month and day in two words, `oct 30` or `30 oct`: this year's, or
174/// next year's once it has passed. None when the words are not one.
175fn 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
194/// `+3` and `-1w` (a count of d, w, m or y, days when the unit is left
195/// out), and Org's weekday shifts: `+tue` is the next Tuesday, `+2tue` the
196/// one after it, `-wed` the last Wednesday; a shift never lands on today.
197fn 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        // org-read-date-get-relative's arithmetic.
213        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
250/// A lowercase clock time: `14:00`, `9:30`, `3pm`, `9am`, `12:30am`. None
251/// for any other word, including a bare number, which is a day of the
252/// month; an error for one shaped like a time that is not one (`25:00`).
253fn 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
277/// A `YYYY-MM-DD` date as Org writes one, the one reader of that form.
278pub(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(&timestamp.date)?;
285    let mut value = format!("<{} {}", timestamp.date, date.format("%a"));
286    if let Some(time) = &timestamp.time {
287        value.push(' ');
288        value.push_str(time);
289        if let Some(end_time) = &timestamp.end_time {
290            value.push('-');
291            value.push_str(end_time);
292        }
293    }
294    if let Some(repeater) = &timestamp.repeater {
295        value.push(' ');
296        value.push_str(repeater);
297    }
298    if let Some(warning) = &timestamp.warning {
299        value.push(' ');
300        value.push_str(warning);
301    }
302    value.push('>');
303    Ok(value)
304}
305
306/// The timestamp inactive, as Org quotes one: `[2026-10-30 Fri]`.
307pub(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
336/// A lowercase month name, short or full, as its number.
337fn 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    /// Each answer is what pinned Emacs's org-read-date gives on Friday
428    /// 2026-10-16, except `tomorrow`, which agd reads as a date (Org ignores
429    /// the word).
430    #[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}