Files
nuwiki/crates/nuwiki-core/src/date.rs
T

416 lines
14 KiB
Rust
Raw Normal View History

//! 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
//! (<https://howardhinnant.github.io/date_algorithms.html>) — 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<Self> {
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<Self> {
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<Ordering> {
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<u32> {
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<Self> {
// 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())
}
}
/// 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,
}
}