#![allow(clippy::missing_errors_doc)]
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use crate::discovery;
use crate::frontmatter;
use crate::links::{Link, LinkKind, extract_links_from_text_with_original};
use crate::scanner::{self, FileVisitor, ScanAction};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BacklinkEntry {
pub source: PathBuf,
pub line: usize,
pub link: Link,
}
pub struct LinkGraphBuild {
pub graph: LinkGraph,
pub warnings: Vec<(PathBuf, String)>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct LinkGraph {
index: HashMap<String, Vec<BacklinkEntry>>,
}
impl LinkGraph {
pub fn build(dir: &Path, site_prefix: Option<&str>) -> Result<LinkGraphBuild> {
let files = discovery::discover_files(dir)?;
let pairs: Vec<(PathBuf, PathBuf)> = files
.into_iter()
.map(|f| {
let rel = f.strip_prefix(dir).unwrap_or(&f).to_path_buf();
(f, rel)
})
.collect();
Self::build_from_files(&pairs, site_prefix)
}
pub fn build_from_files(
files: &[(PathBuf, PathBuf)],
site_prefix: Option<&str>,
) -> Result<LinkGraphBuild> {
let mut index: HashMap<String, Vec<BacklinkEntry>> = HashMap::with_capacity(files.len());
let mut warnings: Vec<(PathBuf, String)> = Vec::new();
for (full_path, rel) in files {
let mut visitor = LinkGraphVisitor::new(rel.clone());
match scanner::scan_file_multi(full_path, &mut [&mut visitor]) {
Ok(()) => {}
Err(e) if frontmatter::is_parse_error(&e) => {
warnings.push((rel.clone(), e.to_string()));
continue;
}
Err(e) => return Err(e),
}
insert_file_links(&mut index, visitor.into_file_links(), site_prefix);
}
Ok(LinkGraphBuild {
graph: Self { index },
warnings,
})
}
pub(crate) fn from_file_links(
file_links: Vec<FileLinks>,
site_prefix: Option<&str>,
) -> LinkGraphBuild {
let mut index: HashMap<String, Vec<BacklinkEntry>> =
HashMap::with_capacity(file_links.len());
for fl in file_links {
insert_file_links(&mut index, fl, site_prefix);
}
LinkGraphBuild {
graph: Self { index },
warnings: Vec::new(),
}
}
pub fn all_targets(&self) -> HashSet<String> {
self.index.keys().cloned().collect()
}
pub fn all_sources(&self) -> HashSet<String> {
self.index
.values()
.flatten()
.map(|entry| {
entry.source.to_string_lossy().replace('\\', "/")
})
.collect()
}
pub fn backlinks(&self, target: &str) -> Vec<&BacklinkEntry> {
let mut results = Vec::new();
if let Some(entries) = self.index.get(target) {
results.extend(entries);
}
let alt = if let Some(stem) = target.strip_suffix(".md") {
stem.to_string()
} else {
format!("{target}.md")
};
if let Some(entries) = self.index.get(&alt) {
results.extend(entries);
}
results
}
pub fn rename_path(&mut self, old_rel: &str, new_rel: &str) {
let old_stem = old_rel.strip_suffix(".md").unwrap_or(old_rel);
let new_stem = new_rel.strip_suffix(".md").unwrap_or(new_rel);
let key_pairs: [(&str, &str); 2] = [(old_stem, new_stem), (old_rel, new_rel)];
for (old_key, new_key) in key_pairs {
if old_key != new_key
&& let Some(mut entries) = self.index.remove(old_key)
{
for entry in &mut entries {
if entry.link.target == old_key {
new_key.clone_into(&mut entry.link.target);
}
}
self.index
.entry(new_key.to_owned())
.or_default()
.extend(entries);
}
}
let old_path = PathBuf::from(old_rel.replace('/', std::path::MAIN_SEPARATOR_STR));
let new_path = PathBuf::from(new_rel.replace('/', std::path::MAIN_SEPARATOR_STR));
for entries in self.index.values_mut() {
for entry in entries.iter_mut() {
if entry.source == old_path {
entry.source.clone_from(&new_path);
}
}
}
}
}
pub fn is_self_link(entry: &BacklinkEntry, target: &str) -> bool {
let alt = if let Some(stem) = target.strip_suffix(".md") {
stem.to_string()
} else {
format!("{target}.md")
};
let source = entry.source.to_string_lossy().replace('\\', "/");
source == target || source == alt
}
pub(crate) struct FileLinks {
pub(crate) source: PathBuf,
pub(crate) links: Vec<(usize, Link)>,
}
fn insert_file_links(
index: &mut HashMap<String, Vec<BacklinkEntry>>,
file_links: FileLinks,
site_prefix: Option<&str>,
) {
for (line, mut link) in file_links.links {
if link.kind == LinkKind::Markdown
&& (link.target.contains('/') || link.target.contains('\\'))
{
if link.target.starts_with('/') {
link.target = strip_site_prefix(&link.target, site_prefix);
} else {
link.target = normalize_target(&file_links.source, &link.target);
}
}
index
.entry(link.target.clone())
.or_default()
.push(BacklinkEntry {
source: file_links.source.clone(),
line,
link,
});
}
}
pub(crate) struct LinkGraphVisitor {
source: PathBuf,
links: Vec<(usize, Link)>,
scratch: Vec<Link>,
}
impl LinkGraphVisitor {
pub fn new(source: PathBuf) -> Self {
Self {
source,
links: Vec::new(),
scratch: Vec::new(),
}
}
pub fn into_file_links(self) -> FileLinks {
FileLinks {
source: self.source,
links: self.links,
}
}
}
impl FileVisitor for LinkGraphVisitor {
fn on_body_line(&mut self, raw: &str, cleaned: &str, line_num: usize) -> ScanAction {
self.scratch.clear();
extract_links_from_text_with_original(cleaned, raw, &mut self.scratch);
for link in self.scratch.drain(..) {
self.links.push((line_num, link));
}
ScanAction::Continue
}
fn needs_frontmatter(&self) -> bool {
false
}
}
pub(crate) fn strip_site_prefix(target: &str, site_prefix: Option<&str>) -> String {
let without_slash = target.strip_prefix('/').unwrap_or(target);
if let Some(prefix) = site_prefix {
let with_slash = format!("{prefix}/");
if let Some(rest) = without_slash.strip_prefix(&with_slash) {
return rest.to_owned();
}
}
without_slash.to_owned()
}
pub(crate) fn normalize_target(source: &Path, target: &str) -> String {
let base = source.parent().unwrap_or(Path::new(""));
let joined = base.join(target);
normalize_path_components(&joined)
}
pub(crate) fn normalize_path_components(path: &Path) -> String {
let mut parts: Vec<&str> = Vec::new();
for component in path.components() {
match component {
Component::ParentDir => {
if parts.last().is_some_and(|p| *p != "..") {
parts.pop();
} else {
parts.push("..");
}
}
Component::Normal(s) => {
if let Some(s) = s.to_str() {
parts.push(s);
}
}
Component::CurDir | Component::Prefix(_) | Component::RootDir => {}
}
}
parts.join("/")
}
#[allow(dead_code)] pub(crate) fn relative_path_between(from_file: &str, to_file: &str) -> String {
let from_parts: Vec<&str> = from_file.split('/').collect();
let from_dir: Vec<&str> = if from_parts.len() > 1 {
from_parts[..from_parts.len() - 1].to_vec()
} else {
Vec::new()
};
if from_dir.is_empty() {
return to_file.to_string();
}
let to_parts: Vec<&str> = to_file.split('/').collect();
let common = from_dir
.iter()
.zip(to_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let up_count = from_dir.len() - common;
let remaining = &to_parts[common..];
let mut result: Vec<&str> = Vec::with_capacity(up_count + remaining.len());
result.extend(std::iter::repeat_n("..", up_count));
result.extend_from_slice(remaining);
result.join("/")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Write as _;
use std::fs;
fn create_vault(files: &[(&str, &str)]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
for (name, content) in files {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, content).unwrap();
}
dir
}
#[test]
fn build_graph_simple_wikilinks() {
let vault = create_vault(&[
("a.md", "---\ntitle: A\n---\nSee [[b]]\n"),
("b.md", "---\ntitle: B\n---\nSee [[a]] and [[c]]\n"),
("c.md", "---\ntitle: C\n---\nNo links here\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl_b = graph.backlinks("b");
assert_eq!(bl_b.len(), 1);
assert_eq!(bl_b[0].source, PathBuf::from("a.md"));
assert_eq!(bl_b[0].line, 4);
let bl_a = graph.backlinks("a");
assert_eq!(bl_a.len(), 1);
assert_eq!(bl_a[0].source, PathBuf::from("b.md"));
let bl_c = graph.backlinks("c");
assert_eq!(bl_c.len(), 1);
assert_eq!(bl_c[0].source, PathBuf::from("b.md"));
assert!(graph.backlinks("nonexistent").is_empty());
}
#[test]
fn build_graph_markdown_links() {
let vault = create_vault(&[
("a.md", "See [note](sub/b.md) for details\n"),
("sub/b.md", "Back to [a](../a.md)\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("sub/b.md");
assert_eq!(bl.len(), 1);
assert_eq!(bl[0].source, PathBuf::from("a.md"));
let bl = graph.backlinks("a.md");
assert_eq!(bl.len(), 1);
assert_eq!(bl[0].source, PathBuf::from("sub/b.md"));
}
#[test]
fn cross_directory_relative_link_normalized() {
let vault = create_vault(&[
("assets/img.md", "# Image\n"),
("notes/page.md", "See [img](../assets/img.md)\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("assets/img.md");
assert_eq!(bl.len(), 1);
assert_eq!(bl[0].source, PathBuf::from("notes/page.md"));
let raw_bl = graph.backlinks("../assets/img.md");
assert!(raw_bl.is_empty());
}
#[test]
fn parent_dir_link_from_subdirectory() {
let vault = create_vault(&[
("target.md", "# Target\n"),
("sub/a.md", "[link](../target.md)\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("target.md");
assert_eq!(bl.len(), 1);
assert_eq!(bl[0].source, PathBuf::from("sub/a.md"));
}
#[test]
fn normalize_path_components_dot_dot() {
assert_eq!(
normalize_path_components(Path::new("sub/../target.md")),
"target.md"
);
assert_eq!(
normalize_path_components(Path::new("a/b/../../c.md")),
"c.md"
);
assert_eq!(
normalize_path_components(Path::new("notes/../assets/img.md")),
"assets/img.md"
);
}
#[test]
fn build_graph_with_alias() {
let vault = create_vault(&[("a.md", "See [[b|my note B]]\n")]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("b");
assert_eq!(bl.len(), 1);
assert_eq!(bl[0].link.label.as_deref(), Some("my note B"));
}
#[test]
fn backlinks_matches_with_and_without_md_extension() {
let vault = create_vault(&[
("a.md", "Link to [[notes]]\n"),
("b.md", "Link to [text](notes.md)\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("notes.md");
assert_eq!(bl.len(), 2);
let bl = graph.backlinks("notes");
assert_eq!(bl.len(), 2);
}
#[test]
fn links_inside_code_blocks_ignored() {
let vault = create_vault(&[("a.md", "---\ntitle: A\n---\n```\n[[b]]\n```\nReal [[c]]\n")]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
assert!(graph.backlinks("b").is_empty());
assert_eq!(graph.backlinks("c").len(), 1);
}
#[test]
fn links_inside_inline_code_ignored() {
let vault = create_vault(&[("a.md", "Use `[[b]]` syntax and [[c]]\n")]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
assert!(graph.backlinks("b").is_empty());
assert_eq!(graph.backlinks("c").len(), 1);
}
#[test]
fn malformed_yaml_ignored_when_frontmatter_not_needed() {
let vault = create_vault(&[("a.md", "---\n: bad yaml [[\n---\n[[b]]\n")]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
assert_eq!(graph.backlinks("b").len(), 1);
}
#[test]
fn unclosed_frontmatter_skipped() {
let vault = create_vault(&[
("good.md", "---\ntitle: Good\n---\n[[target]]\n"),
(
"bad.md",
"---\nunclosed frontmatter without closing delimiter\n",
),
("also_good.md", "---\ntitle: Also Good\n---\n[[target]]\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
assert_eq!(build.warnings.len(), 1);
assert!(build.warnings[0].0.to_str().unwrap().contains("bad.md"));
let bl = build.graph.backlinks("target");
assert_eq!(bl.len(), 2, "both good files should link to target");
}
#[test]
fn frontmatter_too_large_skipped() {
let mut huge_fm = String::from("---\n");
for i in 0..201 {
let _ = writeln!(huge_fm, "key{i}: value");
}
huge_fm.push_str("[[target]]\n");
let vault = create_vault(&[("good.md", "[[target]]\n"), ("huge.md", &huge_fm)]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("target");
assert_eq!(bl.len(), 1, "good file should still be indexed");
}
#[test]
fn empty_vault() {
let vault = create_vault(&[]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
assert!(graph.backlinks("anything").is_empty());
}
#[test]
fn relative_path_same_directory() {
assert_eq!(relative_path_between("sub/a.md", "sub/b.md"), "b.md");
}
#[test]
fn relative_path_from_root_to_subdir() {
assert_eq!(relative_path_between("a.md", "sub/b.md"), "sub/b.md");
}
#[test]
fn relative_path_from_subdir_to_root() {
assert_eq!(relative_path_between("sub/a.md", "b.md"), "../b.md");
}
#[test]
fn relative_path_cross_directory() {
assert_eq!(
relative_path_between("sub/a.md", "other/b.md"),
"../other/b.md"
);
}
#[test]
fn relative_path_deep_to_shallow() {
assert_eq!(relative_path_between("a/b/c.md", "d.md"), "../../d.md");
}
#[test]
fn relative_path_shallow_to_deep() {
assert_eq!(relative_path_between("a.md", "x/y/z.md"), "x/y/z.md");
}
#[test]
fn relative_path_nested_common_prefix() {
assert_eq!(relative_path_between("a/b/c.md", "a/d/e.md"), "../d/e.md");
}
#[test]
fn wikilink_with_path_separator_found_by_backlinks() {
let vault = create_vault(&[
("backlog/item.md", "---\ntitle: Item\n---\nContent\n"),
(
"iterations/iter-1.md",
"---\ntitle: Iter 1\n---\nSee [[backlog/item]]\n",
),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("backlog/item");
assert_eq!(bl.len(), 1, "backlinks('backlog/item') should find 1 link");
assert_eq!(bl[0].source, PathBuf::from("iterations/iter-1.md"));
let bl = graph.backlinks("backlog/item.md");
assert_eq!(
bl.len(),
1,
"backlinks('backlog/item.md') should find 1 link"
);
}
#[test]
fn wikilink_with_path_and_md_extension() {
let vault = create_vault(&[
("backlog/item.md", "Content\n"),
("a.md", "See [[backlog/item.md]]\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("backlog/item.md");
assert_eq!(bl.len(), 1);
let bl = graph.backlinks("backlog/item");
assert_eq!(bl.len(), 1);
}
#[test]
fn wikilink_from_subdirectory_with_path() {
let vault = create_vault(&[
("other/target.md", "Content\n"),
("sub/source.md", "See [[other/target]]\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("other/target");
assert_eq!(bl.len(), 1, "should find wikilink from sub/source.md");
assert_eq!(bl[0].source, PathBuf::from("sub/source.md"));
let bl_wrong = graph.backlinks("sub/other/target");
assert!(
bl_wrong.is_empty(),
"should NOT find under sub/other/target"
);
}
#[test]
fn markdown_link_with_path_still_normalized() {
let vault = create_vault(&[
("target.md", "Content\n"),
("sub/source.md", "See [link](../target.md)\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("target.md");
assert_eq!(
bl.len(),
1,
"relative markdown link should still be normalized"
);
assert_eq!(bl[0].source, PathBuf::from("sub/source.md"));
}
#[test]
fn absolute_link_resolved_with_site_prefix() {
let vault = create_vault(&[
("source.md", "[link](/docs/target.md)\n"),
("target.md", "# Target\n"),
]);
let build = LinkGraph::build(vault.path(), Some("docs")).unwrap();
let graph = build.graph;
let bl = graph.backlinks("target.md");
assert_eq!(bl.len(), 1, "absolute link should resolve to target.md");
assert_eq!(bl[0].source, PathBuf::from("source.md"));
}
#[test]
fn absolute_link_without_prefix() {
let vault = create_vault(&[("source.md", "[link](/page.md)\n"), ("page.md", "# Page\n")]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("page.md");
assert_eq!(
bl.len(),
1,
"absolute link should resolve to page.md with no prefix"
);
assert_eq!(bl[0].source, PathBuf::from("source.md"));
let raw_bl = graph.backlinks("/page.md");
assert!(raw_bl.is_empty(), "raw absolute path must not be indexed");
}
#[test]
fn self_links_present_in_raw_backlinks() {
let vault = create_vault(&[
("a.md", "Self-reference: [[a]] and also [[b]]\n"),
("b.md", "Link to [[a]]\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl_a = graph.backlinks("a");
assert_eq!(bl_a.len(), 2, "raw results must include the self-link");
let sources: Vec<_> = bl_a.iter().map(|e| e.source.to_str().unwrap()).collect();
assert!(
sources.contains(&"a.md"),
"self-link must be in raw results"
);
assert!(sources.contains(&"b.md"));
let filtered: Vec<_> = bl_a.into_iter().filter(|e| !is_self_link(e, "a")).collect();
assert_eq!(filtered.len(), 1, "filtered result excludes the self-link");
assert_eq!(filtered[0].source, PathBuf::from("b.md"));
let bl_b = graph.backlinks("b");
assert_eq!(bl_b.len(), 1, "a.md links to b, so 1 raw backlink expected");
assert_eq!(bl_b[0].source, PathBuf::from("a.md"));
}
#[test]
fn self_links_present_with_md_extension() {
let vault = create_vault(&[
("a.md", "Self: [me](a.md)\n"),
("b.md", "Also: [a](a.md)\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl = graph.backlinks("a.md");
assert_eq!(
bl.len(),
2,
"raw results include both self and external link"
);
let filtered: Vec<_> = bl
.into_iter()
.filter(|e| !is_self_link(e, "a.md"))
.collect();
assert_eq!(filtered.len(), 1, "only b.md after filtering");
assert_eq!(filtered[0].source, PathBuf::from("b.md"));
}
#[test]
fn self_link_only_raw_has_one_entry() {
let vault = create_vault(&[("a.md", "See [[a]] for details.\n")]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let raw_a = graph.backlinks("a");
assert_eq!(raw_a.len(), 1, "raw result contains the self-link");
assert!(is_self_link(raw_a[0], "a"));
let raw_a_md = graph.backlinks("a.md");
assert_eq!(raw_a_md.len(), 1, "same via .md form");
assert!(is_self_link(raw_a_md[0], "a.md"));
assert_eq!(
graph
.backlinks("a")
.into_iter()
.filter(|e| !is_self_link(e, "a"))
.count(),
0,
"filtered backlinks must be empty for a self-link-only file"
);
}
#[test]
fn from_file_links_parity_with_build() {
let vault = create_vault(&[
("a.md", "---\ntitle: A\n---\nSee [[b]] and [c](c.md)\n"),
("b.md", "---\ntitle: B\n---\nSee [[a]]\n"),
("c.md", "---\ntitle: C\n---\nNo links here\n"),
]);
let build1 = LinkGraph::build(vault.path(), None).unwrap();
let graph1 = build1.graph;
let files = crate::discovery::discover_files(vault.path()).unwrap();
let file_links: Vec<FileLinks> = files
.iter()
.map(|full_path| {
let rel = full_path
.strip_prefix(vault.path())
.unwrap_or(full_path)
.to_path_buf();
let mut visitor = LinkGraphVisitor::new(rel);
crate::scanner::scan_file_multi(full_path, &mut [&mut visitor]).unwrap();
visitor.into_file_links()
})
.collect();
let build2 = LinkGraph::from_file_links(file_links, None);
let graph2 = build2.graph;
assert!(build2.warnings.is_empty());
for target in &["a", "b", "c.md", "c"] {
let mut bl1: Vec<&str> = graph1
.backlinks(target)
.iter()
.map(|e| e.source.to_str().unwrap())
.collect();
let mut bl2: Vec<&str> = graph2
.backlinks(target)
.iter()
.map(|e| e.source.to_str().unwrap())
.collect();
bl1.sort_unstable();
bl2.sort_unstable();
assert_eq!(
bl1, bl2,
"backlinks mismatch for target '{target}': build={bl1:?} vs from_file_links={bl2:?}"
);
}
}
#[test]
fn mixed_wiki_and_markdown_links_with_paths() {
let vault = create_vault(&[
("docs/a.md", "Content A\n"),
("notes/b.md", "Content B\n"),
(
"sub/source.md",
"Wiki: [[docs/a]] and md: [link](../notes/b.md)\n",
),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let graph = build.graph;
let bl_a = graph.backlinks("docs/a");
assert_eq!(bl_a.len(), 1, "wikilink to docs/a should be found");
let bl_b = graph.backlinks("notes/b.md");
assert_eq!(bl_b.len(), 1, "markdown link to notes/b.md should be found");
}
#[test]
fn rename_path_updates_keys_and_sources() {
let vault = create_vault(&[
("source.md", "See [[notes/old]]\n"),
("other.md", "[link](notes/old.md)\n"),
("notes/old.md", "See [[unrelated]]\n"),
("unrelated.md", "Content\n"),
]);
let build = LinkGraph::build(vault.path(), None).unwrap();
let mut graph = build.graph;
graph.rename_path("notes/old.md", "notes/new.md");
assert!(
graph.backlinks("notes/old").is_empty(),
"old stem key must be gone"
);
assert!(
graph.backlinks("notes/old.md").is_empty(),
"old full key must be gone"
);
let bl = graph.backlinks("notes/new");
assert_eq!(
bl.len(),
2,
"must find both wikilink and markdown link entries"
);
let sources: Vec<String> = bl
.iter()
.map(|b| b.source.to_string_lossy().replace('\\', "/"))
.collect();
assert!(
sources.contains(&"source.md".to_owned()),
"wikilink source; got: {sources:?}"
);
assert!(
sources.contains(&"other.md".to_owned()),
"markdown link source; got: {sources:?}"
);
let bl_unrelated = graph.backlinks("unrelated");
assert_eq!(
bl_unrelated.len(),
1,
"unrelated must still have 1 backlink"
);
let src_fwd = bl_unrelated[0].source.to_string_lossy().replace('\\', "/");
assert_eq!(
src_fwd, "notes/new.md",
"source path must be updated to new path"
);
}
}