//! Command-coverage suite: at least one behavioural test for every //! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section //! of `doc/nuwiki.txt`). //! //! The user-facing commands funnel through `workspace/executeCommand` //! into the Backend dispatcher, or — for follow/backlinks/rename — through //! the standard LSP requests. The Backend itself owns a live `Client` and //! can't be built in an integration test, so each case drives the *pure* //! building-block the command relies on (the same functions the dispatcher //! wraps). This file is organised to mirror the doc's command groups so a //! reader can confirm every command has coverage. use std::collections::HashMap; use std::path::PathBuf; use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget}; use nuwiki_core::date::DiaryDate; use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::SyntaxPlugin; use nuwiki_lsp::commands::{export_ops, ops, COMMANDS}; use nuwiki_lsp::config::WikiConfig; use nuwiki_lsp::index::WorkspaceIndex; use nuwiki_lsp::nav::find_inline_at; use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target}; use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki}; use nuwiki_lsp::{diary, export}; use tower_lsp::lsp_types::Url; fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { VimwikiSyntax::new().parse(src) } fn wiki_cfg(root: &str) -> WikiConfig { WikiConfig::from_root(PathBuf::from(root)) } /// Build a workspace index rooted at `root` with the given `name -> source` /// pages, mirroring the helper used by the other command suites. fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex { let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root))); for (name, src) in pages { let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap(); idx.upsert(uri, &parse(src)); } idx } fn diary_index(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 uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap(); idx.upsert(uri, &parse("= entry =\n")); } idx } // ===================================================================== // Group 1: Wiki / navigation // ===================================================================== // :NuwikiIndex — open wiki N's index page. #[test] fn nuwiki_index_resolves_index_page() { let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]); let page = idx.page_by_name("index").expect("index page indexed"); assert!(page.uri.as_str().ends_with("/wiki/index.wiki")); } // :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab. // The tab-vs-current split is an editor concern; the resolved target is // identical, and both verbs are advertised as executeCommands. #[test] fn nuwiki_tab_index_advertised_alongside_index() { assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex")); assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex")); } // :NuwikiUISelect — pick a wiki from the configured list. #[test] fn nuwiki_ui_select_lists_configured_wikis() { let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")]; let wikis = build_wikis(&cfgs); assert_eq!(wikis.len(), 2); assert_eq!(wikis[0].config.name, "personal"); assert_eq!(wikis[1].config.name, "work"); // Selection by URI picks the wiki that actually owns the file. let uri = Url::from_file_path("/b/work/Page.wiki").unwrap(); assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id)); } // :NuwikiGoto {page} — open `{page}.wiki` by name. #[test] fn nuwiki_goto_opens_page_by_name() { let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]); let page = idx .page_by_name("Recipes") .expect("page resolvable by name"); assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki")); assert!(idx.page_by_name("Missing").is_none()); } // :NuwikiFollowLink — follow the link under the cursor. #[test] fn nuwiki_follow_link_resolves_target_under_cursor() { let idx = build_index( "/wiki", &[("Home", "see [[About]]\n"), ("About", "= A =\n")], ); let doc = parse("see [[About]]\n"); // Byte col 8 sits inside "[[About]]" (starts at byte 4). let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor"); let InlineNode::WikiLink(w) = node else { panic!("expected a wikilink, got {node:?}"); }; let about = Url::from_file_path("/wiki/About.wiki").unwrap(); assert_eq!(idx.resolve(&w.target, "Home"), Some(&about)); } // :NuwikiBacklinks — every reference to the current page. #[test] fn nuwiki_backlinks_lists_referrers() { let idx = build_index( "/wiki", &[ ("Home", "[[About]]\n"), ("Contact", "see [[About]] too\n"), ("About", "= A =\n"), ], ); let back = idx.backlinks_for("About"); assert_eq!(back.len(), 2); assert!(idx.backlinks_for("Home").is_empty()); } // :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink. // The jump itself is editor-side cursor movement; what the server provides // is the ordered set of links on the page. #[test] fn nuwiki_next_prev_link_walk_links_in_document_order() { let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n"); let links = ops::collect_wiki_links(&doc); let targets: Vec<&str> = links .iter() .filter_map(|l| l.target.path.as_deref()) .collect(); assert_eq!(targets, vec!["First", "Second", "Third"]); } // :NuwikiBaddLink — add the target of the link under the cursor to the // buffer list. The editor :badds whatever URI the link resolves to. #[test] fn nuwiki_badd_link_resolves_target_uri() { let idx = build_index( "/wiki", &[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")], ); let target = LinkTarget { kind: LinkKind::Wiki, path: Some("Notes/Todo".into()), ..Default::default() }; let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap(); assert_eq!(idx.resolve(&target, "Home"), Some(&expected)); } // :NuwikiRenameFile — rename the page and rewrite every inbound link. #[test] fn nuwiki_rename_file_builds_uri_and_rewrites_links() { let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap(); assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki")); assert_eq!( rewrite_wikilink_target("[[Old Name|caption]]", "New Name"), Some("[[New Name|caption]]".to_string()) ); } // :NuwikiDeleteFile — delete the current page and its on-disk file. #[test] fn nuwiki_delete_file_emits_delete_workspace_edit() { use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp}; let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap(); let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new(); b.file_op(nuwiki_lsp::edits::op_delete(uri.clone())); let we = b.build(); let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else { panic!("expected resource operations"); }; assert!(matches!( &ops[0], DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri )); } // ===================================================================== // Group 2: Diary // ===================================================================== // :NuwikiMakeDiaryNote — open today's entry. #[test] fn nuwiki_make_diary_note_targets_todays_file() { let cfg = wiki_cfg("/wiki"); let today = DiaryDate::from_ymd(2026, 5, 12).unwrap(); let uri = diary::uri_for_date(&cfg, &today).unwrap(); assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki")); } // :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the // diary back / forward by one period at the daily cadence. #[test] fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() { let cfg = wiki_cfg("/wiki"); let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap(); let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap(); assert!(diary::uri_for_date(&cfg, &yesterday) .unwrap() .as_str() .ends_with("/diary/2026-05-11.wiki")); assert!(diary::uri_for_date(&cfg, &tomorrow) .unwrap() .as_str() .ends_with("/diary/2026-05-13.wiki")); } // :NuwikiDiaryIndex — open the diary index page. #[test] fn nuwiki_diary_index_resolves_index_uri() { let cfg = wiki_cfg("/wiki"); let uri = diary::index_uri(&cfg).unwrap(); assert!(uri.as_str().ends_with("/diary/diary.wiki")); } // :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries. #[test] fn nuwiki_diary_generate_links_builds_grouped_body() { let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]); let entries = diary::list_entries(&idx); let body = diary::build_index_body(&entries, "diary", "Diary"); assert!(body.starts_with("= Diary =")); assert!(body.contains("2026-05-12")); assert!(body.contains("2026-05-10")); assert!(body.contains("2026-04-30")); // Newest entry appears before the older one within the same month. let p12 = body.find("2026-05-12").unwrap(); let p10 = body.find("2026-05-10").unwrap(); assert!(p12 < p10, "expected newest-first ordering"); } // :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries. #[test] fn nuwiki_diary_next_and_prev_day_walk_entries() { let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]); let from = DiaryDate::from_ymd(2026, 5, 12).unwrap(); assert_eq!( diary::next_entry(&idx, &from).unwrap().date.format(), "2026-05-15" ); assert_eq!( diary::prev_entry(&idx, &from).unwrap().date.format(), "2026-05-10" ); } // ===================================================================== // Group 3: Page generation // ===================================================================== // :NuwikiTOC — generate / refresh the table of contents. #[test] fn nuwiki_toc_generates_nested_contents() { let src = "= Top =\n== Sub ==\ntext\n"; let doc = parse(src); let uri = Url::from_file_path("/wiki/Page.wiki").unwrap(); let edit = ops::toc_edit(src, &doc, &uri, true).expect("toc edit produced"); assert!(edit.changes.is_some() || edit.document_changes.is_some()); let toc = ops::build_toc_text( &[ (1, "Top".into(), "top".into()), (2, "Sub".into(), "sub".into()), ], "Contents", ); assert!(toc.contains("= Contents =")); assert!(toc.contains("- [[#top|Top]]")); assert!(toc.contains(" - [[#sub|Sub]]")); } // :NuwikiGenerateLinks — insert a flat list of every page in the wiki. #[test] fn nuwiki_generate_links_lists_all_pages_excluding_current() { let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()]; let text = ops::build_links_text(&pages, "Links", Some("Home")); assert!(text.contains("= Links =")); assert!(text.contains("- [[About]]")); assert!(text.contains("- [[Notes]]")); assert!(!text.contains("[[Home]]"), "current page excluded"); let src = "= Existing =\n"; let doc = parse(src); let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); assert!(ops::links_edit(src, &doc, &uri, "Home", &pages, true).is_some()); } // :NuwikiCheckLinks — surface broken links across the workspace. #[test] fn nuwiki_check_links_distinguishes_broken_from_resolvable() { let idx = build_index( "/wiki", &[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")], ); let good = LinkTarget { kind: LinkKind::Wiki, path: Some("About".into()), ..Default::default() }; let broken = LinkTarget { kind: LinkKind::Wiki, path: Some("Ghost".into()), ..Default::default() }; assert!(idx.resolve(&good, "Home").is_some()); assert!( idx.resolve(&broken, "Home").is_none(), "broken link unresolved" ); } // ===================================================================== // Group 4: Tags // ===================================================================== // :NuwikiSearchTags {tag} — every `:tag:` occurrence. #[test] fn nuwiki_search_tags_finds_occurrences() { let idx = build_index( "/wiki", &[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")], ); let hits = ops::tag_hits(&idx, "alpha"); assert_eq!(hits.len(), 2); assert!(hits.iter().all(|h| h.name == "alpha")); assert!(ops::tag_hits(&idx, "nonexistent").is_empty()); } // :NuwikiGenerateTagLinks [tag] — section linking every page with a tag. #[test] fn nuwiki_generate_tag_links_builds_section() { let idx = build_index( "/wiki", &[ ("Home", ":alpha:\n"), ("Work", ":alpha:\n"), ("Misc", ":beta:\n"), ], ); let by_tag = ops::tag_pages_snapshot(&idx); let single = ops::build_tag_links_text(&by_tag, Some("alpha")).unwrap(); assert!(single.starts_with("= Tag: alpha =")); assert!(single.contains("- [[Home]]")); assert!(single.contains("- [[Work]]")); // No-arg variant emits a section per tag. let all = ops::build_tag_links_text(&by_tag, None).unwrap(); assert!(all.starts_with("= Tags =")); assert!(all.contains("== alpha ==")); assert!(all.contains("== beta ==")); let src = "= Home =\n"; let doc = parse(src); let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); assert!(ops::tag_links_edit(src, &doc, &uri, Some("alpha"), &by_tag, true).is_some()); } // :NuwikiRebuildTags — force a full workspace re-index. A rebuild must // reflect the current on-disk tags, dropping stale ones. #[test] fn nuwiki_rebuild_tags_reflects_current_state() { let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); idx.upsert(uri.clone(), &parse(":old:\n")); assert_eq!(idx.tags_for("old").len(), 1); // Re-index after the page changed on disk. idx.upsert(uri, &parse(":new:\n")); assert!(idx.tags_for("old").is_empty(), "stale tag dropped"); assert_eq!(idx.tags_for("new").len(), 1); } // ===================================================================== // Group 5: HTML export // ===================================================================== // :Nuwiki2HTML — render the current page to HTML. #[test] fn nuwiki_2html_renders_page() { let cfg = wiki_cfg("/wiki"); let doc = parse("%title My Page\n= One =\nbody text\n"); let html = export::render_page_html( &doc, Some("{{title}}
{{content}}
".into()), "Home", DiaryDate::from_ymd(2026, 5, 12).unwrap(), &cfg.html, Some(".wiki"), HashMap::new(), ) .unwrap(); assert!(html.contains("My Page")); assert!(html.contains("body text")); } // :Nuwiki2HTMLBrowse — render then open in the browser. The render output // is identical to :Nuwiki2HTML; the browse step is an editor-side open. #[test] fn nuwiki_2html_browse_shares_render_and_is_advertised() { assert!(COMMANDS.contains(&"nuwiki.export.browse")); let cfg = wiki_cfg("/wiki"); let doc = parse("= Page =\nhi\n"); let html = export::render_page_html( &doc, Some("{{content}}".into()), "Page", DiaryDate::today_utc(), &cfg.html, Some(".wiki"), HashMap::new(), ) .unwrap(); assert!(html.contains("hi")); } // :NuwikiAll2HTML[!] — export every page; honour the exclude list and // per-page output paths. #[test] fn nuwiki_all2html_maps_output_paths_and_excludes() { let cfg = wiki_cfg("/wiki"); assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html")); assert!( export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html") ); let patterns = vec!["_drafts/**".to_string()]; assert!(export::is_excluded(&patterns, "_drafts/secret")); assert!(!export::is_excluded(&patterns, "Home")); // Both incremental and forced (`!`) variants are advertised. assert!(COMMANDS.contains(&"nuwiki.export.allToHtml")); assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce")); } // :NuwikiRss — write `rss.xml` summarising recent diary entries. #[test] fn nuwiki_rss_writes_feed_file() { let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); let cfg = WikiConfig::from_root(root.clone()); let entries = vec![ diary::DiaryEntry { date: DiaryDate::from_ymd(2026, 5, 10).unwrap(), uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(), }, diary::DiaryEntry { date: DiaryDate::from_ymd(2026, 5, 12).unwrap(), uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(), }, ]; let path = export_ops::write_rss(&cfg, &entries).unwrap(); assert!(path.ends_with("rss.xml")); let xml = std::fs::read_to_string(&path).unwrap(); assert!(xml.contains("")); // Newest entry listed first. let p12 = xml.find("2026-05-12").unwrap(); let p10 = xml.find("2026-05-10").unwrap(); assert!(p12 < p10); let _ = std::fs::remove_dir_all(&root); } // ===================================================================== // Group 6: Other // ===================================================================== // :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an // editor-side action (binary download / cargo build); it is deliberately // NOT an LSP executeCommand, so the server must not advertise it. #[test] fn nuwiki_install_is_editor_only_not_an_lsp_command() { assert!(!COMMANDS.iter().any(|c| c.contains("install"))); } // ===================================================================== // Command-surface cross-check: every documented command that maps to an // executeCommand handler is actually advertised by the server. // ===================================================================== #[test] fn every_documented_lsp_command_is_advertised() { let expected = [ "nuwiki.wiki.openIndex", // :NuwikiIndex "nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex "nuwiki.wiki.listAll", // :NuwikiUISelect "nuwiki.wiki.gotoPage", // :NuwikiGoto "nuwiki.file.delete", // :NuwikiDeleteFile "nuwiki.diary.openToday", // :NuwikiMakeDiaryNote "nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote "nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote "nuwiki.diary.openIndex", // :NuwikiDiaryIndex "nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks "nuwiki.diary.next", // :NuwikiDiaryNextDay "nuwiki.diary.prev", // :NuwikiDiaryPrevDay "nuwiki.toc.generate", // :NuwikiTOC "nuwiki.links.generate", // :NuwikiGenerateLinks "nuwiki.workspace.checkLinks", // :NuwikiCheckLinks "nuwiki.tags.search", // :NuwikiSearchTags "nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks "nuwiki.tags.rebuild", // :NuwikiRebuildTags "nuwiki.export.currentToHtml", // :Nuwiki2HTML "nuwiki.export.browse", // :Nuwiki2HTMLBrowse "nuwiki.export.allToHtml", // :NuwikiAll2HTML "nuwiki.export.rss", // :NuwikiRss ]; for cmd in expected { assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}"); } }