backfill: tag commands + file-operations capability
CI / cargo fmt --check (push) Successful in 19s
CI / cargo clippy (push) Successful in 1m11s
CI / cargo test (push) Successful in 1m21s

SPEC audit of Phases 12–13 surfaced two gaps. Closing them here so the
v1.1 command surface and capability advertisement match what §12.3 and
§12.4 promise.

Phase 12 — tag commands (§12.3):
- `nuwiki.tags.search` — case-insensitive substring filter over the
  workspace tag index. Returns `[{ name, uri, range, scope, page }]`
  where `scope` is one of `file` / `heading` / `standalone`.
- `nuwiki.tags.generateLinks` — generates/refreshes a tagged-pages
  section. Two modes:
    - `{ uri, tag }` → `= Tag: <name> =` + flat list of `[[Page]]`
      entries for every page tagged with `<name>`.
    - `{ uri }` (no tag) → `= Tags =` umbrella section with `== <tag>
      ==` subgroups, mirroring `:VimwikiGenerateTagLinks` (no arg).
  Idempotent: re-runs replace the existing section by
  case-insensitive heading match.
- `nuwiki.tags.rebuild` — synchronous full re-walk of the wiki root.
  Drops index entries for pages whose files no longer exist on disk,
  re-parses every `.wiki`, and returns `{ pages, files_seen,
  stale_removed }`. Useful for cleaning up after out-of-band edits
  the LSP didn't see.

Ops layer:
- `tag_hits(index, query) → Vec<TagHit>` — pure filter, sorted by
  (name, page, line).
- `tag_pages_snapshot(index) → BTreeMap<tag, [pages]>` — taken under
  the read lock so the dispatcher can release the lock before
  building the edit.
- `build_tag_links_text` / `tag_links_edit` — text-level rendering
  and the WorkspaceEdit producer.

Phase 13 — file-operations capability (§12.4):
- `ServerCapabilities.workspace.file_operations` now advertises
  `did_rename` + `did_delete` with filters scoped to `**/*.wiki`
  and `**/*.md`. (will_* deferred — those return a WorkspaceEdit
  and we'd want to wire them into the rename pipeline before
  promising support.)
- `did_rename_files` handler — moves the live DocumentState forward
  to the new URI when the file was open, then refreshes the index
  (remove old URI, upsert new via read_or_open).
- `did_delete_files` handler — drops both DocumentState and index
  entry so backlink lookups don't surface dangling source URIs.

