1use 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
32pub 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 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
68fn 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
82fn 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 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
97fn 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum DueSeverity {
115 Overdue,
116 Today,
117 Soon,
118 Later,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum LabelMode {
128 Long,
129 Short,
130}
131
132pub 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); 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 assert_eq!(parse_due("tue", today).unwrap(), d(2026, 4, 21));
193 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()); assert!(parse_due("+3x", today).is_err()); assert!(parse_due("+w", today).is_err()); assert!(parse_due("-3d", today).is_err()); assert!(parse_due("", today).is_err());
216 }
217
218 #[test]
219 fn renders_relative_labels_long() {
220 let today = d(2026, 4, 14); 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}