6281c5830c
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
207 lines
8.5 KiB
Rust
207 lines
8.5 KiB
Rust
//! SPEC 4.3: one test per link-resolution rule.
|
|
|
|
use notescraft_index::{Error, Index, LinkKind, Resolution};
|
|
|
|
fn note(path: &str, content: &str) -> Index {
|
|
let mut index = Index::new();
|
|
index.update_note(path, content).expect("valid path");
|
|
index
|
|
}
|
|
|
|
fn resolved(index: &Index, from: &str, target: &str) -> (String, bool, bool) {
|
|
match index.resolve(from, target) {
|
|
Resolution::Resolved { path, ambiguous, subpath_ok } => (path, ambiguous, subpath_ok),
|
|
Resolution::Unresolved => panic!("{target:?} from {from:?} did not resolve"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rule1_basenames_match_case_insensitively_anywhere() {
|
|
let mut index = note("deep/folder/My Note.md", "# Head\n");
|
|
index.update_note("top.md", "[[my note]] and [[MY NOTE]]\n").unwrap();
|
|
|
|
let (path, ambiguous, _) = resolved(&index, "top.md", "my note");
|
|
assert_eq!(path, "deep/folder/My Note.md");
|
|
assert!(!ambiguous, "a single match is not ambiguous");
|
|
assert_eq!(resolved(&index, "top.md", "MY NOTE").0, "deep/folder/My Note.md");
|
|
}
|
|
|
|
#[test]
|
|
fn rule2_nearest_folder_wins_and_ambiguity_is_reported() {
|
|
let mut index = Index::new();
|
|
index.update_note("a/shared/Doc.md", "").unwrap();
|
|
index.update_note("b/topic/Doc.md", "").unwrap();
|
|
index.update_note("b/topic/note.md", "from b/topic\n").unwrap();
|
|
|
|
let (path, ambiguous, _) = resolved(&index, "b/topic/note.md", "Doc");
|
|
assert_eq!(path, "b/topic/Doc.md", "nearest folder wins");
|
|
assert!(ambiguous, "several notes matched");
|
|
}
|
|
|
|
#[test]
|
|
fn rule2_lexicographic_path_breaks_a_distance_tie() {
|
|
let mut index = Index::new();
|
|
index.update_note("m/Doc.md", "").unwrap();
|
|
index.update_note("z/Doc.md", "").unwrap();
|
|
index.update_note("root.md", "here\n").unwrap();
|
|
|
|
assert_eq!(resolved(&index, "root.md", "Doc").0, "m/Doc.md");
|
|
}
|
|
|
|
#[test]
|
|
fn rule3_folder_suffix_and_vault_absolute_match() {
|
|
let mut index = Index::new();
|
|
index.update_note("a/b/folder/Note.md", "").unwrap();
|
|
index.update_note("other/x.md", "").unwrap();
|
|
|
|
assert_eq!(resolved(&index, "other/x.md", "folder/Note").0, "a/b/folder/Note.md");
|
|
assert_eq!(resolved(&index, "other/x.md", "/a/b/folder/Note").0, "a/b/folder/Note.md");
|
|
assert_eq!(resolved(&index, "other/x.md", "b/folder/Note").0, "a/b/folder/Note.md", "any suffix of the path matches");
|
|
assert!(matches!(index.resolve("other/x.md", "/nope/Note"), Resolution::Unresolved));
|
|
}
|
|
|
|
#[test]
|
|
fn rule4_aliases_resolve_but_rank_below_basenames() {
|
|
let mut index = Index::new();
|
|
index.update_note("a/Real Name.md", "---\naliases: [Nickname, Second]\n---\n").unwrap();
|
|
index.update_note("b/Nickname.md", "").unwrap();
|
|
index.update_note("src.md", "").unwrap();
|
|
|
|
assert_eq!(resolved(&index, "src.md", "Nickname").0, "b/Nickname.md", "exact basename wins");
|
|
assert_eq!(resolved(&index, "src.md", "nickname").0, "b/Nickname.md", "aliases are case-insensitive too");
|
|
assert_eq!(resolved(&index, "src.md", "Second").0, "a/Real Name.md", "only the alias matches here");
|
|
}
|
|
|
|
#[test]
|
|
fn rule4_alias_key_is_alias_as_well_as_aliases() {
|
|
let index = note("n.md", "---\nalias: [Solo]\n---\n");
|
|
assert_eq!(resolved(&index, "x.md", "Solo").0, "n.md");
|
|
}
|
|
|
|
#[test]
|
|
fn rule5_heading_and_block_subpaths_are_validated() {
|
|
let index = note("target.md", "# Getting Started\n\ntext ^abc-1\n");
|
|
let (path, _, ok) = resolved(&index, "src.md", "Target#Getting Started");
|
|
assert_eq!(path, "target.md");
|
|
assert!(ok);
|
|
|
|
let (_, _, ok) = resolved(&index, "src.md", "Target#getting started");
|
|
assert!(ok, "anchors match case- and whitespace-insensitively");
|
|
|
|
let (_, _, ok) = resolved(&index, "src.md", "Target#^abc-1");
|
|
assert!(ok, "block ids resolve");
|
|
|
|
let (_, _, ok) = resolved(&index, "src.md", "Target#Missing");
|
|
assert!(!ok, "the note resolves but the heading does not exist");
|
|
|
|
let (_, _, ok) = resolved(&index, "src.md", "Target#^nope");
|
|
assert!(!ok, "the note resolves but the block id does not exist");
|
|
}
|
|
|
|
#[test]
|
|
fn rule5_same_note_subpaths_need_no_target() {
|
|
let index = note("self.md", "# Section\n\nbody ^blk\n");
|
|
assert_eq!(resolved(&index, "self.md", "#Section").0, "self.md");
|
|
assert!(resolved(&index, "self.md", "#Section").2);
|
|
assert_eq!(resolved(&index, "self.md", "#^blk").0, "self.md");
|
|
assert!(resolved(&index, "self.md", "#^blk").2);
|
|
let (_, _, ok) = resolved(&index, "self.md", "#Nope");
|
|
assert!(!ok);
|
|
assert!(matches!(index.resolve("self.md", "#Nope").path(), Some("self.md")));
|
|
}
|
|
|
|
#[test]
|
|
fn rule5_self_link_to_a_missing_heading_is_flagged_not_broken() {
|
|
let index = note("self.md", "# Section\n");
|
|
let (path, _, ok) = resolved(&index, "self.md", "#Heading");
|
|
assert_eq!(path, "self.md", "the note is there, the heading is not");
|
|
assert!(!ok, "the missing heading is reported through subpath_ok");
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_links_are_relative_to_the_linking_note_and_decoded() {
|
|
let mut index = Index::new();
|
|
index.update_note("notes/Deep Note.md", "# Head\n").unwrap();
|
|
index.update_note("notes/src.md", "[a](Deep%20Note.md#Head) [b](sub/other.md)\n").unwrap();
|
|
index.update_note("notes/sub/other.md", "").unwrap();
|
|
|
|
let (path, _, ok) = resolved(&index, "notes/src.md", "Deep Note.md#Head");
|
|
assert_eq!(path, "notes/Deep Note.md");
|
|
assert!(ok);
|
|
|
|
assert_eq!(resolved(&index, "notes/src.md", "sub/other.md").0, "notes/sub/other.md");
|
|
|
|
let outgoing = index.outgoing("notes/src.md");
|
|
assert_eq!(outgoing.len(), 2);
|
|
assert_eq!(outgoing.first().map(|l| l.kind), Some(LinkKind::Markdown));
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_links_with_dot_dot_resolve_inside_the_vault() {
|
|
let mut index = Index::new();
|
|
index.update_note("top/target.md", "").unwrap();
|
|
index.update_note("top/a/src.md", "[x](../../top/target.md)\n").unwrap();
|
|
assert_eq!(resolved(&index, "top/a/src.md", "../../top/target.md").0, "top/target.md");
|
|
assert!(matches!(index.resolve("top/a/src.md", "../../../outside.md"), Resolution::Unresolved));
|
|
}
|
|
|
|
#[test]
|
|
fn external_links_are_not_indexed() {
|
|
let index = note("src.md", "[web](https://example.com/a) [mail](mailto:a@b.c) [local](local.md)\n");
|
|
let outgoing = index.outgoing("src.md");
|
|
assert_eq!(outgoing.len(), 1, "only the internal link is indexed: {outgoing:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn embeds_of_notes_and_attachments_resolve() {
|
|
let mut index = note("notes/Target.md", "# Head\n");
|
|
index.add_file("assets/img name.png").unwrap();
|
|
|
|
let (path, _, _) = resolved(&index, "notes/src.md", "![[img name.png]]");
|
|
assert_eq!(path, "assets/img name.png");
|
|
assert_eq!(resolved(&index, "notes/src.md", "![[assets/img name.png|300]]").0, "assets/img name.png");
|
|
assert_eq!(resolved(&index, "notes/src.md", "![[Target#Head]]").0, "notes/Target.md");
|
|
assert!(resolved(&index, "notes/src.md", "![[img name.png]]").2, "attachment subpaths cannot be validated");
|
|
assert_eq!(index.outgoing("notes/src.md").len(), 0, "links live in the note, not in the target");
|
|
}
|
|
|
|
#[test]
|
|
fn extension_is_optional_on_note_targets() {
|
|
let index = note("a/Note.md", "");
|
|
assert_eq!(resolved(&index, "b/x.md", "Note.md").0, "a/Note.md");
|
|
assert_eq!(resolved(&index, "b/x.md", "a/Note.md").0, "a/Note.md");
|
|
}
|
|
|
|
#[test]
|
|
fn unlinked_self_links_and_cycles_resolve() {
|
|
let mut index = Index::new();
|
|
index.update_note("a.md", "[[b]] [[a]]\n").unwrap();
|
|
index.update_note("b.md", "[[a]]\n").unwrap();
|
|
assert_eq!(resolved(&index, "a.md", "b").0, "b.md");
|
|
assert_eq!(resolved(&index, "b.md", "a").0, "a.md");
|
|
let mut out = index.outgoing("a.md");
|
|
out.sort_by(|l, r| l.target.cmp(&r.target));
|
|
assert_eq!(out.len(), 2);
|
|
assert!(out.iter().all(|l| l.resolution.is_resolved()));
|
|
}
|
|
|
|
#[test]
|
|
fn bad_paths_are_errors_not_silent_fixes() {
|
|
let mut index = Index::new();
|
|
for bad in ["", "/abs.md", "../out.md", "a/../b.md", ".hidden/x.md", ".obsidian/app.json", "n.md/", "a//b.md", "back\\slash.md", "C:/x.md"] {
|
|
let err = index.update_note(bad, "body").expect_err("must be rejected");
|
|
assert!(matches!(err, Error::InvalidPath(_) | Error::OutsideVault(_) | Error::ReservedPath(_)), "{bad:?} -> {err:?}");
|
|
}
|
|
assert!(matches!(index.update_note("note.txt", "x"), Err(Error::NotMarkdown(_))));
|
|
assert!(matches!(index.add_file("note.md"), Err(Error::MarkdownPath(_))));
|
|
assert_eq!(index.note("note.txt"), None);
|
|
assert!(matches!(index.remove_note("gone.md"), Err(Error::NotIndexed(_))));
|
|
assert_eq!(index.notes(), Vec::<&str>::new());
|
|
}
|
|
|
|
#[test]
|
|
fn long_paths_are_rejected() {
|
|
let long = format!("{}/x.md", "d".repeat(notescraft_index::MAX_PATH_BYTES));
|
|
assert!(matches!(Index::new().update_note(&long, "x"), Err(Error::PathTooLong { .. })));
|
|
}
|