e75ad6ca89
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>
379 lines
11 KiB
Rust
379 lines
11 KiB
Rust
//! Phase 16: diary date primitives + path conventions + nav helpers +
|
|
//! IndexedPage diary classification.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use nuwiki_core::date::DiaryDate;
|
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
use nuwiki_core::syntax::SyntaxPlugin;
|
|
use nuwiki_lsp::config::WikiConfig;
|
|
use nuwiki_lsp::diary;
|
|
use nuwiki_lsp::index::WorkspaceIndex;
|
|
use tower_lsp::lsp_types::Url;
|
|
|
|
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
VimwikiSyntax::new().parse(src)
|
|
}
|
|
|
|
fn wiki_cfg(root: &str) -> WikiConfig {
|
|
let mut cfg = WikiConfig::from_root(PathBuf::from(root));
|
|
cfg.diary_rel_path = "diary".into();
|
|
cfg.diary_index = "diary".into();
|
|
cfg
|
|
}
|
|
|
|
fn build_index_with_entries(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
|
let mut idx =
|
|
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
|
for d in dates {
|
|
let path = format!("{root}/diary/{d}.wiki");
|
|
let uri = Url::from_file_path(&path).unwrap();
|
|
idx.upsert(uri, &parse("= entry =\n"));
|
|
}
|
|
idx
|
|
}
|
|
|
|
// ===== DiaryDate parser =====
|
|
|
|
#[test]
|
|
fn parse_accepts_canonical_iso8601() {
|
|
let d = DiaryDate::parse("2026-05-11").unwrap();
|
|
assert_eq!(d.year, 2026);
|
|
assert_eq!(d.month, 5);
|
|
assert_eq!(d.day, 11);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_missing_zero_padding() {
|
|
assert!(DiaryDate::parse("2026-5-11").is_none());
|
|
assert!(DiaryDate::parse("2026-05-1").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_alternate_separators() {
|
|
assert!(DiaryDate::parse("2026/05/11").is_none());
|
|
assert!(DiaryDate::parse("2026.05.11").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_extra_whitespace() {
|
|
assert!(DiaryDate::parse(" 2026-05-11").is_none());
|
|
assert!(DiaryDate::parse("2026-05-11 ").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_rejects_invalid_calendar_dates() {
|
|
assert!(DiaryDate::parse("2026-13-01").is_none(), "month 13");
|
|
assert!(DiaryDate::parse("2026-02-30").is_none(), "feb 30");
|
|
assert!(DiaryDate::parse("2026-04-31").is_none(), "april 31");
|
|
assert!(DiaryDate::parse("2026-00-10").is_none(), "month 0");
|
|
assert!(DiaryDate::parse("2026-05-00").is_none(), "day 0");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_accepts_leap_day_in_leap_year() {
|
|
assert!(DiaryDate::parse("2024-02-29").is_some(), "2024 is leap");
|
|
assert!(DiaryDate::parse("2023-02-29").is_none(), "2023 not leap");
|
|
assert!(DiaryDate::parse("2000-02-29").is_some(), "div by 400");
|
|
assert!(
|
|
DiaryDate::parse("1900-02-29").is_none(),
|
|
"div by 100 not 400"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn format_round_trip() {
|
|
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
assert_eq!(d.format(), "2026-05-11");
|
|
assert_eq!(DiaryDate::parse(&d.format()), Some(d));
|
|
}
|
|
|
|
// ===== Date arithmetic =====
|
|
|
|
#[test]
|
|
fn next_day_wraps_month_boundary() {
|
|
let last_of_jan = DiaryDate::from_ymd(2026, 1, 31).unwrap();
|
|
assert_eq!(
|
|
last_of_jan.next_day(),
|
|
DiaryDate::from_ymd(2026, 2, 1).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn next_day_wraps_year_boundary() {
|
|
let nye = DiaryDate::from_ymd(2025, 12, 31).unwrap();
|
|
assert_eq!(nye.next_day(), DiaryDate::from_ymd(2026, 1, 1).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn prev_day_walks_back_across_month() {
|
|
let first_of_mar = DiaryDate::from_ymd(2026, 3, 1).unwrap();
|
|
assert_eq!(
|
|
first_of_mar.prev_day(),
|
|
DiaryDate::from_ymd(2026, 2, 28).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn prev_day_walks_back_across_leap() {
|
|
let first_of_mar = DiaryDate::from_ymd(2024, 3, 1).unwrap();
|
|
assert_eq!(
|
|
first_of_mar.prev_day(),
|
|
DiaryDate::from_ymd(2024, 2, 29).unwrap()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ordering_is_chronological() {
|
|
let a = DiaryDate::from_ymd(2026, 1, 1).unwrap();
|
|
let b = DiaryDate::from_ymd(2026, 1, 2).unwrap();
|
|
let c = DiaryDate::from_ymd(2026, 2, 1).unwrap();
|
|
let d = DiaryDate::from_ymd(2027, 1, 1).unwrap();
|
|
assert!(a < b);
|
|
assert!(b < c);
|
|
assert!(c < d);
|
|
}
|
|
|
|
#[test]
|
|
fn today_utc_is_a_real_calendar_date() {
|
|
let t = DiaryDate::today_utc();
|
|
assert!(t.year >= 2020 && t.year <= 2100);
|
|
assert!((1..=12).contains(&t.month));
|
|
assert!((1..=31).contains(&t.day));
|
|
}
|
|
|
|
// ===== WikiConfig diary paths =====
|
|
|
|
#[test]
|
|
fn diary_dir_joins_root_and_rel_path() {
|
|
let cfg = wiki_cfg("/tmp/wiki");
|
|
assert_eq!(cfg.diary_dir(), PathBuf::from("/tmp/wiki/diary"));
|
|
}
|
|
|
|
#[test]
|
|
fn diary_index_path_uses_diary_index_filename_and_extension() {
|
|
let cfg = wiki_cfg("/tmp/wiki");
|
|
assert_eq!(
|
|
cfg.diary_index_path(),
|
|
PathBuf::from("/tmp/wiki/diary/diary.wiki")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diary_path_for_uses_iso_date_as_stem() {
|
|
let cfg = wiki_cfg("/tmp/wiki");
|
|
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
assert_eq!(
|
|
cfg.diary_path_for(&date),
|
|
PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn diary_uri_for_date_is_file_url() {
|
|
let cfg = wiki_cfg("/tmp/wiki");
|
|
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
let uri = diary::uri_for_date(&cfg, &date).unwrap();
|
|
assert!(uri.as_str().ends_with("/diary/2026-05-11.wiki"));
|
|
}
|
|
|
|
#[test]
|
|
fn diary_index_uri_resolves() {
|
|
let cfg = wiki_cfg("/tmp/wiki");
|
|
let uri = diary::index_uri(&cfg).unwrap();
|
|
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
|
}
|
|
|
|
// ===== Index integration: diary_date on IndexedPage =====
|
|
|
|
#[test]
|
|
fn upsert_marks_diary_entries() {
|
|
let root = "/tmp/diary1";
|
|
let idx = build_index_with_entries(root, &["2026-05-11"]);
|
|
let uri = Url::from_file_path(format!("{root}/diary/2026-05-11.wiki")).unwrap();
|
|
let page = idx.page(&uri).expect("page");
|
|
let date = page.diary_date.expect("diary_date");
|
|
assert_eq!(date.format(), "2026-05-11");
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_does_not_mark_non_diary_files() {
|
|
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary2")))
|
|
.with_diary_rel_path(Some("diary".into()));
|
|
let uri = Url::from_file_path("/tmp/diary2/Home.wiki").unwrap();
|
|
idx.upsert(uri.clone(), &parse("= Home =\n"));
|
|
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn upsert_does_not_mark_dates_outside_diary_subdir() {
|
|
// A file at root named like a date but not under diary/ → not a
|
|
// diary entry.
|
|
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary3")))
|
|
.with_diary_rel_path(Some("diary".into()));
|
|
let uri = Url::from_file_path("/tmp/diary3/2026-05-11.wiki").unwrap();
|
|
idx.upsert(uri.clone(), &parse("= note =\n"));
|
|
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
|
}
|
|
|
|
// ===== Diary listing + navigation =====
|
|
|
|
#[test]
|
|
fn list_entries_returns_sorted_ascending() {
|
|
let idx = build_index_with_entries("/tmp/diary4", &["2026-05-11", "2026-04-30", "2026-05-12"]);
|
|
let entries = diary::list_entries(&idx);
|
|
let dates: Vec<String> = entries.iter().map(|e| e.date.format()).collect();
|
|
assert_eq!(dates, vec!["2026-04-30", "2026-05-11", "2026-05-12"]);
|
|
}
|
|
|
|
#[test]
|
|
fn list_entries_filtered_by_year_and_month() {
|
|
let idx = build_index_with_entries(
|
|
"/tmp/diary5",
|
|
&["2025-12-31", "2026-01-01", "2026-05-11", "2026-05-12"],
|
|
);
|
|
let may = diary::list_entries_filtered(&idx, Some(2026), Some(5));
|
|
assert_eq!(may.len(), 2);
|
|
let y_only = diary::list_entries_filtered(&idx, Some(2026), None);
|
|
assert_eq!(y_only.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn next_entry_finds_strictly_greater() {
|
|
let idx = build_index_with_entries("/tmp/diary6", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
|
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
let next = diary::next_entry(&idx, &from).unwrap();
|
|
assert_eq!(next.date.format(), "2026-05-12");
|
|
}
|
|
|
|
#[test]
|
|
fn next_entry_none_when_no_future_entries() {
|
|
let idx = build_index_with_entries("/tmp/diary7", &["2026-05-10"]);
|
|
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
|
assert!(diary::next_entry(&idx, &from).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn prev_entry_finds_strictly_lesser() {
|
|
let idx = build_index_with_entries("/tmp/diary8", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
|
let from = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
|
let prev = diary::prev_entry(&idx, &from).unwrap();
|
|
assert_eq!(prev.date.format(), "2026-05-12");
|
|
}
|
|
|
|
// ===== Index body generation =====
|
|
|
|
#[test]
|
|
fn build_index_body_is_newest_first_grouped_by_year_month() {
|
|
let idx = build_index_with_entries(
|
|
"/tmp/diary9",
|
|
&["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"],
|
|
);
|
|
let entries = diary::list_entries(&idx);
|
|
let body = diary::build_index_body(&entries, "diary", "Diary");
|
|
let lines: Vec<&str> = body.lines().collect();
|
|
assert_eq!(lines[0], "= Diary =");
|
|
// First date in output should be the latest: 2026-05-12
|
|
let earliest_2026_pos = body.find("2026-05-12").unwrap();
|
|
let latest_2025_pos = body.find("2025-12-31").unwrap();
|
|
assert!(earliest_2026_pos < latest_2025_pos, "newer entries first");
|
|
// Year headings appear
|
|
assert!(body.contains("== 2026 =="));
|
|
assert!(body.contains("== 2025 =="));
|
|
// Month headings appear
|
|
assert!(body.contains("=== May ==="));
|
|
assert!(body.contains("=== December ==="));
|
|
assert!(body.contains("=== April ==="));
|
|
}
|
|
|
|
#[test]
|
|
fn build_index_body_empty_when_no_entries() {
|
|
let body = diary::build_index_body(&[], "diary", "Diary");
|
|
assert_eq!(body, "= Diary =\n");
|
|
}
|
|
|
|
#[test]
|
|
fn render_index_body_round_trip() {
|
|
let cfg = wiki_cfg("/tmp/diaryA");
|
|
let idx = build_index_with_entries("/tmp/diaryA", &["2026-05-11"]);
|
|
let body = diary::render_index_body(&cfg, &idx);
|
|
assert!(body.contains("= Diary ="));
|
|
assert!(body.contains("[[diary/2026-05-11]]"));
|
|
}
|
|
|
|
// ===== is_in_diary =====
|
|
|
|
#[test]
|
|
fn is_in_diary_recognises_subdir_paths() {
|
|
let cfg = wiki_cfg("/tmp/wiki");
|
|
assert!(diary::is_in_diary(
|
|
&cfg,
|
|
&PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
|
));
|
|
assert!(!diary::is_in_diary(
|
|
&cfg,
|
|
&PathBuf::from("/tmp/wiki/Home.wiki")
|
|
));
|
|
}
|
|
|
|
// ===== Serde =====
|
|
|
|
#[test]
|
|
fn diary_entry_serializes_to_date_string_and_uri() {
|
|
let uri = Url::parse("file:///tmp/wiki/diary/2026-05-11.wiki").unwrap();
|
|
let entry = diary::DiaryEntry {
|
|
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
|
uri,
|
|
};
|
|
let v = serde_json::to_value(&entry).unwrap();
|
|
assert_eq!(v["date"], "2026-05-11");
|
|
assert!(v["uri"].as_str().unwrap().contains("2026-05-11.wiki"));
|
|
}
|
|
|
|
// ===== COMMANDS list completeness =====
|
|
|
|
#[test]
|
|
fn commands_list_includes_phase16_entries() {
|
|
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
|
for name in [
|
|
"nuwiki.diary.openToday",
|
|
"nuwiki.diary.openYesterday",
|
|
"nuwiki.diary.openTomorrow",
|
|
"nuwiki.diary.openIndex",
|
|
"nuwiki.diary.generateIndex",
|
|
"nuwiki.diary.next",
|
|
"nuwiki.diary.prev",
|
|
"nuwiki.diary.listEntries",
|
|
"nuwiki.diary.openForDate",
|
|
] {
|
|
assert!(names.contains(&name), "missing: {name}");
|
|
}
|
|
}
|
|
|
|
// ===== Config wire format =====
|
|
|
|
#[test]
|
|
fn wiki_config_defaults_match_vimwiki() {
|
|
let cfg = WikiConfig::empty();
|
|
assert_eq!(cfg.diary_rel_path, "diary");
|
|
assert_eq!(cfg.diary_index, "diary");
|
|
}
|
|
|
|
#[test]
|
|
fn wiki_config_from_root_uses_default_diary_paths() {
|
|
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/x"));
|
|
assert_eq!(cfg.diary_rel_path, "diary");
|
|
}
|
|
|
|
#[test]
|
|
fn config_from_json_accepts_custom_diary_paths() {
|
|
use nuwiki_lsp::config::config_from_json;
|
|
let cfg = config_from_json(serde_json::json!({
|
|
"wikis": [
|
|
{ "root": "/tmp/p", "diary_rel_path": "journal", "diary_index": "index" },
|
|
],
|
|
}));
|
|
assert_eq!(cfg.wikis[0].diary_rel_path, "journal");
|
|
assert_eq!(cfg.wikis[0].diary_index, "index");
|
|
}
|