use chrono::{Datelike, Duration, NaiveDate};
#[derive(Debug, thiserror::Error)]
#[error("cannot parse due date '{input}': expected 'today', 'tomorrow', YYYY-MM-DD, MM-DD, DD.MM, or DD.MM.YYYY")]
pub struct ParseDueError {
input: String,
}
pub fn parse_due(input: &str, today: NaiveDate) -> Result<NaiveDate, ParseDueError> {
let trimmed = input.trim();
let lower = trimmed.to_lowercase();
if lower == "today" {
return Ok(today);
}
if lower == "tomorrow" {
return Ok(today + Duration::days(1));
}
let year = today.year();
if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
return Ok(d);
}
if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%d.%m.%Y") {
return Ok(d);
}
if let Ok(d) = NaiveDate::parse_from_str(&format!("{year}-{trimmed}"), "%Y-%m-%d") {
return Ok(d);
}
if let Ok(d) = NaiveDate::parse_from_str(&format!("{trimmed}.{year}"), "%d.%m.%Y") {
return Ok(d);
}
Err(ParseDueError {
input: input.to_string(),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DueSeverity {
Overdue,
Today,
Soon,
Later,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LabelMode {
Long,
Short,
}
pub fn render_due(due: NaiveDate, today: NaiveDate, mode: LabelMode) -> (String, DueSeverity) {
let delta = (due - today).num_days();
match (delta, mode) {
(d, LabelMode::Long) if d < 0 => (format!("overdue {}d", d.abs()), DueSeverity::Overdue),
(d, LabelMode::Short) if d < 0 => (format!("-{}d", d.abs()), DueSeverity::Overdue),
(0, LabelMode::Long) => ("today".into(), DueSeverity::Today),
(0, LabelMode::Short) => ("tod".into(), DueSeverity::Today),
(1, LabelMode::Long) => ("tomorrow".into(), DueSeverity::Soon),
(1, LabelMode::Short) => ("tmw".into(), DueSeverity::Soon),
(d, _) if (2..=6).contains(&d) => (due.format("%a").to_string(), DueSeverity::Soon),
(_, LabelMode::Long) => (due.format("%Y-%m-%d").to_string(), DueSeverity::Later),
(_, LabelMode::Short) => {
if due.year() == today.year() {
(due.format("%m-%d").to_string(), DueSeverity::Later)
} else {
(due.format("%Y-%m-%d").to_string(), DueSeverity::Later)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn parses_today_tomorrow_iso() {
let today = d(2026, 4, 14);
assert_eq!(parse_due("today", today).unwrap(), today);
assert_eq!(parse_due("TOMORROW", today).unwrap(), d(2026, 4, 15));
assert_eq!(parse_due("2026-12-31", today).unwrap(), d(2026, 12, 31));
}
#[test]
fn parses_current_year_shortcuts() {
let today = d(2026, 4, 14);
assert_eq!(parse_due("04-25", today).unwrap(), d(2026, 4, 25));
assert_eq!(parse_due("25.04", today).unwrap(), d(2026, 4, 25));
assert_eq!(parse_due("25.04.2027", today).unwrap(), d(2027, 4, 25));
}
#[test]
fn rejects_unsupported_forms() {
let today = d(2026, 4, 14);
assert!(parse_due("friday", today).is_err());
assert!(parse_due("+3d", today).is_err());
assert!(parse_due("", today).is_err());
}
#[test]
fn renders_relative_labels_long() {
let today = d(2026, 4, 14); let long = LabelMode::Long;
assert_eq!(render_due(today, today, long).0, "today");
assert_eq!(render_due(d(2026, 4, 15), today, long).0, "tomorrow");
assert_eq!(render_due(d(2026, 4, 17), today, long).0, "Fri");
assert_eq!(render_due(d(2026, 4, 25), today, long).0, "2026-04-25");
assert_eq!(render_due(d(2027, 4, 25), today, long).0, "2027-04-25");
assert_eq!(render_due(d(2026, 4, 13), today, long).0, "overdue 1d");
}
#[test]
fn renders_relative_labels_short() {
let today = d(2026, 4, 14);
let short = LabelMode::Short;
assert_eq!(render_due(today, today, short).0, "tod");
assert_eq!(render_due(d(2026, 4, 15), today, short).0, "tmw");
assert_eq!(render_due(d(2026, 4, 17), today, short).0, "Fri");
assert_eq!(render_due(d(2026, 4, 25), today, short).0, "04-25");
assert_eq!(render_due(d(2027, 4, 25), today, short).0, "2027-04-25");
assert_eq!(render_due(d(2026, 4, 13), today, short).0, "-1d");
assert_eq!(render_due(d(2026, 4, 1), today, short).0, "-13d");
}
#[test]
fn renders_severity_independent_of_mode() {
let today = d(2026, 4, 14);
for mode in [LabelMode::Long, LabelMode::Short] {
assert_eq!(render_due(today, today, mode).1, DueSeverity::Today);
assert_eq!(render_due(d(2026, 4, 15), today, mode).1, DueSeverity::Soon);
assert_eq!(
render_due(d(2026, 4, 13), today, mode).1,
DueSeverity::Overdue
);
assert_eq!(
render_due(d(2026, 5, 30), today, mode).1,
DueSeverity::Later
);
}
}
}