//! Date primitives for the diary subsystem. //! //! This crate deliberately doesn't pull in `chrono` or `time`. The diary feature //! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which //! is small enough that the dependency cost outweighs the convenience. //! //! Date math uses Howard Hinnant's days-from-civil algorithm //! () — proleptic //! Gregorian, valid for any year representable in `i32`. use std::cmp::Ordering; use std::fmt; use std::time::{SystemTime, UNIX_EPOCH}; /// A calendar day, decoupled from any time zone or wall-clock context. /// /// Validation: `parse` and `from_ymd` reject impossible dates (month 0, /// day 0, day > days-in-month, etc.). The struct fields are public so /// callers can read them, but constructing one with bogus values bypasses /// validation — use the constructors. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DiaryDate { pub year: i32, pub month: u8, pub day: u8, } impl DiaryDate { /// Build a date from its components, validating the calendar. pub fn from_ymd(year: i32, month: u8, day: u8) -> Option { if !(1..=12).contains(&month) { return None; } if day < 1 || day > days_in_month(year, month) { return None; } Some(Self { year, month, day }) } /// Parse a strict `YYYY-MM-DD` string. Rejects any deviation — extra /// whitespace, missing zero-padding, alternate separators, etc. pub fn parse(s: &str) -> Option { let b = s.as_bytes(); if b.len() != 10 || b[4] != b'-' || b[7] != b'-' { return None; } let y = parse_digits(&b[0..4])?; let m = parse_digits(&b[5..7])? as u8; let d = parse_digits(&b[8..10])? as u8; Self::from_ymd(y as i32, m, d) } /// Format as `YYYY-MM-DD`. Years outside 0..=9999 still render /// numerically; the diary use case never produces those. pub fn format(&self) -> String { format!("{:04}-{:02}-{:02}", self.year, self.month, self.day) } /// UTC "today" computed from `SystemTime::now()`. The diary /// commands intentionally use UTC — local-time semantics depend on a /// timezone DB we don't ship and would surprise users crossing DST /// boundaries. pub fn today_utc() -> Self { let secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let days = (secs / 86_400) as i64; from_days(days) } pub fn next_day(&self) -> Self { from_days(to_days(self.year, self.month, self.day) + 1) } pub fn prev_day(&self) -> Self { from_days(to_days(self.year, self.month, self.day) - 1) } /// Internal — exposed for tests that want to verify ordering against /// the days-from-epoch representation. pub fn to_days_epoch(&self) -> i64 { to_days(self.year, self.month, self.day) } } impl PartialOrd for DiaryDate { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for DiaryDate { fn cmp(&self, other: &Self) -> Ordering { self.to_days_epoch().cmp(&other.to_days_epoch()) } } impl fmt::Display for DiaryDate { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.format()) } } fn parse_digits(b: &[u8]) -> Option { if b.is_empty() { return None; } let mut n: u32 = 0; for ch in b { if !ch.is_ascii_digit() { return None; } n = n.checked_mul(10)?.checked_add((*ch - b'0') as u32)?; } Some(n) } fn is_leap(year: i32) -> bool { (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) } fn days_in_month(year: i32, month: u8) -> u8 { match month { 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, 4 | 6 | 9 | 11 => 30, 2 => { if is_leap(year) { 29 } else { 28 } } _ => 0, } } /// Howard Hinnant's `days_from_civil`: 1970-01-01 → 0. fn to_days(year: i32, month: u8, day: u8) -> i64 { let y = year as i64 - if (month as i64) <= 2 { 1 } else { 0 }; let era = y.div_euclid(400); let yoe = y.rem_euclid(400); let m = month as i64; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468 } // ============================================================ // Diary frequency / period // ============================================================ // // `DiaryFrequency` is the user-configured cadence (mirrors vimwiki's // `diary_frequency` setting). `DiaryPeriod` is the *concrete* diary // entry — a specific day, ISO week, calendar month, or year — that // the LSP commands compute and the renderer addresses. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DiaryFrequency { Daily, Weekly, Monthly, Yearly, } impl DiaryFrequency { /// Parse the string form used in `WikiConfig.diary_frequency`. /// Falls back to `Daily` for unrecognised values — vimwiki's /// permissive behaviour for stale configs. pub fn parse(s: &str) -> Self { match s.trim().to_ascii_lowercase().as_str() { "weekly" | "week" => Self::Weekly, "monthly" | "month" => Self::Monthly, "yearly" | "year" => Self::Yearly, _ => Self::Daily, } } pub fn as_str(self) -> &'static str { match self { Self::Daily => "daily", Self::Weekly => "weekly", Self::Monthly => "monthly", Self::Yearly => "yearly", } } } /// A specific diary entry, identified by its calendar period. The four /// variants share the same `format` / `parse` / `next` / `prev` / /// `today_utc` surface so the LSP dispatcher can stay frequency-agnostic. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DiaryPeriod { Day(DiaryDate), /// ISO 8601 week — year/week pair, week ∈ 1..=53. Week { iso_year: i32, iso_week: u8, }, Month { year: i32, month: u8, }, Year { year: i32, }, } impl DiaryPeriod { /// "Now" for the given frequency, in UTC. See `DiaryDate::today_utc` /// for the timezone rationale. pub fn today_utc(freq: DiaryFrequency) -> Self { let today = DiaryDate::today_utc(); match freq { DiaryFrequency::Daily => Self::Day(today), DiaryFrequency::Weekly => Self::week_containing(today), DiaryFrequency::Monthly => Self::Month { year: today.year, month: today.month, }, DiaryFrequency::Yearly => Self::Year { year: today.year }, } } /// File-stem format — also the format vimwiki uses for diary link /// targets (`[[diary:2026-W19]]`, `[[diary:2026-05]]`, etc.). /// /// Day(2026-05-12) → `2026-05-12` /// Week(2026, 19) → `2026-W19` /// Month(2026, 5) → `2026-05` /// Year(2026) → `2026` pub fn format(&self) -> String { match self { Self::Day(d) => d.format(), Self::Week { iso_year, iso_week } => format!("{iso_year:04}-W{iso_week:02}"), Self::Month { year, month } => format!("{year:04}-{month:02}"), Self::Year { year } => format!("{year:04}"), } } /// Inverse of `format` — recognise any of the four stems. Returns /// `None` for inputs that don't match any flavour. pub fn parse(s: &str) -> Option { // Day: `YYYY-MM-DD` if let Some(d) = DiaryDate::parse(s) { return Some(Self::Day(d)); } let b = s.as_bytes(); // Week: `YYYY-Www` if b.len() == 8 && b[4] == b'-' && (b[5] == b'W' || b[5] == b'w') { let y = parse_digits(&b[0..4])? as i32; let w = parse_digits(&b[6..8])? as u8; if (1..=53).contains(&w) { return Some(Self::Week { iso_year: y, iso_week: w, }); } } // Month: `YYYY-MM` if b.len() == 7 && b[4] == b'-' { let y = parse_digits(&b[0..4])? as i32; let m = parse_digits(&b[5..7])? as u8; if (1..=12).contains(&m) { return Some(Self::Month { year: y, month: m }); } } // Year: `YYYY` if b.len() == 4 { let y = parse_digits(&b[0..4])? as i32; return Some(Self::Year { year: y }); } None } pub fn next(&self) -> Self { match *self { Self::Day(d) => Self::Day(d.next_day()), Self::Week { iso_year, iso_week } => { // Step forward one week: take the Monday of this ISO week, // add 7 days, recompute the (year, week) it falls in. let mon = monday_of_iso_week(iso_year, iso_week); let next_mon = from_days(to_days(mon.year, mon.month, mon.day) + 7); let (iso_year, iso_week) = iso_year_week(&next_mon); Self::Week { iso_year, iso_week } } Self::Month { year, month } => { if month == 12 { Self::Month { year: year + 1, month: 1, } } else { Self::Month { year, month: month + 1, } } } Self::Year { year } => Self::Year { year: year + 1 }, } } pub fn prev(&self) -> Self { match *self { Self::Day(d) => Self::Day(d.prev_day()), Self::Week { iso_year, iso_week } => { let mon = monday_of_iso_week(iso_year, iso_week); let prev_mon = from_days(to_days(mon.year, mon.month, mon.day) - 7); let (iso_year, iso_week) = iso_year_week(&prev_mon); Self::Week { iso_year, iso_week } } Self::Month { year, month } => { if month == 1 { Self::Month { year: year - 1, month: 12, } } else { Self::Month { year, month: month - 1, } } } Self::Year { year } => Self::Year { year: year - 1 }, } } /// Compute the ISO week containing the given calendar date. pub fn week_containing(d: DiaryDate) -> Self { let (iso_year, iso_week) = iso_year_week(&d); Self::Week { iso_year, iso_week } } /// First calendar day of this period — useful for chronological /// sorting across mixed-frequency entries. /// /// Day(d) → d /// Week(y,w) → Monday of that ISO week /// Month(y,m) → 1st of that month /// Year(y) → Jan 1st of that year pub fn first_day(&self) -> DiaryDate { match *self { Self::Day(d) => d, Self::Week { iso_year, iso_week } => monday_of_iso_week(iso_year, iso_week), Self::Month { year, month } => { DiaryDate::from_ymd(year, month, 1).expect("validated month") } Self::Year { year } => DiaryDate::from_ymd(year, 1, 1).expect("Jan 1st always valid"), } } } impl fmt::Display for DiaryPeriod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.format()) } } /// How a *weekly* diary entry is named. /// /// `Iso` — ISO-week label `YYYY-Www` (nuwiki's original scheme; the week /// always begins on Monday, `week_start` is ignored). /// `Date` — the week-start day's calendar date `YYYY-MM-DD`, matching /// upstream vimwiki; honours `week_start`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WeeklyStyle { Iso, Date, } impl WeeklyStyle { /// Parse the config string. `date`/`vimwiki` → `Date`; anything else /// (including `iso`/`week`/empty) → `Iso`. pub fn parse(s: &str) -> Self { match s.trim().to_ascii_lowercase().as_str() { "date" | "vimwiki" | "day" => Self::Date, _ => Self::Iso, } } } /// Day a week begins on, in ISO numbering (Monday = 1 … Sunday = 7). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct WeekStart(u8); impl WeekStart { /// Parse a weekday name (`monday`..`sunday`); unknown → Monday. pub fn parse(s: &str) -> Self { let n = match s.trim().to_ascii_lowercase().as_str() { "tuesday" => 2, "wednesday" => 3, "thursday" => 4, "friday" => 5, "saturday" => 6, "sunday" => 7, _ => 1, // monday (default) }; Self(n) } pub fn monday() -> Self { Self(1) } fn iso(self) -> i64 { self.0 as i64 } } /// Diary navigation policy: frequency plus, for weekly diaries, the naming /// scheme and week-start day. Built from a wiki's `diary_*` config so the /// LSP commands can compute today / next / prev without re-deriving the /// rules. `Daily`/`Monthly`/`Yearly` ignore the weekly fields. #[derive(Debug, Clone, Copy)] pub struct DiaryCalendar { pub freq: DiaryFrequency, pub weekly_style: WeeklyStyle, pub week_start: WeekStart, } impl DiaryCalendar { pub fn new(freq: DiaryFrequency, weekly_style: WeeklyStyle, week_start: WeekStart) -> Self { Self { freq, weekly_style, week_start, } } fn is_weekly_date(&self) -> bool { self.freq == DiaryFrequency::Weekly && self.weekly_style == WeeklyStyle::Date } /// "Now" as a diary period for this calendar. Date-mode weekly snaps /// today back to the most recent week-start day (a plain `Day`, so the /// file is `YYYY-MM-DD` like upstream); everything else defers to /// [`DiaryPeriod::today_utc`]. pub fn today(&self) -> DiaryPeriod { if self.is_weekly_date() { DiaryPeriod::Day(self.week_start_of(DiaryDate::today_utc())) } else { DiaryPeriod::today_utc(self.freq) } } /// The period one cadence-step after `p`. pub fn next(&self, p: DiaryPeriod) -> DiaryPeriod { self.step(p, 1) } /// The period one cadence-step before `p`. pub fn prev(&self, p: DiaryPeriod) -> DiaryPeriod { self.step(p, -1) } fn step(&self, p: DiaryPeriod, dir: i64) -> DiaryPeriod { // Date-mode weekly: the period is a `Day` on a week-start; a step is // ±7 days (re-snapped so an off-week-start pivot still lands cleanly). if self.is_weekly_date() { if let DiaryPeriod::Day(d) = p { let start = self.week_start_of(d); return DiaryPeriod::Day(add_days(start, 7 * dir)); } } if dir >= 0 { p.next() } else { p.prev() } } /// The most recent `week_start` weekday on or before `d`. fn week_start_of(&self, d: DiaryDate) -> DiaryDate { let wd = iso_weekday(&d) as i64; // 1..=7 let back = (wd - self.week_start.iso()).rem_euclid(7); add_days(d, -back) } } /// Shift a calendar date by `delta` days (may be negative). fn add_days(d: DiaryDate, delta: i64) -> DiaryDate { from_days(to_days(d.year, d.month, d.day) + delta) } /// Day-of-week with Monday=1 … Sunday=7 (ISO 8601). fn iso_weekday(d: &DiaryDate) -> u8 { // Zeller-style via days-from-epoch: 1970-01-01 was a Thursday. let days = to_days(d.year, d.month, d.day); // (days + 3) mod 7 puts Monday at 0 … Sunday at 6; shift to 1..=7. let r = (days + 3).rem_euclid(7); (r as u8) + 1 } /// ISO 8601 `(year, week)` for a calendar date. The ISO year may differ /// from the calendar year for early January / late December dates. fn iso_year_week(d: &DiaryDate) -> (i32, u8) { // Standard algorithm: shift to the Thursday of the same ISO week, // then ISO year = thursday.year, ISO week = ordinal_day(thursday) / 7 + 1. let weekday = iso_weekday(d) as i64; // 1..=7 let days = to_days(d.year, d.month, d.day); let thursday = from_days(days + 4 - weekday); let jan1 = to_days(thursday.year, 1, 1); let ordinal = to_days(thursday.year, thursday.month, thursday.day) - jan1 + 1; let week = ((ordinal - 1) / 7 + 1) as u8; (thursday.year, week) } /// Date of the Monday that opens the given ISO `(year, week)`. fn monday_of_iso_week(iso_year: i32, iso_week: u8) -> DiaryDate { // Find Jan 4th of iso_year — always in ISO week 1 by definition. let jan4 = DiaryDate { year: iso_year, month: 1, day: 4, }; let jan4_weekday = iso_weekday(&jan4) as i64; // 1..=7 let week1_monday_days = to_days(jan4.year, jan4.month, jan4.day) - (jan4_weekday - 1); let target = week1_monday_days + (iso_week as i64 - 1) * 7; from_days(target) } /// Inverse of [`to_days`]. fn from_days(days: i64) -> DiaryDate { let z = days + 719_468; let era = if z >= 0 { z } else { z - 146_096 } / 146_097; let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe as i64 + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = (doy - (153 * mp + 2) / 5 + 1) as u8; let m_int = if mp < 10 { mp + 3 } else { mp - 9 }; let year = y + if m_int <= 2 { 1 } else { 0 }; DiaryDate { year: year as i32, month: m_int as u8, day: d, } }