phase 16: diary path conventions + nav commands
CI / cargo fmt --check (push) Successful in 27s
CI / cargo clippy (push) Successful in 1m14s
CI / cargo test (push) Successful in 1m20s

Hand-rolled date primitive (no chrono/time dep) plus the diary
command surface that closes the `:VimwikiMakeDiaryNote` ergonomics gap.

nuwiki-core/src/date.rs:
- `DiaryDate { year, month, day }` with strict `YYYY-MM-DD` parsing
  (rejects missing zero-padding, alternate separators, whitespace,
  impossible calendar dates including the leap-year exceptions).
- `next_day` / `prev_day` via Howard Hinnant's days-from-civil
  algorithm — proleptic Gregorian, walks across month/year/leap-day
  boundaries.
- `today_utc()` from `SystemTime::now()`. UTC-only by design: a
  local-tz implementation would need a tz database we don't ship.
- `Ord` by epoch days, `Display`/`format` round-trip with `parse`.

WikiConfig (P15 resolution):
- `diary_rel_path: String` (default `"diary"`) — mirrors vimwiki's
  `g:vimwiki_diary_rel_path`.
- `diary_index: String` (default `"diary"`) — the stem inside the
  diary subdir.
- `diary_dir()`, `diary_index_path()`, `diary_path_for(date)` —
  centralise the path math so commands stay declarative.
- `RawWiki` (init-options wire format) accepts both fields, so users
  with `g:vimwiki_diary_rel_path = "journal"` migrate cleanly.

WorkspaceIndex:
- New `diary_rel_path: Option<String>` field set by `Wiki::new`.
- `IndexedPage.diary_date: Option<DiaryDate>` — populated in `upsert`
  when the URI is under `<root>/<diary_rel_path>/` and the stem
  parses. `diary_date_for_uri` is the standalone classifier.
- The classifier *requires* the URI to be inside the diary subdir
  when a root is known; with no root it accepts any file whose stem
  is a date (kept for the ad-hoc test/scratch case).

nuwiki-lsp/src/diary.rs:
- `uri_for_date` / `index_uri` — bridge from `DiaryDate` and config
  to `file://` URIs.
- `list_entries(index) → Vec<DiaryEntry>` — ascending, plus
  `list_entries_filtered(year, month)` for calendar hooks.
- `next_entry` / `prev_entry` — strict less-than/greater-than
  comparison so navigating from a non-diary page (uses today as
  pivot) still moves to the right entry.
- `build_index_body` — newest-first, grouped under `== YYYY ==` /
  `=== Month ===` subheadings, emits `- [[diary/YYYY-MM-DD]]`. Matches
  `:VimwikiDiaryGenerateLinks`.
- `DiaryEntry` JSON shape: `{ date: "YYYY-MM-DD", uri }`. Manual
  serde impl so DiaryDate stays serde-free in nuwiki-core.

Commands (9 new):
- `nuwiki.diary.openToday` / `openYesterday` / `openTomorrow` →
  `{ uri, date }`.
- `nuwiki.diary.openIndex` → `{ uri }`.
- `nuwiki.diary.generateIndex` → WorkspaceEdit. Three paths:
  live document in `backend.documents` → full-doc replace; on-disk
  index → read + full-doc replace; missing → CreateFile + insert.
- `nuwiki.diary.next` / `prev` → pivots on the current page's
  `diary_date`, falls back to today.
- `nuwiki.diary.listEntries` → optional `year` / `month` filter.
- `nuwiki.diary.openForDate` → strict `YYYY-MM-DD` arg.

All commands accept an optional `uri` to scope to a particular wiki
when multi-wiki lands; the dispatcher falls back to `default_wiki()`.

Tests: 35 new in `phase16_diary.rs` (date parser exhaustively,
calendar arithmetic across boundaries + leaps, path conventions,
upsert classification including the "date filename outside diary dir"
no-op case, list/filter/nav, index body grouping, serde shape, command
list completeness). Total 309 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 21:04:55 +00:00
parent ce3ac8c4f8
commit e75ad6ca89
10 changed files with 1089 additions and 2 deletions
+166
View File
@@ -0,0 +1,166 @@
//! Date primitives for the diary subsystem (Phase 16).
//!
//! v1.1 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()`. Phase 16's 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
}
/// 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,
}
}