Skip to main content

jyn_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//!   - weekday names: `fri`, `friday`, `next monday` (next future occurrence)
9//!   - relative offsets: `+3d`, `3d`, `+1w`, `2w` (added to today)
10//!   - `YYYY-MM-DD` (ISO 8601 calendar date)
11//!   - `MM-DD`      (current year implied)
12//!   - `DD.MM`      (German short form, current year implied)
13//!   - `DD.MM.YYYY` (German long form)
14//!
15//! Weekday names always resolve to the next matching day in the future,
16//! never today (so `friday` on a Friday means the following Friday); the
17//! optional `next ` prefix is accepted as a synonym. The parser returns a
18//! structured error for anything it does not recognise so the CLI can
19//! surface a useful hint. See JOT-0032-69.
20//!
21//! Rendering produces short human-readable labels for a list view,
22//! with a side-channel severity so the CLI can colorise consistently.
23
24use chrono::{Datelike, Duration, NaiveDate, Weekday};
25
26#[derive(Debug, thiserror::Error)]
27#[error("cannot parse due date '{input}': expected 'today', 'tomorrow', a weekday (e.g. 'fri', 'next monday'), an offset (e.g. '+3d', '2w'), YYYY-MM-DD, MM-DD, DD.MM, or DD.MM.YYYY")]
28pub struct ParseDueError {
29    input: String,
30}
31
32/// Parse a `--due` argument against a reference 'today' date.
33pub fn parse_due(input: &str, today: NaiveDate) -> Result<NaiveDate, ParseDueError> {
34    let trimmed = input.trim();
35    let lower = trimmed.to_lowercase();
36    if lower == "today" {
37        return Ok(today);
38    }
39    if lower == "tomorrow" {
40        return Ok(today + Duration::days(1));
41    }
42    if let Some(date) = parse_weekday(&lower, today) {
43        return Ok(date);
44    }
45    if let Some(date) = parse_offset(&lower, today) {
46        return Ok(date);
47    }
48
49    let year = today.year();
50    // Try the accepted explicit formats first, then the current-year shortcuts.
51    if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
52        return Ok(d);
53    }
54    if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%d.%m.%Y") {
55        return Ok(d);
56    }
57    if let Ok(d) = NaiveDate::parse_from_str(&format!("{year}-{trimmed}"), "%Y-%m-%d") {
58        return Ok(d);
59    }
60    if let Ok(d) = NaiveDate::parse_from_str(&format!("{trimmed}.{year}"), "%d.%m.%Y") {
61        return Ok(d);
62    }
63    Err(ParseDueError {
64        input: input.to_string(),
65    })
66}
67
68/// Map a full or three-letter weekday name to a `chrono::Weekday`.
69fn weekday_from_name(s: &str) -> Option<Weekday> {
70    Some(match s {
71        "monday" | "mon" => Weekday::Mon,
72        "tuesday" | "tue" => Weekday::Tue,
73        "wednesday" | "wed" => Weekday::Wed,
74        "thursday" | "thu" => Weekday::Thu,
75        "friday" | "fri" => Weekday::Fri,
76        "saturday" | "sat" => Weekday::Sat,
77        "sunday" | "sun" => Weekday::Sun,
78        _ => return None,
79    })
80}
81
82/// Parse a weekday name into the next matching date strictly after today.
83/// Accepts an optional `next ` prefix as a synonym. Returns `None` when
84/// the input is not a weekday name.
85fn parse_weekday(lower: &str, today: NaiveDate) -> Option<NaiveDate> {
86    let name = lower.strip_prefix("next ").unwrap_or(lower).trim();
87    let target = weekday_from_name(name)?;
88    let today_idx = today.weekday().num_days_from_monday();
89    let target_idx = target.num_days_from_monday();
90    // 0 means the weekday is today; map it to a week out so a weekday
91    // name is always a future date (1..=7), never today.
92    let raw = (target_idx + 7 - today_idx) % 7;
93    let ahead = if raw == 0 { 7 } else { raw };
94    Some(today + Duration::days(ahead as i64))
95}
96
97/// Parse a relative offset like `+3d`, `3d`, `+1w`, or `2w` into a date
98/// `n` days/weeks after today. The leading `+` is optional. Returns
99/// `None` when the input is not a recognised offset.
100fn parse_offset(lower: &str, today: NaiveDate) -> Option<NaiveDate> {
101    let body = lower.strip_prefix('+').unwrap_or(lower);
102    let (num, unit) = body.split_at(body.len().checked_sub(1)?);
103    // u32 rejects empty, negative, and non-digit numerators.
104    let n = i64::from(num.parse::<u32>().ok()?);
105    match unit {
106        "d" => Some(today + Duration::days(n)),
107        "w" => Some(today + Duration::weeks(n)),
108        _ => None,
109    }
110}
111
112/// Relative severity of a due date compared to 'today'.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum DueSeverity {
115    Overdue,
116    Today,
117    Soon,
118    Later,
119}
120
121/// Label length mode. `Long` is the default reader-friendly form
122/// (`today`, `tomorrow`, `overdue 2d`); `Short` compresses to the
123/// terminal-friendly abbreviations matching joy-cli short mode
124/// (`tod`, `tmw`, `-2d`). Weekday and month-day renderings are the
125/// same in both modes because `%a` and `%b %-d` are already compact.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum LabelMode {
128    Long,
129    Short,
130}
131
132/// Render a due date as a label. Returns `(label, severity)` so the
133/// CLI can apply colors consistently.
134///
135/// Dates beyond the next-six-days window always include the year so
136/// entries from 2026 and 2027 are never mistaken for each other:
137/// Long mode uses ISO `YYYY-MM-DD`; Short mode uses `MM-DD` when the
138/// year matches today and `YYYY-MM-DD` otherwise.
139pub fn render_due(due: NaiveDate, today: NaiveDate, mode: LabelMode) -> (String, DueSeverity) {
140    let delta = (due - today).num_days();
141    match (delta, mode) {
142        (d, LabelMode::Long) if d < 0 => (format!("overdue {}d", d.abs()), DueSeverity::Overdue),
143        (d, LabelMode::Short) if d < 0 => (format!("-{}d", d.abs()), DueSeverity::Overdue),
144        (0, LabelMode::Long) => ("today".into(), DueSeverity::Today),
145        (0, LabelMode::Short) => ("tod".into(), DueSeverity::Today),
146        (1, LabelMode::Long) => ("tomorrow".into(), DueSeverity::Soon),
147        (1, LabelMode::Short) => ("tmw".into(), DueSeverity::Soon),
148        (d, _) if (2..=6).contains(&d) => (due.format("%a").to_string(), DueSeverity::Soon),
149        (_, LabelMode::Long) => (due.format("%Y-%m-%d").to_string(), DueSeverity::Later),
150        (_, LabelMode::Short) => {
151            if due.year() == today.year() {
152                (due.format("%m-%d").to_string(), DueSeverity::Later)
153            } else {
154                (due.format("%Y-%m-%d").to_string(), DueSeverity::Later)
155            }
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
165        NaiveDate::from_ymd_opt(y, m, day).unwrap()
166    }
167
168    #[test]
169    fn parses_today_tomorrow_iso() {
170        let today = d(2026, 4, 14);
171        assert_eq!(parse_due("today", today).unwrap(), today);
172        assert_eq!(parse_due("TOMORROW", today).unwrap(), d(2026, 4, 15));
173        assert_eq!(parse_due("2026-12-31", today).unwrap(), d(2026, 12, 31));
174    }
175
176    #[test]
177    fn parses_current_year_shortcuts() {
178        let today = d(2026, 4, 14);
179        assert_eq!(parse_due("04-25", today).unwrap(), d(2026, 4, 25));
180        assert_eq!(parse_due("25.04", today).unwrap(), d(2026, 4, 25));
181        assert_eq!(parse_due("25.04.2027", today).unwrap(), d(2027, 4, 25));
182    }
183
184    #[test]
185    fn parses_weekday_names() {
186        let today = d(2026, 4, 14); // Tuesday
187                                    // Upcoming weekday within the week.
188        assert_eq!(parse_due("friday", today).unwrap(), d(2026, 4, 17));
189        assert_eq!(parse_due("fri", today).unwrap(), d(2026, 4, 17));
190        assert_eq!(parse_due("Sunday", today).unwrap(), d(2026, 4, 19));
191        // Today's own weekday resolves to the following week, never today.
192        assert_eq!(parse_due("tue", today).unwrap(), d(2026, 4, 21));
193        // The optional "next " prefix is accepted as a synonym.
194        assert_eq!(parse_due("next monday", today).unwrap(), d(2026, 4, 20));
195        assert_eq!(parse_due("mon", today).unwrap(), d(2026, 4, 20));
196    }
197
198    #[test]
199    fn parses_relative_offsets() {
200        let today = d(2026, 4, 14);
201        assert_eq!(parse_due("+3d", today).unwrap(), d(2026, 4, 17));
202        assert_eq!(parse_due("3d", today).unwrap(), d(2026, 4, 17));
203        assert_eq!(parse_due("+1w", today).unwrap(), d(2026, 4, 21));
204        assert_eq!(parse_due("2w", today).unwrap(), d(2026, 4, 28));
205    }
206
207    #[test]
208    fn rejects_unsupported_forms() {
209        let today = d(2026, 4, 14);
210        assert!(parse_due("someday", today).is_err());
211        assert!(parse_due("3", today).is_err()); // offset needs a unit
212        assert!(parse_due("+3x", today).is_err()); // unknown unit
213        assert!(parse_due("+w", today).is_err()); // missing count
214        assert!(parse_due("-3d", today).is_err()); // no negative offsets
215        assert!(parse_due("", today).is_err());
216    }
217
218    #[test]
219    fn renders_relative_labels_long() {
220        let today = d(2026, 4, 14); // Tuesday
221        let long = LabelMode::Long;
222        assert_eq!(render_due(today, today, long).0, "today");
223        assert_eq!(render_due(d(2026, 4, 15), today, long).0, "tomorrow");
224        assert_eq!(render_due(d(2026, 4, 17), today, long).0, "Fri");
225        assert_eq!(render_due(d(2026, 4, 25), today, long).0, "2026-04-25");
226        assert_eq!(render_due(d(2027, 4, 25), today, long).0, "2027-04-25");
227        assert_eq!(render_due(d(2026, 4, 13), today, long).0, "overdue 1d");
228    }
229
230    #[test]
231    fn renders_relative_labels_short() {
232        let today = d(2026, 4, 14);
233        let short = LabelMode::Short;
234        assert_eq!(render_due(today, today, short).0, "tod");
235        assert_eq!(render_due(d(2026, 4, 15), today, short).0, "tmw");
236        assert_eq!(render_due(d(2026, 4, 17), today, short).0, "Fri");
237        assert_eq!(render_due(d(2026, 4, 25), today, short).0, "04-25");
238        assert_eq!(render_due(d(2027, 4, 25), today, short).0, "2027-04-25");
239        assert_eq!(render_due(d(2026, 4, 13), today, short).0, "-1d");
240        assert_eq!(render_due(d(2026, 4, 1), today, short).0, "-13d");
241    }
242
243    #[test]
244    fn renders_severity_independent_of_mode() {
245        let today = d(2026, 4, 14);
246        for mode in [LabelMode::Long, LabelMode::Short] {
247            assert_eq!(render_due(today, today, mode).1, DueSeverity::Today);
248            assert_eq!(render_due(d(2026, 4, 15), today, mode).1, DueSeverity::Soon);
249            assert_eq!(
250                render_due(d(2026, 4, 13), today, mode).1,
251                DueSeverity::Overdue
252            );
253            assert_eq!(
254                render_due(d(2026, 5, 30), today, mode).1,
255                DueSeverity::Later
256            );
257        }
258    }
259}