//! Phase 18: multi-wiki — interwiki resolution + picker commands. //! //! The cross-wiki resolver methods on `Backend` are exercised indirectly //! through `tower_lsp::LspService`'s test harness via a synthetic //! `Backend` (the same pattern Phase 13 used). To keep this test suite //! lightweight we drive the *pure* resolution primitives — the parser //! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex` //! lookup-by-name — and verify the multi-wiki config shape end-to-end. use std::path::PathBuf; use nuwiki_core::ast::{InlineNode, LinkKind}; use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::SyntaxPlugin; use nuwiki_lsp::config::config_from_json; use nuwiki_lsp::index::WorkspaceIndex; use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, WikiId}; use tower_lsp::lsp_types::Url; fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { VimwikiSyntax::new().parse(src) } fn first_link(src: &str) -> nuwiki_core::ast::WikiLinkNode { let doc = parse(src); for block in &doc.children { if let nuwiki_core::ast::BlockNode::Paragraph(p) = block { for inline in &p.children { if let InlineNode::WikiLink(w) = inline { return w.clone(); } } } } panic!("no wikilink in: {src}"); } // ===== Parser-level: `wiki:` / `wn.:` produce Interwiki ===== #[test] fn numbered_interwiki_link_parses_to_wiki_index() { let w = first_link("[[wiki1:OtherPage]]\n"); assert_eq!(w.target.kind, LinkKind::Interwiki); assert_eq!(w.target.wiki_index, Some(1)); assert_eq!(w.target.path.as_deref(), Some("OtherPage")); assert!(w.target.wiki_name.is_none()); } #[test] fn named_interwiki_link_parses_to_wiki_name() { let w = first_link("[[wn.work:Project]]\n"); assert_eq!(w.target.kind, LinkKind::Interwiki); assert_eq!(w.target.wiki_name.as_deref(), Some("work")); assert_eq!(w.target.path.as_deref(), Some("Project")); assert!(w.target.wiki_index.is_none()); } #[test] fn plain_wikilink_is_not_interwiki() { let w = first_link("[[Home]]\n"); assert_eq!(w.target.kind, LinkKind::Wiki); assert!(w.target.wiki_index.is_none()); assert!(w.target.wiki_name.is_none()); } // ===== Config: `wikis = [...]` shape ===== #[test] fn multi_wiki_config_loads_two_entries() { let cfg = config_from_json(serde_json::json!({ "wikis": [ { "root": "/tmp/personal", "name": "personal" }, { "root": "/tmp/work", "name": "work" }, ], })); assert_eq!(cfg.wikis.len(), 2); assert_eq!(cfg.wikis[0].name, "personal"); assert_eq!(cfg.wikis[1].name, "work"); } #[test] fn v1_0_single_root_still_works_alongside_multi_wiki_shape() { // Single-wiki shorthand is the v1.0 form; ensure it still desugars. let cfg = config_from_json(serde_json::json!({ "wiki_root": "/tmp/legacy", })); assert_eq!(cfg.wikis.len(), 1); assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/legacy")); } // ===== Wiki aggregate + URI routing ===== #[test] fn build_wikis_assigns_sequential_ids_starting_at_zero() { let cfg = config_from_json(serde_json::json!({ "wikis": [ { "root": "/tmp/p" }, { "root": "/tmp/q" }, { "root": "/tmp/r" }, ], })); let wikis = build_wikis(&cfg.wikis); assert_eq!(wikis.len(), 3); for (i, w) in wikis.iter().enumerate() { assert_eq!(w.id, WikiId(i as u32)); } } #[test] fn resolve_uri_to_wiki_routes_to_the_owning_root() { let cfg = config_from_json(serde_json::json!({ "wikis": [ { "root": "/tmp/personal" }, { "root": "/tmp/work" }, ], })); let wikis = build_wikis(&cfg.wikis); let uri = Url::from_file_path("/tmp/work/Project.wiki").unwrap(); assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1))); } #[test] fn nested_root_picks_longest_prefix_match() { let cfg = config_from_json(serde_json::json!({ "wikis": [ { "root": "/notes" }, { "root": "/notes/sub" }, ], })); let wikis = build_wikis(&cfg.wikis); let uri = Url::from_file_path("/notes/sub/page.wiki").unwrap(); // The nested wiki wins because its root is a longer prefix. assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1))); } // ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) ===== /// Verify that the cross-wiki resolution semantics match the SPEC: /// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not /// the source wiki's. The pure-data check here mirrors what /// `Backend::resolve_target_uri` does. #[test] fn interwiki_resolution_uses_target_wiki_index() { // Build two wikis with overlapping page names so we can tell whose // index was consulted. let mut work = WorkspaceIndex::new(Some(PathBuf::from("/tmp/work"))); work.upsert( Url::from_file_path("/tmp/work/Project.wiki").unwrap(), &parse("= Work Project =\n"), ); let mut personal = WorkspaceIndex::new(Some(PathBuf::from("/tmp/personal"))); personal.upsert( Url::from_file_path("/tmp/personal/Project.wiki").unwrap(), &parse("= Personal Project =\n"), ); // Simulate the resolver: source is personal, target is `wiki1:Project` // which should hit the work index (assumed to be index 1). let w = first_link("[[wiki1:Project]]\n"); assert_eq!(w.target.wiki_index, Some(1)); let resolved = work.pages_by_name.get("Project").cloned(); assert!(resolved.is_some(), "work wiki should resolve Project"); assert!( resolved .unwrap() .as_str() .contains("/tmp/work/Project.wiki"), "must come from the work wiki, not personal" ); } #[test] fn interwiki_resolution_falls_back_when_wiki_missing() { // Only one wiki registered; `[[wiki2:X]]` references the third // wiki by index — no match expected. let cfg = config_from_json(serde_json::json!({ "wikis": [ { "root": "/tmp/only" } ], })); let wikis = build_wikis(&cfg.wikis); // Index 2 is out of range. assert!(wikis.get(2).is_none()); } // ===== Wiki commands: COMMANDS list completeness ===== #[test] fn commands_list_includes_phase18_entries() { let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); for name in [ "nuwiki.wiki.listAll", "nuwiki.wiki.select", "nuwiki.wiki.openIndex", "nuwiki.wiki.tabOpenIndex", "nuwiki.wiki.gotoPage", ] { assert!(names.contains(&name), "missing: {name}"); } } // ===== Multi-wiki link kind matrix ===== #[test] fn link_kind_matrix_for_typical_targets() { let cases = &[ ("[[Home]]", LinkKind::Wiki), ("[[/Page]]", LinkKind::Wiki), // root-relative still Wiki ("[[//abs/path]]", LinkKind::Local), // filesystem-absolute ("[[wiki1:X]]", LinkKind::Interwiki), ("[[wn.work:Y]]", LinkKind::Interwiki), ("[[file:foo.pdf]]", LinkKind::File), ("[[local:foo.pdf]]", LinkKind::Local), ("[[diary:2026-05-11]]", LinkKind::Diary), ("[[#anchor]]", LinkKind::AnchorOnly), ]; for (src, expected) in cases { let w = first_link(&format!("{src}\n")); assert_eq!(w.target.kind, *expected, "case: {src}"); } }