Skip to main content

jot_core/
due.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Minimal due-date parsing and rendering.
5//!
6//! Accepts the common forms a personal task manager needs:
7//!   - `today`, `tomorrow`
8//!   - `YYYY-MM-DD` (ISO 8601 calendar date)
9//!   - `MM-DD`      (current year implied)
10//!   - `DD.MM`      (German short form, current year implied)
11//!   - `DD.MM.YYYY` (German long form)
12//!
13//! Weekday names (`fri`, `next monday`) and relative offsets (`+3d`) are
14//! deferred to JOT-0032-69; the parser returns a structured error for
15//! anything it does not recognise so the CLI can surface a useful hint.
16//!
17//! Rendering produces short human-readable labels for a list view,
18//! with a side-channel severity so the CLI can colorise consistently.
19
20use chrono::{Datelike, Duration, NaiveDate};
21
22#[derive(Debug, thiserror::Error)]
23#[error("cannot parse due date '{input}': expected 'today', 'tomorrow', YYYY-MM-DD, MM-DD, DD.MM, or DD.MM.YYYY")]
24pub struct ParseDueError {
25    input: String,
26}
27
28/// Parse a `--due` argument against a reference 'today' date.
29pub fn parse_due(input: &str, today: NaiveDate) -> Result<NaiveDate, ParseDueError> {
30    let trimmed = input.trim();
31    let lower = trimmed.to_lowercase();
32    if lower == "today" {
33        return Ok(today);
34    }
35    if lower == "tomorrow" {
36        return Ok(today + Duration::days(1));
37    }
38
39    let year = today.year();
40    // Try the accepted explicit formats first, then the current-year shortcuts.
41    if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
42        return Ok(d);
43    }
44    if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%d.%m.%Y") {
45        return Ok(d);
46    }
47    if let Ok(d) = NaiveDate::parse_from_str(&format!("{year}-{trimmed}"), "%Y-%m-%d") {
48        return Ok(d);
49    }
50    if let Ok(d) = NaiveDate::parse_from_str(&format!("{trimmed}.{year}"), "%d.%m.%Y") {
51        return Ok(d);
52    }
53    Err(ParseDueError {
54        input: input.to_string(),
55    })
56}
57
58/// Relative severity of a due date compared to 'today'.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum DueSeverity {
61    Overdue,
62    Today,
63    Soon,
64    Later,
65}
66
67/// Label length mode. `Long` is the default reader-friendly form
68/// (`today`, `tomorrow`, `overdue 2d`); `Short` compresses to the
69/// terminal-friendly abbreviations matching joy-cli short mode
70/// (`tod`, `tmw`, `-2d`). Weekday and month-day renderings are the
71/// same in both modes because `%a` and `%b %-d` are already compact.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum LabelMode {
74    Long,
75    Short,
76}
77
78/// Render a due date as a label. Returns `(label, severity)` so the
79/// CLI can apply colors consistently.
80///
81/// Dates beyond the next-six-days window always include the year so
82/// entries from 2026 and 2027 are never mistaken for each other:
83/// Long mode uses ISO `YYYY-MM-DD`; Short mode uses `MM-DD` when the
84/// year matches today and `YYYY-MM-DD` otherwise.
85pub fn render_due(due: NaiveDate, today: NaiveDate, mode: LabelMode) -> (String, DueSeverity) {
86    let delta = (due - today).num_days();
87    match (delta, mode) {
88        (d, LabelMode::Long) if d < 0 => (format!("overdue {}d", d.abs()), DueSeverity::Overdue),
89        (d, LabelMode::Short) if d < 0 => (format!("-{}d", d.abs()), DueSeverity::Overdue),
90        (0, LabelMode::Long) => ("today".into(), DueSeverity::Today),
91        (0, LabelMode::Short) => ("tod".into(), DueSeverity::Today),
92        (1, LabelMode::Long) => ("tomorrow".into(), DueSeverity::Soon),
93        (1, LabelMode::Short) => ("tmw".into(), DueSeverity::Soon),
94        (d, _) if (2..=6).contains(&d) => (due.format("%a").to_string(), DueSeverity::Soon),
95        (_, LabelMode::Long) => (due.format("%Y-%m-%d").to_string(), DueSeverity::Later),
96        (_, LabelMode::Short) => {
97            if due.year() == today.year() {
98                (due.format("%m-%d").to_string(), DueSeverity::Later)
99            } else {
100                (due.format("%Y-%m-%d").to_string(), DueSeverity::Later)
101            }
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
111        NaiveDate::from_ymd_opt(y, m, day).unwrap()
112    }
113
114    #[test]
115    fn parses_today_tomorrow_iso() {
116        let today = d(2026, 4, 14);
117        assert_eq!(parse_due("today", today).unwrap(), today);
118        assert_eq!(parse_due("TOMORROW", today).unwrap(), d(2026, 4, 15));
119        assert_eq!(parse_due("2026-12-31", today).unwrap(), d(2026, 12, 31));
120    }
121
122    #[test]
123    fn parses_current_year_shortcuts() {
124        let today = d(2026, 4, 14);
125        assert_eq!(parse_due("04-25", today).unwrap(), d(2026, 4, 25));
126        assert_eq!(parse_due("25.04", today).unwrap(), d(2026, 4, 25));
127        assert_eq!(parse_due("25.04.2027", today).unwrap(), d(2027, 4, 25));
128    }
129
130    #[test]
131    fn rejects_unsupported_forms() {
132        let today = d(2026, 4, 14);
133        assert!(parse_due("friday", today).is_err());
134        assert!(parse_due("+3d", today).is_err());
135        assert!(parse_due("", today).is_err());
136    }
137
138    #[test]
139    fn renders_relative_labels_long() {
140        let today = d(2026, 4, 14); // Tuesday
141        let long = LabelMode::Long;
142        assert_eq!(render_due(today, today, long).0, "today");
143        assert_eq!(render_due(d(2026, 4, 15), today, long).0, "tomorrow");
144        assert_eq!(render_due(d(2026, 4, 17), today, long).0, "Fri");
145        assert_eq!(render_due(d(2026, 4, 25), today, long).0, "2026-04-25");
146        assert_eq!(render_due(d(2027, 4, 25), today, long).0, "2027-04-25");
147        assert_eq!(render_due(d(2026, 4, 13), today, long).0, "overdue 1d");
148    }
149
150    #[test]
151    fn renders_relative_labels_short() {
152        let today = d(2026, 4, 14);
153        let short = LabelMode::Short;
154        assert_eq!(render_due(today, today, short).0, "tod");
155        assert_eq!(render_due(d(2026, 4, 15), today, short).0, "tmw");
156        assert_eq!(render_due(d(2026, 4, 17), today, short).0, "Fri");
157        assert_eq!(render_due(d(2026, 4, 25), today, short).0, "04-25");
158        assert_eq!(render_due(d(2027, 4, 25), today, short).0, "2027-04-25");
159        assert_eq!(render_due(d(2026, 4, 13), today, short).0, "-1d");
160        assert_eq!(render_due(d(2026, 4, 1), today, short).0, "-13d");
161    }
162
163    #[test]
164    fn renders_severity_independent_of_mode() {
165        let today = d(2026, 4, 14);
166        for mode in [LabelMode::Long, LabelMode::Short] {
167            assert_eq!(render_due(today, today, mode).1, DueSeverity::Today);
168            assert_eq!(render_due(d(2026, 4, 15), today, mode).1, DueSeverity::Soon);
169            assert_eq!(
170                render_due(d(2026, 4, 13), today, mode).1,
171                DueSeverity::Overdue
172            );
173            assert_eq!(
174                render_due(d(2026, 5, 30), today, mode).1,
175                DueSeverity::Later
176            );
177        }
178    }
179}