Skip to main content

mach/
due.rs

1//! Due dates may be typed inline as `[...]` at the end of a task description.
2//! Input accepts `[yyyy-mm-dd hh:mm]`, `[yyyy-mm-dd]`, `[mm-dd hh:mm]`,
3//! `[mm-dd]`, and `[hh:mm]`; persistence canonicalizes shorthand to an
4//! absolute date and stores it separately from the title.
5
6use std::sync::OnceLock;
7
8use chrono::{Datelike, Local, NaiveDate, NaiveDateTime, NaiveTime, Timelike};
9use regex::Regex;
10
11fn patterns() -> &'static [Regex] {
12    static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
13    PATTERNS.get_or_init(|| {
14        // Month and day accept one or two digits everywhere, so a date
15        // that parses on its own still parses once a time is appended.
16        [
17            r"\[(\d{4}-\d{1,2}-\d{1,2} \d{2}:\d{2})\]\s*$",
18            r"\[(\d{4}-\d{1,2}-\d{1,2})\]\s*$",
19            r"\[(\d{1,2}-\d{1,2} \d{2}:\d{2})\]\s*$",
20            r"\[(\d{1,2}-\d{1,2})\]\s*$",
21            r"\[(\d{2}:\d{2})\]\s*$",
22        ]
23        .iter()
24        .map(|p| Regex::new(p).expect("valid due-date pattern"))
25        .collect()
26    })
27}
28
29/// Split a description into `(due, remaining_text)`.
30pub fn parse(description: &str) -> (String, String) {
31    for re in patterns() {
32        if let Some(captures) = re.captures(description) {
33            let matched = captures
34                .get(0)
35                .expect("due-date pattern always has a full match");
36            let due = captures
37                .get(1)
38                .expect("due-date pattern always captures the value")
39                .as_str()
40                .to_string();
41            let mut rest = String::with_capacity(description.len());
42            rest.push_str(&description[..matched.start()]);
43            rest.push_str(&description[matched.end()..]);
44            return (due, rest.trim().to_string());
45        }
46    }
47    (String::new(), description.trim().to_string())
48}
49
50/// Whether a bare date (no brackets) is one mach can store. An empty
51/// string counts as valid: it just means "no due date".
52pub fn is_valid(text: &str) -> bool {
53    normalize_for_write(text).is_ok()
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct InvalidDue {
58    value: String,
59}
60
61impl std::fmt::Display for InvalidDue {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        write!(
64            f,
65            "invalid due {:?}; use YYYY-MM-DD, MM-DD, or HH:MM, optionally with a time",
66            self.value
67        )
68    }
69}
70
71impl std::error::Error for InvalidDue {}
72
73/// Canonicalize a due value at the moment it is persisted.
74///
75/// Fully qualified dates keep their year, including dates in the past.
76/// Shorthand values name their next occurrence: a past `MM-DD` rolls to a
77/// later year and a past `HH:MM` rolls to tomorrow. The stored result is
78/// always an absolute `YYYY-MM-DD` date, optionally followed by `HH:MM`.
79pub fn normalize_for_write(text: &str) -> Result<String, InvalidDue> {
80    normalize_for_write_at(text, Local::now().naive_local())
81}
82
83pub fn normalize_for_write_at(text: &str, now: NaiveDateTime) -> Result<String, InvalidDue> {
84    normalize_at(text, now, true)
85}
86
87/// Freeze legacy shorthand using the migration clock instead of treating it
88/// as a newly entered future occurrence. This preserves what an existing
89/// `MM-DD` / `HH:MM` value meant on the day SQLite first imports it.
90pub fn normalize_legacy_at(text: &str, now: NaiveDateTime) -> Result<String, InvalidDue> {
91    normalize_at(text, now, false)
92}
93
94fn normalize_at(
95    text: &str,
96    now: NaiveDateTime,
97    roll_shorthand_forward: bool,
98) -> Result<String, InvalidDue> {
99    let original = text.trim();
100    if original.is_empty() {
101        return Ok(String::new());
102    }
103    let input = original.replace('T', " ");
104    let (matched, rest) = parse(&format!("[{input}]"));
105    if matched != input || !rest.is_empty() {
106        return Err(InvalidDue {
107            value: original.to_string(),
108        });
109    }
110
111    let (date_part, time_part) = match input.split_once(' ') {
112        Some((date, time)) if !date.is_empty() && !time.is_empty() => (date, Some(time)),
113        None if input.contains(':') => ("", Some(input.as_str())),
114        None => (input.as_str(), None),
115        _ => {
116            return Err(InvalidDue {
117                value: original.to_string(),
118            });
119        }
120    };
121    let time = match time_part {
122        Some(value) => Some(parse_time(value).ok_or_else(|| InvalidDue {
123            value: original.to_string(),
124        })?),
125        None => None,
126    };
127
128    let pieces: Vec<&str> = date_part.split('-').collect();
129    let date = match pieces.as_slice() {
130        [year, month, day] => {
131            let year = year.parse::<i32>().ok();
132            let month = month.parse::<u32>().ok();
133            let day = day.parse::<u32>().ok();
134            match (year, month, day) {
135                (Some(year), Some(month), Some(day)) => NaiveDate::from_ymd_opt(year, month, day),
136                _ => None,
137            }
138            .ok_or_else(|| InvalidDue {
139                value: original.to_string(),
140            })?
141        }
142        [month, day] => {
143            let month = month.parse::<u32>().ok();
144            let day = day.parse::<u32>().ok();
145            let (Some(month), Some(day)) = (month, day) else {
146                return Err(InvalidDue {
147                    value: original.to_string(),
148                });
149            };
150            if roll_shorthand_forward {
151                next_month_day(month, day, time, now)
152            } else {
153                month_day_in_migration_year(month, day, now)
154            }
155            .ok_or_else(|| InvalidDue {
156                value: original.to_string(),
157            })?
158        }
159        [""] => {
160            let Some(clock) = time else {
161                return Err(InvalidDue {
162                    value: original.to_string(),
163                });
164            };
165            let today = now.date();
166            let candidate = today.and_time(clock);
167            if !roll_shorthand_forward || candidate >= now {
168                today
169            } else {
170                today.succ_opt().ok_or_else(|| InvalidDue {
171                    value: original.to_string(),
172                })?
173            }
174        }
175        _ => {
176            return Err(InvalidDue {
177                value: original.to_string(),
178            });
179        }
180    };
181
182    Ok(match time {
183        Some(time) => format!("{} {}", date.format("%Y-%m-%d"), time.format("%H:%M")),
184        None => date.format("%Y-%m-%d").to_string(),
185    })
186}
187
188fn month_day_in_migration_year(month: u32, day: u32, now: NaiveDateTime) -> Option<NaiveDate> {
189    NaiveDate::from_ymd_opt(now.year(), month, day)
190}
191
192pub(crate) fn parse_time(value: &str) -> Option<NaiveTime> {
193    if value.len() != 5 || value.as_bytes().get(2) != Some(&b':') {
194        return None;
195    }
196    let hour = value.get(..2)?.parse().ok()?;
197    let minute = value.get(3..)?.parse().ok()?;
198    NaiveTime::from_hms_opt(hour, minute, 0)
199}
200
201fn next_month_day(
202    month: u32,
203    day: u32,
204    time: Option<NaiveTime>,
205    now: NaiveDateTime,
206) -> Option<NaiveDate> {
207    // Eight years always crosses a leap year, including the Gregorian
208    // century exception. The wider bound also keeps this correct near i32::MAX.
209    for offset in 0..=8 {
210        let year = now.year().checked_add(offset)?;
211        let Some(candidate) = NaiveDate::from_ymd_opt(year, month, day) else {
212            continue;
213        };
214        let is_future = match time {
215            Some(clock) => candidate.and_time(clock) >= now,
216            None => candidate >= now.date(),
217        };
218        if is_future {
219            return Some(candidate);
220        }
221    }
222    None
223}
224
225pub fn is_today(due: &str) -> bool {
226    relative_day(due) == Some(0)
227}
228
229/// Days from today for `due`: `-1` yesterday, `0` today, `1` tomorrow.
230fn relative_day(due: &str) -> Option<i64> {
231    if due.is_empty() {
232        return None;
233    }
234    // Bare hh:mm means today.
235    if due.len() == 5 && due.contains(':') {
236        return Some(0);
237    }
238    let (y, mo, d, _, _) = sort_key(due)?;
239    relative_date(y, mo, d)
240}
241
242fn relative_date(year: i32, month: u32, day: u32) -> Option<i64> {
243    relative_date_from(year, month, day, Local::now().date_naive())
244}
245
246fn relative_date_from(year: i32, month: u32, day: u32, today: NaiveDate) -> Option<i64> {
247    let date = NaiveDate::from_ymd_opt(year, month, day)?;
248    Some((date - today).num_days())
249}
250
251/// The bracketed full string shown in the preview and CLI output.
252/// Date order follows `date_format` (`Y-M-D` / `D-M-Y` / `M-D-Y`).
253/// Nearby days use Today / Tomorrow / Yesterday.
254pub fn display(due: &str, date_format: &str) -> String {
255    if due.is_empty() {
256        return String::new();
257    }
258    let Some((y, mo, d, h, mi)) = sort_key(due) else {
259        return format!("[{due}]");
260    };
261    let relative = if due.len() == 5 && due.contains(':') {
262        Some(0)
263    } else {
264        relative_date(y, mo, d)
265    };
266    let label = match relative {
267        Some(0) => "Today".to_string(),
268        Some(1) => "Tomorrow".to_string(),
269        Some(-1) => "Yesterday".to_string(),
270        _ => format_date(y, mo, d, date_format),
271    };
272    let text = if due.contains(':') {
273        format!("{label} {h:02}:{mi:02}")
274    } else {
275        label
276    };
277    format!("[{text}]")
278}
279
280/// The shorter due label used inside a task-list row.
281///
282/// Relative dates stay readable. Other dates omit the current year and use
283/// two digits for a different year; the preview and CLI keep using [`display`]
284/// so the full value is always available outside the compact list.
285pub fn display_compact(due: &str, date_format: &str) -> String {
286    display_compact_at(due, date_format, Local::now().date_naive())
287}
288
289pub(crate) fn display_compact_at(due: &str, date_format: &str, today: NaiveDate) -> String {
290    if due.is_empty() {
291        return String::new();
292    }
293    let Some((y, mo, d, h, mi)) = sort_key(due) else {
294        return due.to_string();
295    };
296    let relative = if due.len() == 5 && due.contains(':') {
297        Some(0)
298    } else {
299        relative_date_from(y, mo, d, today)
300    };
301    let label = match relative {
302        Some(0) => "Today".to_string(),
303        Some(1) => "Tomorrow".to_string(),
304        Some(-1) => "Yesterday".to_string(),
305        _ => format_compact_date(y, mo, d, date_format, today.year()),
306    };
307    if due.contains(':') {
308        format!("{label} {h:02}:{mi:02}")
309    } else {
310        label
311    }
312}
313
314fn format_date(year: i32, month: u32, day: u32, date_format: &str) -> String {
315    match date_format {
316        "D-M-Y" => format!("{day:02}-{month:02}-{year}"),
317        "M-D-Y" => format!("{month:02}-{day:02}-{year}"),
318        _ => format!("{year}-{month:02}-{day:02}"),
319    }
320}
321
322fn format_compact_date(
323    year: i32,
324    month: u32,
325    day: u32,
326    date_format: &str,
327    current_year: i32,
328) -> String {
329    if year == current_year {
330        return match date_format {
331            "D-M-Y" => format!("{day:02}-{month:02}"),
332            _ => format!("{month:02}-{day:02}"),
333        };
334    }
335
336    let year = year.rem_euclid(100);
337    match date_format {
338        "D-M-Y" => format!("{day:02}-{month:02}-{year:02}"),
339        "M-D-Y" => format!("{month:02}-{day:02}-{year:02}"),
340        _ => format!("{year:02}-{month:02}-{day:02}"),
341    }
342}
343
344/// A comparable point in time for sorting. New writes are canonical absolute
345/// values; the shorthand branches remain for displaying pre-migration callers
346/// without turning malformed text into a real date. `None` is "no due date"
347/// or an invalid value, both of which sort last.
348pub fn sort_key(due: &str) -> Option<(i32, u32, u32, u32, u32)> {
349    sort_key_at(due, Local::now().date_naive())
350}
351
352pub(crate) fn sort_key_at(due: &str, today: NaiveDate) -> Option<(i32, u32, u32, u32, u32)> {
353    if due.is_empty() {
354        return None;
355    }
356    let (date_part, time_part) = match due.split_once(' ') {
357        Some((date, time)) => (date, Some(time)),
358        None if due.contains(':') => ("", Some(due)),
359        None => (due, None),
360    };
361
362    let date = match date_part.split('-').collect::<Vec<_>>().as_slice() {
363        [y, m, d] => NaiveDate::from_ymd_opt(y.parse().ok()?, m.parse().ok()?, d.parse().ok()?)?,
364        [m, d] => NaiveDate::from_ymd_opt(today.year(), m.parse().ok()?, d.parse().ok()?)?,
365        [""] if time_part.is_some() => today,
366        _ => return None,
367    };
368    let time = match time_part {
369        Some(value) => parse_time(value)?,
370        None => NaiveTime::from_hms_opt(0, 0, 0)?,
371    };
372    Some((
373        date.year(),
374        date.month(),
375        date.day(),
376        time.hour(),
377        time.minute(),
378    ))
379}
380
381/// Current date and time for the status bar, in the configured order.
382pub fn now_string(date_format: &str) -> String {
383    let now = Local::now();
384    let date = match date_format {
385        "D-M-Y" => now.format("%d-%m-%Y"),
386        "M-D-Y" => now.format("%m-%d-%Y"),
387        _ => now.format("%Y-%m-%d"),
388    };
389    format!("{} {}", date, now.format("%H:%M"))
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use chrono::Local;
396
397    #[test]
398    fn parses_every_supported_shape() {
399        let cases = [
400            (
401                "buy milk [2026-08-06 09:00]",
402                "2026-08-06 09:00",
403                "buy milk",
404            ),
405            ("buy milk [2026-8-6]", "2026-8-6", "buy milk"),
406            ("buy milk [08-06 09:00]", "08-06 09:00", "buy milk"),
407            ("buy milk [8-6]", "8-6", "buy milk"),
408            ("buy milk [09:00]", "09:00", "buy milk"),
409        ];
410        for (input, due, rest) in cases {
411            assert_eq!(parse(input), (due.to_string(), rest.to_string()), "{input}");
412        }
413    }
414
415    #[test]
416    fn leaves_plain_descriptions_alone() {
417        assert_eq!(
418            parse("read [the] book"),
419            (String::new(), "read [the] book".to_string())
420        );
421        assert_eq!(
422            parse("plan [2030-01-02] launch"),
423            (String::new(), "plan [2030-01-02] launch".to_string()),
424            "only a trailing date token is due syntax"
425        );
426    }
427
428    #[test]
429    fn sorts_across_the_different_shapes() {
430        let year = Local::now().year();
431        assert_eq!(sort_key(""), None);
432        assert_eq!(sort_key("2030-01-02 09:30"), Some((2030, 1, 2, 9, 30)));
433        assert_eq!(sort_key("2030-01-02"), Some((2030, 1, 2, 0, 0)));
434        assert_eq!(sort_key("12-25"), Some((year, 12, 25, 0, 0)));
435        assert!(
436            sort_key("12-25") > sort_key("01-02"),
437            "December comes after January of the same year"
438        );
439        assert!(
440            sort_key(&format!("{}-01-01", year + 1)) > sort_key("12-25"),
441            "next year comes after this December"
442        );
443    }
444
445    #[test]
446    fn invalid_due_values_do_not_masquerade_as_today() {
447        for value in ["garbage", "2030-99-99", "25:99"] {
448            assert_eq!(sort_key(value), None, "{value}");
449            assert_eq!(display(value, "Y-M-D"), format!("[{value}]"));
450        }
451    }
452
453    #[test]
454    fn validates_bare_dates() {
455        assert!(is_valid(""));
456        assert!(is_valid("09:00"));
457        assert!(is_valid("2030-01-02"));
458        assert!(is_valid("01-02 09:00"));
459        // A date and a time that are each valid stay valid once joined.
460        assert!(is_valid("8-6"));
461        assert!(is_valid("8-6 14:30"));
462        assert!(is_valid("2026-8-6 14:30"));
463        assert!(!is_valid("next tuesday"));
464        assert!(!is_valid("2030/01/02"));
465        assert!(!is_valid("09:00 and more"));
466    }
467
468    #[test]
469    fn rejects_digits_that_are_not_a_real_date_or_time() {
470        assert!(!is_valid("2026-99-99"), "month 99");
471        assert!(!is_valid("2026-00-00"), "month and day zero");
472        assert!(!is_valid("2026-02-30"), "February has no 30th");
473        assert!(!is_valid("25:99"), "hour and minute out of range");
474        assert!(!is_valid("2026-08-10 30:70"));
475        assert!(is_valid("2028-02-29"), "a real leap day still passes");
476    }
477
478    #[test]
479    fn normalizes_shorthand_to_the_next_absolute_moment() {
480        let now = chrono::NaiveDate::from_ymd_opt(2026, 8, 8)
481            .unwrap()
482            .and_hms_opt(12, 0, 0)
483            .unwrap();
484
485        assert_eq!(normalize_for_write_at("8-8", now).unwrap(), "2026-08-08");
486        assert_eq!(normalize_for_write_at("8-7", now).unwrap(), "2027-08-07");
487        assert_eq!(
488            normalize_for_write_at("8-8 13:00", now).unwrap(),
489            "2026-08-08 13:00"
490        );
491        assert_eq!(
492            normalize_for_write_at("8-8 11:00", now).unwrap(),
493            "2027-08-08 11:00"
494        );
495        assert_eq!(
496            normalize_for_write_at("13:00", now).unwrap(),
497            "2026-08-08 13:00"
498        );
499        assert_eq!(
500            normalize_for_write_at("11:00", now).unwrap(),
501            "2026-08-09 11:00"
502        );
503    }
504
505    #[test]
506    fn absolute_due_values_are_canonicalized_without_rolling_forward() {
507        let now = chrono::NaiveDate::from_ymd_opt(2026, 8, 8)
508            .unwrap()
509            .and_hms_opt(12, 0, 0)
510            .unwrap();
511
512        assert_eq!(
513            normalize_for_write_at("2020-1-2", now).unwrap(),
514            "2020-01-02"
515        );
516        assert_eq!(
517            normalize_for_write_at("2020-1-2 03:04", now).unwrap(),
518            "2020-01-02 03:04"
519        );
520        assert!(normalize_for_write_at("2026-02-30", now).is_err());
521    }
522
523    #[test]
524    fn legacy_shorthand_is_frozen_to_the_migration_day_and_year() {
525        let now = chrono::NaiveDate::from_ymd_opt(2026, 8, 8)
526            .unwrap()
527            .and_hms_opt(12, 0, 0)
528            .unwrap();
529
530        assert_eq!(normalize_legacy_at("8-7", now).unwrap(), "2026-08-07");
531        assert_eq!(
532            normalize_legacy_at("11:00", now).unwrap(),
533            "2026-08-08 11:00"
534        );
535    }
536
537    #[test]
538    fn today_is_labelled() {
539        let today = Local::now().date_naive();
540        let today_s = today.format("%Y-%m-%d").to_string();
541        assert!(is_today(&today_s));
542        assert_eq!(display(&today_s, "Y-M-D"), "[Today]");
543        assert_eq!(
544            display(&format!("{today_s} 07:30"), "Y-M-D"),
545            "[Today 07:30]"
546        );
547        assert_eq!(display("09:00", "D-M-Y"), "[Today 09:00]");
548        assert_eq!(display("2000-01-01", "Y-M-D"), "[2000-01-01]");
549        assert_eq!(display("2000-01-01", "D-M-Y"), "[01-01-2000]");
550        assert_eq!(display("2000-01-01 09:30", "M-D-Y"), "[01-01-2000 09:30]");
551        assert_eq!(display("", "Y-M-D"), "");
552
553        let tom = (today + chrono::Duration::days(1))
554            .format("%Y-%m-%d")
555            .to_string();
556        let yest = (today - chrono::Duration::days(1))
557            .format("%Y-%m-%d")
558            .to_string();
559        assert_eq!(display(&tom, "Y-M-D"), "[Tomorrow]");
560        assert_eq!(
561            display(&format!("{tom} 09:00"), "Y-M-D"),
562            "[Tomorrow 09:00]"
563        );
564        assert_eq!(display(&yest, "Y-M-D"), "[Yesterday]");
565        assert_eq!(
566            display(&format!("{yest} 18:00"), "D-M-Y"),
567            "[Yesterday 18:00]"
568        );
569    }
570
571    #[test]
572    fn compact_display_keeps_relative_dates_and_shortens_calendar_dates() {
573        let today = NaiveDate::from_ymd_opt(2026, 8, 9).unwrap();
574
575        assert_eq!(display_compact_at("2026-08-09", "Y-M-D", today), "Today");
576        assert_eq!(
577            display_compact_at("2026-08-10 09:00", "Y-M-D", today),
578            "Tomorrow 09:00"
579        );
580        assert_eq!(
581            display_compact_at("2026-08-08 18:00", "Y-M-D", today),
582            "Yesterday 18:00"
583        );
584
585        assert_eq!(
586            display_compact_at("2026-08-14 18:25", "Y-M-D", today),
587            "08-14 18:25"
588        );
589        assert_eq!(display_compact_at("2026-08-14", "D-M-Y", today), "14-08");
590        assert_eq!(display_compact_at("2027-01-02", "Y-M-D", today), "27-01-02");
591        assert_eq!(display_compact_at("2027-01-02", "D-M-Y", today), "02-01-27");
592        assert_eq!(display_compact_at("", "M-D-Y", today), "");
593    }
594}