diff --git a/crates/nuwiki-core/src/date.rs b/crates/nuwiki-core/src/date.rs index 64c8d40..5f91bb4 100644 --- a/crates/nuwiki-core/src/date.rs +++ b/crates/nuwiki-core/src/date.rs @@ -146,6 +146,255 @@ fn to_days(year: i32, month: u8, day: u8) -> i64 { 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()) + } +} + +/// 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; diff --git a/crates/nuwiki-core/tests/diary_period.rs b/crates/nuwiki-core/tests/diary_period.rs new file mode 100644 index 0000000..78545dd --- /dev/null +++ b/crates/nuwiki-core/tests/diary_period.rs @@ -0,0 +1,184 @@ +//! Cluster 4 — diary frequency / period primitives. +//! Tests the date math for ISO weeks, monthly, yearly periods. + +use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod}; + +#[test] +fn frequency_parses_canonical_strings() { + assert_eq!(DiaryFrequency::parse("daily"), DiaryFrequency::Daily); + assert_eq!(DiaryFrequency::parse("weekly"), DiaryFrequency::Weekly); + assert_eq!(DiaryFrequency::parse("monthly"), DiaryFrequency::Monthly); + assert_eq!(DiaryFrequency::parse("yearly"), DiaryFrequency::Yearly); + assert_eq!(DiaryFrequency::parse("WeEkLy"), DiaryFrequency::Weekly); + // Unknown falls back to Daily (mirrors vimwiki's permissive behaviour). + assert_eq!(DiaryFrequency::parse("hourly"), DiaryFrequency::Daily); + assert_eq!(DiaryFrequency::parse(""), DiaryFrequency::Daily); +} + +#[test] +fn period_format_round_trips() { + let cases = [ + ( + "2026-05-12", + DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()), + ), + ( + "2026-W19", + DiaryPeriod::Week { + iso_year: 2026, + iso_week: 19, + }, + ), + ( + "2026-05", + DiaryPeriod::Month { + year: 2026, + month: 5, + }, + ), + ("2026", DiaryPeriod::Year { year: 2026 }), + ]; + for (s, want) in cases { + let parsed = DiaryPeriod::parse(s).unwrap_or_else(|| panic!("parse {s}")); + assert_eq!(parsed, want, "parse({s})"); + assert_eq!(parsed.format(), s, "format({s})"); + } +} + +#[test] +fn period_parse_rejects_garbage() { + assert!(DiaryPeriod::parse("garbage").is_none()); + assert!(DiaryPeriod::parse("2026-W00").is_none()); + assert!(DiaryPeriod::parse("2026-13").is_none()); // month 13 + assert!(DiaryPeriod::parse("2026-").is_none()); +} + +#[test] +fn iso_week_jan_1_can_belong_to_prior_year() { + // 2021-01-01 was a Friday → ISO week 53 of 2020. + let d = DiaryDate::from_ymd(2021, 1, 1).unwrap(); + let p = DiaryPeriod::week_containing(d); + assert_eq!( + p, + DiaryPeriod::Week { + iso_year: 2020, + iso_week: 53 + } + ); +} + +#[test] +fn iso_week_dec_31_can_belong_to_next_year() { + // 2024-12-30 (Monday) is in ISO week 1 of 2025. + let d = DiaryDate::from_ymd(2024, 12, 30).unwrap(); + let p = DiaryPeriod::week_containing(d); + assert_eq!( + p, + DiaryPeriod::Week { + iso_year: 2025, + iso_week: 1 + } + ); +} + +#[test] +fn iso_week_mid_year_is_consistent() { + // 2026-05-12 is a Tuesday in ISO week 20. + let d = DiaryDate::from_ymd(2026, 5, 12).unwrap(); + let p = DiaryPeriod::week_containing(d); + assert_eq!( + p, + DiaryPeriod::Week { + iso_year: 2026, + iso_week: 20 + } + ); +} + +#[test] +fn week_next_and_prev_step_by_seven_days() { + let p = DiaryPeriod::Week { + iso_year: 2026, + iso_week: 20, + }; + assert_eq!( + p.next(), + DiaryPeriod::Week { + iso_year: 2026, + iso_week: 21 + } + ); + assert_eq!( + p.prev(), + DiaryPeriod::Week { + iso_year: 2026, + iso_week: 19 + } + ); +} + +#[test] +fn week_next_crosses_year_boundary() { + // Week 53 of 2020 → week 53 ends; next should be week 1 of 2021. + let p = DiaryPeriod::Week { + iso_year: 2020, + iso_week: 53, + }; + assert_eq!( + p.next(), + DiaryPeriod::Week { + iso_year: 2021, + iso_week: 1 + } + ); +} + +#[test] +fn month_next_wraps_to_january() { + let dec = DiaryPeriod::Month { + year: 2026, + month: 12, + }; + assert_eq!( + dec.next(), + DiaryPeriod::Month { + year: 2027, + month: 1 + } + ); +} + +#[test] +fn month_prev_wraps_to_december() { + let jan = DiaryPeriod::Month { + year: 2026, + month: 1, + }; + assert_eq!( + jan.prev(), + DiaryPeriod::Month { + year: 2025, + month: 12 + } + ); +} + +#[test] +fn year_next_and_prev_increment_by_one() { + let y = DiaryPeriod::Year { year: 2026 }; + assert_eq!(y.next(), DiaryPeriod::Year { year: 2027 }); + assert_eq!(y.prev(), DiaryPeriod::Year { year: 2025 }); +} + +#[test] +fn day_next_and_prev_still_work() { + let d = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()); + assert_eq!( + d.next(), + DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 13).unwrap()) + ); + assert_eq!( + d.prev(), + DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 11).unwrap()) + ); +} diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 584880a..d74287a 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -379,18 +379,20 @@ fn diary_open_relative( let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else { return Ok(None); }; - let today = nuwiki_core::date::DiaryDate::today_utc(); - let date = match rel { - RelativeDay::Today => today, - RelativeDay::Yesterday => today.prev_day(), - RelativeDay::Tomorrow => today.next_day(), + let freq = wiki.config.frequency(); + let today_period = nuwiki_core::date::DiaryPeriod::today_utc(freq); + let period = match rel { + RelativeDay::Today => today_period, + RelativeDay::Yesterday => today_period.prev(), + RelativeDay::Tomorrow => today_period.next(), }; - let Some(uri) = crate::diary::uri_for_date(&wiki.config, &date) else { + let Some(uri) = crate::diary::uri_for_period(&wiki.config, &period) else { return Ok(None); }; Ok(Some(serde_json::json!({ "uri": uri, - "date": date.format(), + "date": period.format(), + "frequency": freq.as_str(), }))) } @@ -434,17 +436,18 @@ fn diary_step(backend: &Backend, args: Vec, forward: bool) -> Result on a weekly + // entry jump to the next weekly entry, not the next daily one. + let pivot: nuwiki_core::date::DiaryPeriod = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; idx.page(&p.uri) - .and_then(|page| page.diary_date) - .unwrap_or_else(nuwiki_core::date::DiaryDate::today_utc) + .and_then(|page| page.diary_period) + .unwrap_or_else(|| nuwiki_core::date::DiaryPeriod::today_utc(wiki.config.frequency())) }; let next = { let idx = wiki @@ -452,9 +455,9 @@ fn diary_step(backend: &Backend, args: Vec, forward: bool) -> Result PathBuf { + let mut p = self.diary_dir(); + p.push(format!("{}{}", period.format(), self.file_extension)); + p + } + + /// `DiaryFrequency` parsed from this wiki's `diary_frequency` field. + pub fn frequency(&self) -> nuwiki_core::date::DiaryFrequency { + nuwiki_core::date::DiaryFrequency::parse(&self.diary_frequency) + } } fn default_diary_rel_path() -> String { diff --git a/crates/nuwiki-lsp/src/diary.rs b/crates/nuwiki-lsp/src/diary.rs index 2b2b7ba..eda13e4 100644 --- a/crates/nuwiki-lsp/src/diary.rs +++ b/crates/nuwiki-lsp/src/diary.rs @@ -12,7 +12,7 @@ use std::path::Path; use tower_lsp::lsp_types::Url; -use nuwiki_core::date::DiaryDate; +use nuwiki_core::date::{DiaryDate, DiaryPeriod}; use crate::config::WikiConfig; use crate::index::WorkspaceIndex; @@ -22,6 +22,12 @@ pub fn uri_for_date(cfg: &WikiConfig, date: &DiaryDate) -> Option { Url::from_file_path(cfg.diary_path_for(date)).ok() } +/// Resolve a `DiaryPeriod` to its `file://` URI under the given wiki. +/// Picks the right stem for the period's flavour (day / week / month / year). +pub fn uri_for_period(cfg: &WikiConfig, period: &DiaryPeriod) -> Option { + Url::from_file_path(cfg.diary_path_for_period(period)).ok() +} + /// `file://` URI of the diary index page. pub fn index_uri(cfg: &WikiConfig) -> Option { Url::from_file_path(cfg.diary_index_path()).ok() @@ -74,6 +80,94 @@ pub fn prev_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option` on a weekly page jumps to the next weekly entry, not the +/// next daily one. +pub fn list_periods_of_kind(index: &WorkspaceIndex, kind: PeriodKind) -> Vec { + let mut out: Vec = index + .pages_by_uri + .values() + .filter_map(|p| { + let period = p.diary_period?; + if PeriodKind::of(&period) != kind { + return None; + } + Some(PeriodEntry { + period, + uri: p.uri.clone(), + }) + }) + .collect(); + out.sort_by_key(|e| period_first_day(&e.period)); + out +} + +pub fn next_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option { + let kind = PeriodKind::of(from); + let pivot = period_first_day(from); + list_periods_of_kind(index, kind) + .into_iter() + .find(|e| period_first_day(&e.period) > pivot) +} + +pub fn prev_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option { + let kind = PeriodKind::of(from); + let pivot = period_first_day(from); + list_periods_of_kind(index, kind) + .into_iter() + .rev() + .find(|e| period_first_day(&e.period) < pivot) +} + +/// Cluster 4: `(period, uri)` pair for non-daily diary navigation. +#[derive(Debug, Clone)] +pub struct PeriodEntry { + pub period: DiaryPeriod, + pub uri: Url, +} + +impl serde::Serialize for PeriodEntry { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + // For back-compat with daily-only clients, emit `date` AND + // `period` — the former is just the first-day string of the + // period, the latter is the canonical stem. + let mut s = serializer.serialize_struct("PeriodEntry", 3)?; + s.serialize_field("date", &self.period.first_day().format())?; + s.serialize_field("period", &self.period.format())?; + s.serialize_field("uri", &self.uri)?; + s.end() + } +} + +/// Which flavour of `DiaryPeriod` we're looking at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PeriodKind { + Day, + Week, + Month, + Year, +} + +impl PeriodKind { + pub fn of(p: &DiaryPeriod) -> Self { + match p { + DiaryPeriod::Day(_) => Self::Day, + DiaryPeriod::Week { .. } => Self::Week, + DiaryPeriod::Month { .. } => Self::Month, + DiaryPeriod::Year { .. } => Self::Year, + } + } +} + +fn period_first_day(p: &DiaryPeriod) -> DiaryDate { + p.first_day() +} + /// Generate the body of the diary index page — a flat newest-first list /// of `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month /// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`. diff --git a/crates/nuwiki-lsp/src/index.rs b/crates/nuwiki-lsp/src/index.rs index a14a9a4..5a2e4e8 100644 --- a/crates/nuwiki-lsp/src/index.rs +++ b/crates/nuwiki-lsp/src/index.rs @@ -32,10 +32,16 @@ pub struct IndexedPage { /// Phase 12: every `TagNode` on the page. `tags_by_name` on the /// containing `WorkspaceIndex` is the reverse map. pub tags: Vec, - /// Phase 16: `Some(date)` when this page is a diary entry — i.e. its - /// file lives under `//` and its stem parses as - /// `YYYY-MM-DD`. Used by the diary navigation commands. + /// Phase 16: `Some(date)` when this page is a *daily* diary entry — + /// stem parses as `YYYY-MM-DD`. Equivalent to + /// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`. + /// Kept as its own field for back-compat with prev/next/list callers + /// that pre-date the multi-frequency upgrade. pub diary_date: Option, + /// Cluster 4 (vimwiki parity): `Some(period)` for any diary entry + /// (daily, weekly, monthly, yearly). The stem must match the + /// canonical format for one of the four frequencies. + pub diary_period: Option, } /// One tag occurrence on a page. `name` is the bare tag string (no @@ -118,8 +124,12 @@ impl WorkspaceIndex { pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) { self.remove(&uri); let name = page_name_from_uri(&uri, self.root.as_deref()); - let diary_date = - diary_date_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref()); + let diary_period = + diary_period_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref()); + let diary_date = diary_period.and_then(|p| match p { + nuwiki_core::date::DiaryPeriod::Day(d) => Some(d), + _ => None, + }); let mut page = IndexedPage { uri: uri.clone(), name: name.clone(), @@ -128,6 +138,7 @@ impl WorkspaceIndex { outgoing: Vec::new(), tags: Vec::new(), diary_date, + diary_period, }; index_blocks(&ast.children, &mut page); @@ -224,19 +235,40 @@ impl WorkspaceIndex { } } -/// Return a parsed [`DiaryDate`] when `uri` is a diary entry — its file -/// path is under `//` and its stem parses as -/// `YYYY-MM-DD`. Anything else returns `None`. +/// Return a parsed [`DiaryDate`] when `uri` is a daily diary entry — +/// the path lives under `//` and its stem parses +/// as `YYYY-MM-DD`. For weekly/monthly/yearly entries see +/// [`diary_period_for_uri`]. pub fn diary_date_for_uri( uri: &Url, root: Option<&Path>, diary_rel_path: Option<&str>, ) -> Option { + match diary_period_for_uri(uri, root, diary_rel_path)? { + nuwiki_core::date::DiaryPeriod::Day(d) => Some(d), + _ => None, + } +} + +/// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of +/// the four supported frequencies. Recognises: +/// +/// `YYYY-MM-DD` → Day +/// `YYYY-Www` → Week (ISO) +/// `YYYY-MM` → Month +/// `YYYY` → Year +/// +/// As with `diary_date_for_uri`, the path must sit under +/// `//` to be accepted; without a `root` we accept +/// any stem (so ad-hoc test setups still work). +pub fn diary_period_for_uri( + uri: &Url, + root: Option<&Path>, + diary_rel_path: Option<&str>, +) -> Option { let path = uri.to_file_path().ok()?; let stem = path.file_stem()?.to_str()?; - let parsed = DiaryDate::parse(stem)?; - // Without a root we can't enforce the subdir requirement — accept any - // file whose stem looks like a date so that ad-hoc test setups work. + let parsed = nuwiki_core::date::DiaryPeriod::parse(stem)?; let Some(root) = root else { return Some(parsed); }; diff --git a/crates/nuwiki-lsp/tests/diary_frequency.rs b/crates/nuwiki-lsp/tests/diary_frequency.rs new file mode 100644 index 0000000..258f595 --- /dev/null +++ b/crates/nuwiki-lsp/tests/diary_frequency.rs @@ -0,0 +1,241 @@ +//! Cluster 4 — diary frequency wiring at the WikiConfig + index layer. +//! +//! These tests exercise the path/URI shape for each frequency and the +//! index's ability to recognise non-daily diary entries. The +//! integration through `executeCommand` is exercised in the LSP-level +//! tests via the Neovim keymap harness; here we stay at the pure-Rust +//! layer so failures land with a clean stack. + +use std::path::PathBuf; + +use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod}; +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::config::WikiConfig; +use nuwiki_lsp::diary; +use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex}; +use tower_lsp::lsp_types::Url; + +fn cfg(root: &str, frequency: &str) -> WikiConfig { + let mut c = WikiConfig::from_root(PathBuf::from(root)); + c.diary_rel_path = "diary".into(); + c.diary_frequency = frequency.into(); + c +} + +#[test] +fn frequency_helper_picks_up_config_value() { + assert_eq!(cfg("/tmp", "daily").frequency(), DiaryFrequency::Daily); + assert_eq!(cfg("/tmp", "weekly").frequency(), DiaryFrequency::Weekly); + assert_eq!(cfg("/tmp", "monthly").frequency(), DiaryFrequency::Monthly); + assert_eq!(cfg("/tmp", "yearly").frequency(), DiaryFrequency::Yearly); +} + +#[test] +fn diary_path_for_period_picks_right_stem_per_frequency() { + let c = cfg("/wiki", "daily"); + let day = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()); + let wk = DiaryPeriod::Week { + iso_year: 2026, + iso_week: 19, + }; + let mo = DiaryPeriod::Month { + year: 2026, + month: 5, + }; + let yr = DiaryPeriod::Year { year: 2026 }; + assert_eq!( + c.diary_path_for_period(&day), + PathBuf::from("/wiki/diary/2026-05-12.wiki") + ); + assert_eq!( + c.diary_path_for_period(&wk), + PathBuf::from("/wiki/diary/2026-W19.wiki") + ); + assert_eq!( + c.diary_path_for_period(&mo), + PathBuf::from("/wiki/diary/2026-05.wiki") + ); + assert_eq!( + c.diary_path_for_period(&yr), + PathBuf::from("/wiki/diary/2026.wiki") + ); +} + +#[test] +fn uri_for_period_produces_file_url_per_frequency() { + let c = cfg("/wiki", "weekly"); + let wk = DiaryPeriod::Week { + iso_year: 2026, + iso_week: 19, + }; + let u = diary::uri_for_period(&c, &wk).unwrap(); + assert!( + u.as_str().ends_with("/wiki/diary/2026-W19.wiki"), + "got: {u}" + ); +} + +// ===== Index recognition ===== + +fn make_index_with(entries: &[&str]) -> WorkspaceIndex { + let root = PathBuf::from("/wiki"); + let mut idx = WorkspaceIndex::new(Some(root.clone())).with_diary_rel_path(Some("diary".into())); + for stem in entries { + let path = format!("/wiki/diary/{stem}.wiki"); + let uri = Url::from_file_path(&path).unwrap(); + let ast = VimwikiSyntax::new().parse("= entry =\n"); + idx.upsert(uri, &ast); + } + idx +} + +#[test] +fn index_recognises_all_four_frequency_stems() { + let idx = make_index_with(&["2026-05-12", "2026-W19", "2026-05", "2026"]); + for (stem, expected) in [ + ( + "2026-05-12", + DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()), + ), + ( + "2026-W19", + DiaryPeriod::Week { + iso_year: 2026, + iso_week: 19, + }, + ), + ( + "2026-05", + DiaryPeriod::Month { + year: 2026, + month: 5, + }, + ), + ("2026", DiaryPeriod::Year { year: 2026 }), + ] { + let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap(); + let page = idx.page(&uri).expect(stem); + assert_eq!( + page.diary_period, + Some(expected), + "stem {stem} should index as {expected:?}" + ); + } +} + +#[test] +fn index_only_sets_diary_date_for_daily_entries() { + let idx = make_index_with(&["2026-05-12", "2026-W19"]); + let daily = Url::from_file_path("/wiki/diary/2026-05-12.wiki").unwrap(); + let weekly = Url::from_file_path("/wiki/diary/2026-W19.wiki").unwrap(); + assert!(idx.page(&daily).unwrap().diary_date.is_some()); + assert!(idx.page(&weekly).unwrap().diary_date.is_none()); + assert!(idx.page(&weekly).unwrap().diary_period.is_some()); +} + +#[test] +fn diary_period_for_uri_rejects_paths_outside_diary_dir() { + // Non-diary path with a date-looking stem — should NOT count. + let uri = Url::from_file_path("/wiki/notes/2026-05-12.wiki").unwrap(); + let root = PathBuf::from("/wiki"); + assert!(diary_period_for_uri(&uri, Some(&root), Some("diary")).is_none()); +} + +#[test] +fn next_prev_period_navigate_at_the_same_frequency() { + // Mix entries at different frequencies. Navigation should stay + // within the same flavour as the pivot. + let idx = make_index_with(&[ + "2026-W18", + "2026-W19", + "2026-W20", // weekly + "2026-04", + "2026-05", + "2026-06", // monthly + "2026-05-10", + "2026-05-12", // daily + ]); + let pivot_w = DiaryPeriod::Week { + iso_year: 2026, + iso_week: 19, + }; + let next_w = diary::next_period(&idx, &pivot_w).unwrap(); + assert_eq!( + next_w.period, + DiaryPeriod::Week { + iso_year: 2026, + iso_week: 20 + } + ); + let prev_w = diary::prev_period(&idx, &pivot_w).unwrap(); + assert_eq!( + prev_w.period, + DiaryPeriod::Week { + iso_year: 2026, + iso_week: 18 + } + ); + + let pivot_m = DiaryPeriod::Month { + year: 2026, + month: 5, + }; + let next_m = diary::next_period(&idx, &pivot_m).unwrap(); + assert_eq!( + next_m.period, + DiaryPeriod::Month { + year: 2026, + month: 6 + } + ); + let prev_m = diary::prev_period(&idx, &pivot_m).unwrap(); + assert_eq!( + prev_m.period, + DiaryPeriod::Month { + year: 2026, + month: 4 + } + ); +} + +#[test] +fn next_prev_period_returns_none_past_the_edge() { + let idx = make_index_with(&["2026-W19"]); + let only = DiaryPeriod::Week { + iso_year: 2026, + iso_week: 19, + }; + assert!(diary::next_period(&idx, &only).is_none()); + assert!(diary::prev_period(&idx, &only).is_none()); +} + +#[test] +fn diary_period_for_uri_handles_each_frequency_under_diary_dir() { + let root = PathBuf::from("/wiki"); + for (stem, expected) in [ + ( + "2026-05-12", + DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()), + ), + ( + "2026-W19", + DiaryPeriod::Week { + iso_year: 2026, + iso_week: 19, + }, + ), + ( + "2026-05", + DiaryPeriod::Month { + year: 2026, + month: 5, + }, + ), + ("2026", DiaryPeriod::Year { year: 2026 }), + ] { + let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap(); + let got = diary_period_for_uri(&uri, Some(&root), Some("diary")); + assert_eq!(got, Some(expected), "stem {stem}"); + } +}