1use 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
28pub 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum DueSeverity {
61 Overdue,
62 Today,
63 Soon,
64 Later,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum LabelMode {
74 Long,
75 Short,
76}
77
78pub 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); 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}