Files
2026-10-13 19:21:58 -03:00

134 lines
6.1 KiB
Rust

//! The index must stay correct under incremental updates: a new basename
//! re-resolves links that were waiting for it, a removal breaks them again,
//! and a rename only re-keys.
use notescraft_index::{Error, Index, Resolution};
#[test]
fn a_new_note_resolves_the_links_waiting_for_it() {
let mut index = Index::new();
index.update_note("a/src.md", "[[Later]] [[later]] ![[Later#Head]]\n").unwrap();
assert_eq!(index.unresolved().len(), 3);
index.update_note("deep/Later.md", "# Head\n").unwrap();
assert_eq!(index.unresolved().len(), 0, "the new basename resolves the old links");
let outgoing = index.outgoing("a/src.md");
assert!(outgoing.iter().all(|l| l.resolution.path() == Some("deep/Later.md")));
assert!(matches!(outgoing.last().map(|l| &l.resolution), Some(Resolution::Resolved { subpath_ok: true, .. })));
index.remove_note("deep/Later.md").unwrap();
assert_eq!(index.unresolved().len(), 3, "removing it breaks them again");
assert_eq!(index.backlinks("a/src.md"), Vec::new());
}
#[test]
fn a_new_heading_or_block_repairs_a_subpath() {
let mut index = Index::new();
index.update_note("t.md", "text\n").unwrap();
index.update_note("s.md", "[[t#Fresh]] [[t#^new]]\n").unwrap();
assert!(index.outgoing("s.md").iter().any(|l| matches!(l.resolution, Resolution::Resolved { subpath_ok: false, .. })));
index.update_note("t.md", "text\n\n# Fresh\n\nmore ^new\n").unwrap();
assert!(index.outgoing("s.md").iter().all(|l| matches!(l.resolution, Resolution::Resolved { subpath_ok: true, .. })));
}
#[test]
fn ambiguity_disappears_when_the_closer_match_is_removed() {
let mut index = Index::new();
index.update_note("far/Doc.md", "").unwrap();
index.update_note("near/Doc.md", "").unwrap();
index.update_note("near/src.md", "[[Doc]]\n").unwrap();
assert!(matches!(index.resolve("near/src.md", "Doc"), Resolution::Resolved { path: ref p, ambiguous: true, .. } if p == "near/Doc.md"));
index.remove_note("near/Doc.md").unwrap();
assert_eq!(index.resolve("near/src.md", "Doc"), Resolution::Resolved { path: String::from("far/Doc.md"), ambiguous: false, subpath_ok: true });
}
#[test]
fn updating_a_note_replaces_its_links_aliases_and_tags() {
let mut index = Index::new();
index.update_note("n.md", "---\naliases: [Old]\ntags: [old]\n---\n[[target]]\n").unwrap();
index.update_note("target.md", "").unwrap();
index.update_note("src.md", "[[Old]]\n").unwrap();
assert_eq!(index.backlinks("n.md").len(), 1, "the alias link from src");
index.update_note("n.md", "---\naliases: [New]\ntags: [new]\n---\n").unwrap();
assert_eq!(index.backlinks("n.md").len(), 0);
assert!(matches!(index.resolve("src.md", "Old"), Resolution::Unresolved), "the old alias is gone");
assert!(index.resolve("src.md", "New").is_resolved());
assert_eq!(index.note("n.md").map(|n| n.tags.clone()), Some(vec![String::from("new")]));
assert_eq!(index.notes_with_tag("old", false), Vec::<&str>::new());
assert_eq!(index.notes_with_tag("new", false), vec!["n.md"]);
}
#[test]
fn rename_rekeys_without_rewriting_any_content() {
let mut index = Index::new();
index.update_note("old/Thing.md", "---\naliases: [Thing]\n---\n# Head\n\n[[Thing]] [[other]]\n").unwrap();
index.update_note("other.md", "[[Thing]]\n").unwrap();
let before = index.note("old/Thing.md").expect("indexed").clone();
index.rename_note("old/Thing.md", "new/Thing.md").unwrap();
let after = index.note("new/Thing.md").expect("re-keyed");
assert_eq!(after.path, "new/Thing.md");
assert_eq!(after.links, before.links, "content and link ranges are untouched");
assert_eq!(index.note("old/Thing.md"), None, "the old path is gone");
assert_eq!(index.notes(), vec!["new/Thing.md", "other.md"]);
assert_eq!(index.unresolved().len(), 0, "links by basename survive a rename");
assert_eq!(index.headings("new/Thing.md").map(<[_]>::len), Some(1));
}
#[test]
fn a_rename_into_an_existing_name_replaces_the_target_note() {
let mut index = Index::new();
index.update_note("a/Name.md", "first\n").unwrap();
index.update_note("b/Name.md", "second\n").unwrap();
index.update_note("src.md", "[[Name]]\n").unwrap();
index.rename_note("a/Name.md", "b/Name.md").unwrap();
assert_eq!(index.notes(), vec!["b/Name.md", "src.md"]);
assert_eq!(index.note("b/Name.md").map(|n| n.links.len()), Some(0), "the renamed note is indexed");
assert_eq!(index.note("a/Name.md"), None);
}
#[test]
fn renames_move_the_folder_tiebreak() {
let mut index = Index::new();
index.update_note("m/Doc.md", "").unwrap();
index.update_note("z/Doc.md", "").unwrap();
index.update_note("m/src.md", "[[Doc]]\n").unwrap();
assert_eq!(index.resolve("m/src.md", "Doc").path(), Some("m/Doc.md"));
index.rename_note("m/Doc.md", "m2/Doc.md").unwrap();
let resolution = index.resolve("m/src.md", "Doc");
assert!(
matches!(resolution, Resolution::Resolved { ref path, ambiguous: true, .. } if path == "m2/Doc.md"),
"both Docs match now, the lexicographically smaller one wins: {resolution:?}"
);
index.update_note("m/src.md", "[[m2/Doc]]\n").unwrap();
assert_eq!(index.resolve("m/src.md", "m2/Doc").path(), Some("m2/Doc.md"), "an explicit path is never ambiguous");
}
#[test]
fn renaming_an_unknown_note_is_an_error() {
let mut index = Index::new();
index.update_note("n.md", "").unwrap();
assert_eq!(index.rename_note("gone.md", "other.md"), Err(Error::NotIndexed(String::from("gone.md"))));
assert!(matches!(index.rename_note("n.md", "../evil.md"), Err(Error::OutsideVault(_))));
assert_eq!(index.notes(), vec!["n.md"], "a failed rename changes nothing");
}
#[test]
fn remove_note_reports_unknown_paths_and_detaches_the_note() {
let mut index = Index::new();
index.update_note("n.md", "[[n]]\n").unwrap();
assert_eq!(index.remove_note("missing.md"), Err(Error::NotIndexed(String::from("missing.md"))));
assert_eq!(index.note("n.md").map(|n| n.links.len()), Some(1));
index.remove_note("N.MD").expect("matched case-insensitively");
assert_eq!(index.notes(), Vec::<&str>::new());
assert!(matches!(index.resolve("n.md", "n"), Resolution::Unresolved));
}