Tests: 15 new in `phase17_backfill.rs` covering tag hit filtering &
ordering, snapshot dedup, single-tag and full-index render shapes,
edit replace-vs-insert flow, JSON shape of `TagHit`, and the COMMANDS
list. Total 324 tests pass; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 21:17:04 +00:00
parent e75ad6ca89
commit ad1b55a401
3 changed files with 637 additions and 8 deletions
+235
View File
@@ -0,0 +1,235 @@
//! Backfill tests for items missed in Phases 1213:
//! - `nuwiki.tags.search`, `nuwiki.tags.generateLinks`, `nuwiki.tags.rebuild`
//! - `workspace/fileOperations` capability advertisement
//!
//! These tests drive the pure ops directly; the live-handler paths
//! (rebuild walking the filesystem, did_rename re-reading from disk) are
//! covered by the existing Phase 11 closed-doc loader tests.
use std::collections::BTreeMap;
use std::path::PathBuf;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::ops;
use nuwiki_lsp::index::WorkspaceIndex;
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
for (name, src) in pages {
let path = format!("{root}/{name}.wiki");
let uri = Url::from_file_path(&path).unwrap();
idx.upsert(uri, &parse(src));
}
idx
}
// ===== tag_hits =====
#[test]
fn tag_hits_empty_query_returns_every_tag() {
let idx = build_index(
"/tmp/th1",
&[
("Home", "= Home =\n:work:personal:\n"),
("Notes", "= Notes =\n:work:\n"),
],
);
let hits = ops::tag_hits(&idx, "");
let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect();
assert!(names.contains(&"work"));
assert!(names.contains(&"personal"));
// 2 pages tagged with `work`, 1 with `personal` → 3 hits.
assert_eq!(hits.len(), 3);
}
#[test]
fn tag_hits_substring_filter_case_insensitive() {
let idx = build_index(
"/tmp/th2",
&[("Page", "= Page =\n:release-2026:RoadMap:\n")],
);
let hits = ops::tag_hits(&idx, "RELEASE");
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].name, "release-2026");
}
#[test]
fn tag_hits_orders_by_name_then_page() {
let idx = build_index(
"/tmp/th3",
&[
("Beta", "= Beta =\n:zeta:\n"),
("Alpha", "= Alpha =\n:alpha:\n"),
("Gamma", "= Gamma =\n:alpha:\n"),
],
);
let hits = ops::tag_hits(&idx, "");
let pairs: Vec<(String, String)> = hits
.iter()
.map(|h| (h.name.clone(), h.page.clone()))
.collect();
// alpha tag comes first; within it, Alpha page before Gamma; then zeta/Beta.
assert_eq!(pairs[0], ("alpha".into(), "Alpha".into()));
assert_eq!(pairs[1], ("alpha".into(), "Gamma".into()));
assert_eq!(pairs[2], ("zeta".into(), "Beta".into()));
}
#[test]
fn tag_hits_serializes_to_expected_json_shape() {
let idx = build_index("/tmp/th4", &[("Page", "= Page =\n:release:\n")]);
let hits = ops::tag_hits(&idx, "");
let v = serde_json::to_value(&hits).unwrap();
let row = &v[0];
assert_eq!(row["name"], "release");
assert!(row["uri"].is_string());
assert!(row["range"]["start"]["line"].is_number());
assert!(row["page"].is_string());
// scope is one of the three string variants.
let scope = row["scope"].as_str().unwrap();
assert!(
["file", "heading", "standalone"].contains(&scope),
"{scope}"
);
}
// ===== tag_pages_snapshot =====
#[test]
fn tag_pages_snapshot_deduplicates_pages_per_tag() {
// Same page has the tag in both file scope and heading scope —
// dedup so the rendered list doesn't show it twice.
let idx = build_index(
"/tmp/ts1",
&[("Home", "= Home =\n:release:\n== Section ==\n:release:\n")],
);
let snap = ops::tag_pages_snapshot(&idx);
assert_eq!(snap.get("release").unwrap().len(), 1);
assert_eq!(snap["release"][0], "Home");
}
#[test]
fn tag_pages_snapshot_sorts_pages_alphabetically() {
let idx = build_index(
"/tmp/ts2",
&[
("Charlie", "= Charlie =\n:topic:\n"),
("Alpha", "= Alpha =\n:topic:\n"),
("Bravo", "= Bravo =\n:topic:\n"),
],
);
let snap = ops::tag_pages_snapshot(&idx);
assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]);
}
// ===== build_tag_links_text =====
#[test]
fn build_tag_links_for_single_tag() {
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]);
let out = ops::build_tag_links_text(&snap, Some("release")).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "= Tag: release =");
assert_eq!(lines[1], "- [[Alpha]]");
assert_eq!(lines[2], "- [[Beta]]");
}
#[test]
fn build_tag_links_for_missing_tag_returns_none() {
let snap = BTreeMap::new();
assert!(ops::build_tag_links_text(&snap, Some("ghost")).is_none());
}
#[test]
fn build_tag_links_full_index_groups_by_tag() {
let mut snap = BTreeMap::new();
snap.insert("a".to_string(), vec!["P1".into()]);
snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]);
let out = ops::build_tag_links_text(&snap, None).unwrap();
assert!(out.starts_with("= Tags =\n"));
assert!(out.contains("== a =="));
assert!(out.contains("== b =="));
assert!(out.contains("- [[P1]]"));
assert!(out.contains("- [[P2]]"));
assert!(out.contains("- [[P3]]"));
}
#[test]
fn build_tag_links_full_index_returns_none_when_no_tags() {
let snap = BTreeMap::new();
assert!(ops::build_tag_links_text(&snap, None).is_none());
}
// ===== tag_links_edit =====
#[test]
fn tag_links_edit_inserts_when_section_missing() {
let src = "Some content.\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Alpha".into()]);
let edit =
ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("got an edit");
let te = &edit.changes.unwrap()[&uri][0];
// Insertion at start.
assert_eq!(te.range.start, te.range.end);
assert!(te.new_text.contains("= Tag: release ="));
assert!(te.new_text.contains("[[Alpha]]"));
}
#[test]
fn tag_links_edit_replaces_existing_section() {
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Fresh".into()]);
let edit = ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert_eq!(te.range.start.line, 0);
assert!(te.new_text.contains("[[Fresh]]"));
assert!(!te.new_text.contains("Stale"));
}
#[test]
fn tag_links_edit_full_index_replaces_existing_tags_section() {
let src = "= Tags =\n- [[old]]\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("alpha".to_string(), vec!["P".into()]);
let edit = ops::tag_links_edit(src, &ast, &uri, None, &snap, true).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("== alpha =="));
assert!(!te.new_text.contains("[[old]]"));
}
#[test]
fn tag_links_edit_returns_none_for_unknown_tag() {
let src = "hi\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let snap = BTreeMap::new();
assert!(ops::tag_links_edit(src, &ast, &uri, Some("ghost"), &snap, true).is_none());
}
// ===== COMMANDS list contains new entries =====
#[test]
fn commands_list_includes_tag_commands() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [
"nuwiki.tags.search",
"nuwiki.tags.generateLinks",
"nuwiki.tags.rebuild",
] {
assert!(names.contains(&name), "missing: {name}");
}
}