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