use petgraph::prelude::*;
use petgraph::unionfind::UnionFind;
use petgraph::visit::{EdgeRef, NodeIndexable};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use turbovault_core::prelude::*;
type NodeIndex = petgraph::graph::NodeIndex;
pub struct LinkGraph {
graph: DiGraph<PathBuf, Link>,
file_index: HashMap<String, Vec<NodeIndex>>,
alias_index: HashMap<String, Vec<NodeIndex>>,
path_index: HashMap<PathBuf, NodeIndex>,
unresolved_links: HashMap<PathBuf, Vec<Link>>,
path_suffix_index: HashMap<Vec<String>, Vec<NodeIndex>>,
}
impl LinkGraph {
pub fn new() -> Self {
Self {
graph: DiGraph::new(),
file_index: HashMap::new(),
alias_index: HashMap::new(),
path_index: HashMap::new(),
unresolved_links: HashMap::new(),
path_suffix_index: HashMap::new(),
}
}
pub fn unresolved_link_count(&self) -> usize {
self.unresolved_links.values().map(|v| v.len()).sum()
}
pub fn add_file(&mut self, file: &VaultFile) -> Result<()> {
let path = file.path.clone();
let node_idx = if let Some(&idx) = self.path_index.get(&path) {
idx
} else {
let idx = self.graph.add_node(path.clone());
self.path_index.insert(path.clone(), idx);
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
self.file_index
.entry(stem.to_lowercase())
.or_default()
.push(idx);
}
let components: Vec<String> = path
.iter()
.filter_map(|c| c.to_str())
.map(|s| {
let lower = s.to_lowercase();
lower.strip_suffix(".md").unwrap_or(&lower).to_string()
})
.collect();
for i in (0..components.len()).rev() {
let suffix = components[i..].to_vec();
self.path_suffix_index.entry(suffix).or_default().push(idx);
}
idx
};
if let Some(fm) = &file.frontmatter {
for alias in fm.aliases() {
let entries = self.alias_index.entry(alias.to_lowercase()).or_default();
if !entries.contains(&node_idx) {
entries.push(node_idx);
}
}
}
self.reconcile_unresolved_links();
Ok(())
}
fn reconcile_unresolved_links(&mut self) {
let unresolved = std::mem::take(&mut self.unresolved_links);
for (source_path, links) in unresolved {
let Some(&source_idx) = self.path_index.get(&source_path) else {
self.unresolved_links.insert(source_path, links);
continue;
};
let mut remaining = Vec::new();
for mut link in links {
if let Some(target_idx) = self.resolve_link(&link.target) {
if target_idx != source_idx {
link.is_valid = true;
self.graph.add_edge(source_idx, target_idx, link);
}
} else {
remaining.push(link);
}
}
if !remaining.is_empty() {
self.unresolved_links.insert(source_path, remaining);
}
}
}
pub fn remove_file(&mut self, path: &PathBuf) -> Result<()> {
if let Some(&idx) = self.path_index.get(path) {
self.path_index.remove(path);
self.unresolved_links.remove(path);
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let key = stem.to_lowercase();
if let Some(indices) = self.file_index.get_mut(&key) {
indices.retain(|&i| i != idx);
if indices.is_empty() {
self.file_index.remove(&key);
}
}
}
for indices in self.alias_index.values_mut() {
indices.retain(|&i| i != idx);
}
self.alias_index.retain(|_, indices| !indices.is_empty());
for indices in self.path_suffix_index.values_mut() {
indices.retain(|&i| i != idx);
}
self.path_suffix_index
.retain(|_, indices| !indices.is_empty());
let last_idx = NodeIndex::new(self.graph.node_count() - 1);
let swapped_path = if last_idx != idx {
Some(self.graph[last_idx].clone())
} else {
None
};
self.graph.remove_node(idx);
if let Some(swapped_path) = swapped_path {
self.path_index.insert(swapped_path.clone(), idx);
if let Some(stem) = swapped_path.file_stem().and_then(|s| s.to_str()) {
let key = stem.to_lowercase();
if let Some(indices) = self.file_index.get_mut(&key) {
for node_idx in indices.iter_mut() {
if *node_idx == last_idx {
*node_idx = idx;
}
}
}
}
for indices in self.alias_index.values_mut() {
for node_idx in indices.iter_mut() {
if *node_idx == last_idx {
*node_idx = idx;
}
}
}
for indices in self.path_suffix_index.values_mut() {
for node_idx in indices.iter_mut() {
if *node_idx == last_idx {
*node_idx = idx;
}
}
}
}
}
Ok(())
}
pub fn update_links(&mut self, file: &VaultFile) -> Result<()> {
let source_path = &file.path;
let source_idx = if let Some(&idx) = self.path_index.get(source_path) {
idx
} else {
let idx = self.graph.add_node(source_path.clone());
self.path_index.insert(source_path.clone(), idx);
if let Some(stem) = source_path.file_stem().and_then(|s| s.to_str()) {
self.file_index
.entry(stem.to_lowercase())
.or_default()
.push(idx);
}
let components: Vec<String> = source_path
.iter()
.filter_map(|c| c.to_str())
.map(|s| {
let lower = s.to_lowercase();
lower.strip_suffix(".md").unwrap_or(&lower).to_string()
})
.collect();
for i in (0..components.len()).rev() {
let suffix = components[i..].to_vec();
self.path_suffix_index.entry(suffix).or_default().push(idx);
}
idx
};
let outgoing: Vec<_> = self.graph.edges(source_idx).map(|e| e.id()).collect();
for edge_id in outgoing {
self.graph.remove_edge(edge_id);
}
self.unresolved_links.remove(source_path);
for link in &file.links {
let is_graph_link = match link.type_ {
LinkType::WikiLink
| LinkType::Embed
| LinkType::BlockRef
| LinkType::HeadingRef
| LinkType::MarkdownLink => is_note_reference(&link.target),
LinkType::Anchor | LinkType::ExternalLink => false,
};
if is_graph_link {
let clean_target = link.target.split('#').next().unwrap_or("").trim();
if clean_target.is_empty() {
continue;
}
if let Some(target_idx) = self.resolve_link(&link.target) {
if target_idx != source_idx {
self.graph.add_edge(source_idx, target_idx, link.clone());
}
} else {
let mut broken = link.clone();
broken.is_valid = false;
self.unresolved_links
.entry(source_path.clone())
.or_default()
.push(broken);
}
}
}
Ok(())
}
fn resolve_link(&self, target: &str) -> Option<NodeIndex> {
let parts = turbovault_core::okf::normalize_link_target(target)?;
if parts.len() == 1
&& let Some(indices) = self.file_index.get(&parts[0])
&& let Some(&idx) = indices.first()
{
return Some(idx);
}
let joined = parts.join("/");
if let Some(indices) = self.alias_index.get(&joined)
&& let Some(&idx) = indices.first()
{
return Some(idx);
}
if let Some(candidates) = self.path_suffix_index.get(&parts) {
if candidates.len() == 1 {
return Some(candidates[0]);
}
if !candidates.is_empty() {
return candidates
.iter()
.min_by_key(|&&idx| self.graph[idx].components().count())
.copied();
}
}
None
}
pub fn backlinks(&self, path: &PathBuf) -> Result<Vec<(PathBuf, Vec<Link>)>> {
if let Some(&target_idx) = self.path_index.get(path) {
let backlinks: Vec<_> = self
.graph
.edges_directed(target_idx, Incoming)
.map(|edge| {
let source_idx = edge.source();
let source_path = self.graph[source_idx].clone();
(source_path, edge.weight().clone())
})
.fold(HashMap::new(), |mut acc, (path, link)| {
acc.entry(path).or_insert_with(Vec::new).push(link);
acc
})
.into_iter()
.collect();
Ok(backlinks)
} else {
Ok(vec![])
}
}
pub fn forward_links(&self, path: &PathBuf) -> Result<Vec<(PathBuf, Vec<Link>)>> {
if let Some(&source_idx) = self.path_index.get(path) {
let forward_links: Vec<_> = self
.graph
.edges(source_idx)
.map(|edge| {
let target_idx = edge.target();
let target_path = self.graph[target_idx].clone();
(target_path, edge.weight().clone())
})
.fold(HashMap::new(), |mut acc, (path, link)| {
acc.entry(path).or_insert_with(Vec::new).push(link);
acc
})
.into_iter()
.collect();
Ok(forward_links)
} else {
Ok(vec![])
}
}
pub fn orphaned_notes(&self) -> Vec<PathBuf> {
self.graph
.node_indices()
.filter(|&idx| {
let in_degree = self.graph.edges_directed(idx, Incoming).count();
let out_degree = self.graph.edges(idx).count();
in_degree == 0 && out_degree == 0
})
.map(|idx| self.graph[idx].clone())
.collect()
}
pub fn related_notes(&self, path: &PathBuf, max_hops: usize) -> Result<Vec<PathBuf>> {
if let Some(&start_idx) = self.path_index.get(path) {
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
queue.push_back((start_idx, 0));
let mut related = Vec::new();
visited.insert(start_idx);
while let Some((idx, hops)) = queue.pop_front() {
if hops > 0 {
related.push(self.graph[idx].clone());
}
if hops < max_hops {
for neighbor_idx in self.graph.neighbors(idx) {
if visited.insert(neighbor_idx) {
queue.push_back((neighbor_idx, hops + 1));
}
}
for neighbor_idx in self.graph.edges_directed(idx, Incoming).map(|e| e.source())
{
if visited.insert(neighbor_idx) {
queue.push_back((neighbor_idx, hops + 1));
}
}
}
}
Ok(related)
} else {
Ok(vec![])
}
}
pub fn cycles(&self) -> Vec<Vec<PathBuf>> {
let sccs = petgraph::algo::kosaraju_scc(&self.graph);
sccs.into_iter()
.filter(|scc| scc.len() > 1) .map(|scc| scc.iter().map(|&idx| self.graph[idx].clone()).collect())
.collect()
}
pub fn stats(&self) -> GraphStats {
let node_count = self.graph.node_count();
let edge_count = self.graph.edge_count();
let orphaned_count = self.orphaned_notes().len();
let avg_links_per_file = if node_count > 0 {
edge_count as f64 / node_count as f64
} else {
0.0
};
GraphStats {
total_files: node_count,
total_links: edge_count,
orphaned_files: orphaned_count,
average_links_per_file: avg_links_per_file,
}
}
pub fn all_files(&self) -> Vec<PathBuf> {
self.graph
.node_indices()
.map(|idx| self.graph[idx].clone())
.collect()
}
pub fn node_count(&self) -> usize {
self.graph.node_count()
}
pub fn edge_count(&self) -> usize {
self.graph.edge_count()
}
pub fn incoming_links(&self, path: &PathBuf) -> Result<Vec<Link>> {
if let Some(&target_idx) = self.path_index.get(path) {
let links: Vec<Link> = self
.graph
.edges_directed(target_idx, Incoming)
.map(|edge| edge.weight().clone())
.collect();
Ok(links)
} else {
Ok(vec![])
}
}
pub fn outgoing_links(&self, path: &PathBuf) -> Result<Vec<Link>> {
if let Some(&source_idx) = self.path_index.get(path) {
let links: Vec<Link> = self
.graph
.edges(source_idx)
.map(|edge| edge.weight().clone())
.collect();
Ok(links)
} else {
Ok(vec![])
}
}
pub fn all_links(&self) -> HashMap<PathBuf, Vec<Link>> {
let mut result = HashMap::new();
for node_idx in self.graph.node_indices() {
let source_path = self.graph[node_idx].clone();
let links: Vec<Link> = self
.graph
.edges(node_idx)
.map(|edge| edge.weight().clone())
.collect();
if !links.is_empty() {
result.insert(source_path, links);
}
}
result
}
pub fn all_unresolved_links(&self) -> &HashMap<PathBuf, Vec<Link>> {
&self.unresolved_links
}
pub fn connected_components(&self) -> Result<Vec<Vec<PathBuf>>> {
let node_bound = self.graph.node_bound();
if node_bound == 0 {
return Ok(Vec::new());
}
let mut uf = UnionFind::new(node_bound);
for edge in self.graph.edge_references() {
uf.union(edge.source().index(), edge.target().index());
}
let mut groups: HashMap<usize, Vec<NodeIndex>> = HashMap::new();
for idx in self.graph.node_indices() {
let rep = uf.find(idx.index());
groups.entry(rep).or_default().push(idx);
}
let result: Vec<Vec<PathBuf>> = groups
.into_values()
.map(|component| {
component
.iter()
.map(|&idx| self.graph[idx].clone())
.collect()
})
.collect();
Ok(result)
}
}
impl Default for LinkGraph {
fn default() -> Self {
Self::new()
}
}
fn is_note_reference(target: &str) -> bool {
let path = target.split('#').next().unwrap_or("").trim_end();
if path.is_empty() {
return false;
}
let last = path.rsplit(['/', '\\']).next().unwrap_or(path);
match last.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() => !is_attachment_ext(ext),
_ => true,
}
}
fn is_attachment_ext(ext: &str) -> bool {
const ATTACHMENT_EXTS: &[&str] = &[
"png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "ico", "avif", "tiff", "pdf", "csv", "tsv", "json", "yaml", "yml", "xml", "parquet", "sqlite", "db", "html", "htm", "css", "js", "mjs", "wasm", "mp4", "mov", "webm", "mkv", "mp3", "wav", "ogg", "m4a", "flac", "zip", "tar", "gz", "tgz", "7z", "rar", "xlsx", "docx", "pptx", "key", "numbers", "pages",
];
let lower = ext.to_ascii_lowercase();
ATTACHMENT_EXTS.contains(&lower.as_str())
}
#[derive(Debug, Clone)]
pub struct GraphStats {
pub total_files: usize,
pub total_links: usize,
pub orphaned_files: usize,
pub average_links_per_file: f64,
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_file(path: &str, links: Vec<&str>) -> VaultFile {
let parsed_links: Vec<Link> = links
.into_iter()
.enumerate()
.map(|(i, target)| Link {
type_: LinkType::WikiLink,
source_file: PathBuf::from(path),
target: target.to_string(),
display_text: None,
position: SourcePosition::new(0, 0, i * 10, 10),
resolved_target: None,
is_valid: true,
})
.collect();
let mut vault_file = VaultFile::new(
PathBuf::from(path),
String::new(),
FileMetadata {
path: PathBuf::from(path),
size: 0,
created_at: 0.0,
modified_at: 0.0,
checksum: String::new(),
is_attachment: false,
},
);
vault_file.links = parsed_links;
vault_file
}
#[test]
fn test_add_file() {
let mut graph = LinkGraph::new();
let file = create_test_file("note.md", vec![]);
assert!(graph.add_file(&file).is_ok());
assert_eq!(graph.node_count(), 1);
}
#[test]
fn test_add_multiple_files() {
let mut graph = LinkGraph::new();
let file1 = create_test_file("note1.md", vec![]);
let file2 = create_test_file("note2.md", vec![]);
graph.add_file(&file1).unwrap();
graph.add_file(&file2).unwrap();
assert_eq!(graph.node_count(), 2);
}
#[test]
fn test_update_links() {
let mut graph = LinkGraph::new();
let file1 = create_test_file("note1.md", vec![]);
let file2 = create_test_file("note2.md", vec!["note1"]);
graph.add_file(&file1).unwrap();
graph.add_file(&file2).unwrap();
graph.update_links(&file2).unwrap();
assert_eq!(graph.edge_count(), 1);
}
#[test]
fn test_orphaned_notes() {
let mut graph = LinkGraph::new();
let orphan = create_test_file("orphan.md", vec![]);
let linked1 = create_test_file("note1.md", vec![]);
let linked2 = create_test_file("note2.md", vec!["note1"]);
graph.add_file(&orphan).unwrap();
graph.add_file(&linked1).unwrap();
graph.add_file(&linked2).unwrap();
graph.update_links(&linked2).unwrap();
let orphans = graph.orphaned_notes();
assert_eq!(orphans.len(), 1);
assert_eq!(orphans[0], PathBuf::from("orphan.md"));
}
#[test]
fn test_graph_stats() {
let mut graph = LinkGraph::new();
let file1 = create_test_file("note1.md", vec![]);
let file2 = create_test_file("note2.md", vec!["note1"]);
graph.add_file(&file1).unwrap();
graph.add_file(&file2).unwrap();
graph.update_links(&file2).unwrap();
let stats = graph.stats();
assert_eq!(stats.total_files, 2);
assert_eq!(stats.total_links, 1);
assert_eq!(stats.orphaned_files, 0); }
#[test]
fn test_unresolved_links_tracked() {
let mut graph = LinkGraph::new();
let file1 = create_test_file("note1.md", vec![]);
let file2 = create_test_file("note2.md", vec!["note1", "nonexistent"]);
graph.add_file(&file1).unwrap();
graph.add_file(&file2).unwrap();
graph.update_links(&file2).unwrap();
assert_eq!(graph.edge_count(), 1);
let unresolved = graph.all_unresolved_links();
let note2_path = PathBuf::from("note2.md");
assert!(unresolved.contains_key(¬e2_path));
assert_eq!(unresolved[¬e2_path].len(), 1);
assert_eq!(unresolved[¬e2_path][0].target, "nonexistent");
assert!(!unresolved[¬e2_path][0].is_valid);
}
#[test]
fn test_case_insensitive_resolution() {
let mut graph = LinkGraph::new();
let file1 = create_test_file("My Note.md", vec![]);
let file2 = create_test_file("linker.md", vec!["my note"]);
graph.add_file(&file1).unwrap();
graph.add_file(&file2).unwrap();
graph.update_links(&file2).unwrap();
assert_eq!(graph.edge_count(), 1);
assert!(graph.all_unresolved_links().is_empty());
}
#[test]
fn test_unresolved_links_cleared_on_update() {
let mut graph = LinkGraph::new();
let file1 = create_test_file("note1.md", vec![]);
let file2_broken = create_test_file("note2.md", vec!["nonexistent"]);
graph.add_file(&file1).unwrap();
graph.add_file(&file2_broken).unwrap();
graph.update_links(&file2_broken).unwrap();
assert_eq!(graph.all_unresolved_links().len(), 1);
let file2_fixed = create_test_file("note2.md", vec!["note1"]);
graph.update_links(&file2_fixed).unwrap();
assert!(graph.all_unresolved_links().is_empty());
assert_eq!(graph.edge_count(), 1);
}
#[test]
fn test_unresolved_link_resolves_when_target_is_added_later() {
let mut graph = LinkGraph::new();
let source = create_test_file("source.md", vec!["late-target"]);
graph.add_file(&source).unwrap();
graph.update_links(&source).unwrap();
assert_eq!(graph.edge_count(), 0);
assert_eq!(graph.unresolved_link_count(), 1);
let target = create_test_file("late-target.md", vec![]);
graph.add_file(&target).unwrap();
assert_eq!(graph.edge_count(), 1);
assert_eq!(graph.unresolved_link_count(), 0);
assert_eq!(
graph.outgoing_links(&PathBuf::from("source.md")).unwrap()[0].target,
"late-target"
);
assert_eq!(
graph.backlinks(&PathBuf::from("late-target.md")).unwrap()[0].0,
PathBuf::from("source.md")
);
}
#[test]
fn test_case_insensitive_collision_both_indexed() {
let mut graph = LinkGraph::new();
let file1 = create_test_file("Note.md", vec![]);
let file2 = create_test_file("NOTE.md", vec![]);
let linker = create_test_file("linker.md", vec!["note"]);
graph.add_file(&file1).unwrap();
graph.add_file(&file2).unwrap();
graph.add_file(&linker).unwrap();
graph.update_links(&linker).unwrap();
assert_eq!(graph.node_count(), 3);
assert_eq!(graph.edge_count(), 1);
assert!(graph.all_unresolved_links().is_empty());
}
#[test]
fn test_remove_file_with_case_collision() {
let mut graph = LinkGraph::new();
let file1 = create_test_file("Note.md", vec![]);
let file2 = create_test_file("NOTE.md", vec![]);
graph.add_file(&file1).unwrap();
graph.add_file(&file2).unwrap();
assert_eq!(graph.node_count(), 2);
graph.remove_file(&PathBuf::from("Note.md")).unwrap();
let linker = create_test_file("linker.md", vec!["note"]);
graph.add_file(&linker).unwrap();
graph.update_links(&linker).unwrap();
assert_eq!(graph.edge_count(), 1);
assert!(graph.all_unresolved_links().is_empty());
let forward = graph.forward_links(&PathBuf::from("linker.md")).unwrap();
assert_eq!(forward.len(), 1);
assert_eq!(forward[0].0, PathBuf::from("NOTE.md"));
}
#[test]
fn test_remove_node_swap_fixup_three_nodes() {
let mut graph = LinkGraph::new();
let a = create_test_file("a.md", vec![]);
let b = create_test_file("b.md", vec![]);
let c = create_test_file("c.md", vec!["b"]);
graph.add_file(&a).unwrap(); graph.add_file(&b).unwrap(); graph.add_file(&c).unwrap(); graph.update_links(&c).unwrap();
assert_eq!(graph.edge_count(), 1);
graph.remove_file(&PathBuf::from("a.md")).unwrap();
assert_eq!(graph.node_count(), 2);
let forward = graph.forward_links(&PathBuf::from("c.md")).unwrap();
assert_eq!(forward.len(), 1);
assert_eq!(forward[0].0, PathBuf::from("b.md"));
let back = graph.backlinks(&PathBuf::from("b.md")).unwrap();
assert_eq!(back.len(), 1);
assert_eq!(back[0].0, PathBuf::from("c.md"));
let d = create_test_file("d.md", vec!["c"]);
graph.add_file(&d).unwrap();
graph.update_links(&d).unwrap();
let c_back = graph.backlinks(&PathBuf::from("c.md")).unwrap();
assert_eq!(c_back.len(), 1);
assert_eq!(c_back[0].0, PathBuf::from("d.md"));
}
#[test]
fn test_resolve_link_path_suffix_without_extension() {
let mut graph = LinkGraph::new();
let file = create_test_file("projects/ideas/My Note.md", vec![]);
let linker = create_test_file("index.md", vec!["ideas/My Note"]);
graph.add_file(&file).unwrap();
graph.add_file(&linker).unwrap();
graph.update_links(&linker).unwrap();
assert_eq!(graph.edge_count(), 1);
assert!(graph.all_unresolved_links().is_empty());
}
#[test]
fn test_connected_components_weakly_connected() {
let mut graph = LinkGraph::new();
let a = create_test_file("a.md", vec![]);
let b = create_test_file("b.md", vec!["a"]);
let c = create_test_file("c.md", vec!["b"]);
graph.add_file(&a).unwrap();
graph.add_file(&b).unwrap();
graph.add_file(&c).unwrap();
graph.update_links(&b).unwrap();
graph.update_links(&c).unwrap();
let components = graph.connected_components().unwrap();
assert_eq!(
components.len(),
1,
"chain A→B→C should form a single weakly-connected component"
);
assert_eq!(components[0].len(), 3);
}
#[test]
fn test_connected_components_two_islands() {
let mut graph = LinkGraph::new();
let a = create_test_file("island_a1.md", vec![]);
let b = create_test_file("island_a2.md", vec!["island_a1"]);
let c = create_test_file("island_b1.md", vec![]);
let d = create_test_file("island_b2.md", vec!["island_b1"]);
graph.add_file(&a).unwrap();
graph.add_file(&b).unwrap();
graph.add_file(&c).unwrap();
graph.add_file(&d).unwrap();
graph.update_links(&b).unwrap();
graph.update_links(&d).unwrap();
let components = graph.connected_components().unwrap();
assert_eq!(
components.len(),
2,
"two disconnected pairs should yield 2 components"
);
let sizes: Vec<usize> = {
let mut s: Vec<usize> = components.iter().map(|c| c.len()).collect();
s.sort_unstable();
s
};
assert_eq!(sizes, vec![2, 2]);
}
#[test]
fn test_connected_components_empty_graph() {
let graph = LinkGraph::new();
let components = graph.connected_components().unwrap();
assert!(components.is_empty(), "empty graph should return empty vec");
}
#[test]
fn test_path_suffix_index_basic() {
let mut graph = LinkGraph::new();
let deep = create_test_file("projects/2024/note.md", vec![]);
let daily = create_test_file("daily/note.md", vec![]);
graph.add_file(&deep).unwrap();
graph.add_file(&daily).unwrap();
let linker_stem = create_test_file("linker_stem.md", vec!["note"]);
graph.add_file(&linker_stem).unwrap();
graph.update_links(&linker_stem).unwrap();
assert_eq!(
graph.edge_count(),
1,
"[[note]] should resolve via file_index to one of the two files"
);
let linker_stem_path = PathBuf::from("linker_stem.md");
graph.remove_file(&linker_stem_path).unwrap();
let linker_suffix = create_test_file("linker_suffix.md", vec!["2024/note"]);
graph.add_file(&linker_suffix).unwrap();
graph.update_links(&linker_suffix).unwrap();
assert!(
graph.all_unresolved_links().is_empty(),
"[[2024/note]] should resolve successfully"
);
let forward = graph
.forward_links(&PathBuf::from("linker_suffix.md"))
.unwrap();
assert_eq!(forward.len(), 1);
assert_eq!(forward[0].0, PathBuf::from("projects/2024/note.md"));
}
#[test]
fn test_path_suffix_index_disambiguation() {
let mut graph = LinkGraph::new();
let a = create_test_file("a/shared.md", vec![]);
let b = create_test_file("b/shared.md", vec![]);
graph.add_file(&a).unwrap();
graph.add_file(&b).unwrap();
let linker1 = create_test_file("linker1.md", vec!["shared"]);
graph.add_file(&linker1).unwrap();
graph.update_links(&linker1).unwrap();
assert_eq!(
graph.edge_count(),
1,
"[[shared]] should resolve to first-added candidate"
);
graph.remove_file(&PathBuf::from("linker1.md")).unwrap();
let linker2 = create_test_file("linker2.md", vec!["a/shared"]);
graph.add_file(&linker2).unwrap();
graph.update_links(&linker2).unwrap();
assert!(
graph.all_unresolved_links().is_empty(),
"[[a/shared]] should resolve"
);
let forward = graph.forward_links(&PathBuf::from("linker2.md")).unwrap();
assert_eq!(forward.len(), 1);
assert_eq!(forward[0].0, PathBuf::from("a/shared.md"));
}
fn create_link_with_type(path: &str, target: &str, link_type: LinkType) -> Link {
Link {
type_: link_type,
source_file: PathBuf::from(path),
target: target.to_string(),
display_text: None,
position: SourcePosition::new(0, 0, 0, 10),
resolved_target: None,
is_valid: true,
}
}
fn create_test_file_with_typed_link(
path: &str,
target: &str,
link_type: LinkType,
) -> VaultFile {
let link = create_link_with_type(path, target, link_type);
let mut file = VaultFile::new(
PathBuf::from(path),
String::new(),
FileMetadata {
path: PathBuf::from(path),
size: 0,
created_at: 0.0,
modified_at: 0.0,
checksum: String::new(),
is_attachment: false,
},
);
file.links = vec![link];
file
}
#[test]
fn test_heading_ref_creates_edge() {
let mut graph = LinkGraph::new();
let b = create_test_file("B.md", vec![]);
graph.add_file(&b).unwrap();
let a = create_test_file_with_typed_link("A.md", "B#heading", LinkType::HeadingRef);
graph.add_file(&a).unwrap();
graph.update_links(&a).unwrap();
assert_eq!(
graph.edge_count(),
1,
"HeadingRef link should create an edge"
);
assert!(graph.all_unresolved_links().is_empty());
let forward = graph.forward_links(&PathBuf::from("A.md")).unwrap();
assert_eq!(forward.len(), 1);
assert_eq!(forward[0].0, PathBuf::from("B.md"));
}
#[test]
fn test_block_ref_creates_edge() {
let mut graph = LinkGraph::new();
let b = create_test_file("B.md", vec![]);
graph.add_file(&b).unwrap();
let a = create_test_file_with_typed_link("A.md", "B#^blockid", LinkType::BlockRef);
graph.add_file(&a).unwrap();
graph.update_links(&a).unwrap();
assert_eq!(graph.edge_count(), 1, "BlockRef link should create an edge");
assert!(graph.all_unresolved_links().is_empty());
let forward = graph.forward_links(&PathBuf::from("A.md")).unwrap();
assert_eq!(forward.len(), 1);
assert_eq!(forward[0].0, PathBuf::from("B.md"));
}
#[test]
fn test_same_document_anchor_skipped() {
let mut graph = LinkGraph::new();
let a = create_test_file_with_typed_link("A.md", "#heading", LinkType::HeadingRef);
graph.add_file(&a).unwrap();
graph.update_links(&a).unwrap();
assert_eq!(
graph.edge_count(),
0,
"same-document anchor must not create any edge"
);
assert!(
graph.all_unresolved_links().is_empty(),
"same-document anchor must not appear in unresolved_links"
);
}
#[test]
fn test_related_notes_bfs_order() {
let mut graph = LinkGraph::new();
let a = create_test_file("A.md", vec![]);
let b = create_test_file("B.md", vec![]);
let c = create_test_file("C.md", vec![]);
let d = create_test_file("D.md", vec![]);
graph.add_file(&a).unwrap();
graph.add_file(&b).unwrap();
graph.add_file(&c).unwrap();
graph.add_file(&d).unwrap();
let a_linked = {
let link_b = create_link_with_type("A.md", "B", LinkType::WikiLink);
let link_c = create_link_with_type("A.md", "C", LinkType::WikiLink);
let mut f = VaultFile::new(
PathBuf::from("A.md"),
String::new(),
FileMetadata {
path: PathBuf::from("A.md"),
size: 0,
created_at: 0.0,
modified_at: 0.0,
checksum: String::new(),
is_attachment: false,
},
);
f.links = vec![link_b, link_c];
f
};
graph.update_links(&a_linked).unwrap();
let b_linked = create_test_file_with_typed_link("B.md", "D", LinkType::WikiLink);
graph.update_links(&b_linked).unwrap();
let path_a = PathBuf::from("A.md");
let path_b = PathBuf::from("B.md");
let path_c = PathBuf::from("C.md");
let path_d = PathBuf::from("D.md");
let related = graph.related_notes(&path_a, 2).unwrap();
assert!(related.contains(&path_b), "B should be related to A");
assert!(related.contains(&path_c), "C should be related to A");
assert!(related.contains(&path_d), "D should be related to A");
let pos_b = related.iter().position(|p| p == &path_b).unwrap();
let pos_c = related.iter().position(|p| p == &path_c).unwrap();
let pos_d = related.iter().position(|p| p == &path_d).unwrap();
let hop1_max = pos_b.max(pos_c);
assert!(
hop1_max < pos_d,
"B and C (hop 1) must appear before D (hop 2) in BFS order; got pos_b={}, pos_c={}, pos_d={}",
pos_b,
pos_c,
pos_d
);
}
#[test]
fn test_update_links_creates_file_index_for_new_node() {
let mut graph = LinkGraph::new();
let target = create_test_file("target.md", vec![]);
graph.add_file(&target).unwrap();
let source = create_test_file("source.md", vec!["target"]);
graph.update_links(&source).unwrap();
assert_eq!(graph.node_count(), 2);
assert_eq!(graph.edge_count(), 1);
assert!(graph.all_unresolved_links().is_empty());
let third = create_test_file("third.md", vec!["source"]);
graph.add_file(&third).unwrap();
graph.update_links(&third).unwrap();
assert_eq!(graph.edge_count(), 2);
assert!(graph.all_unresolved_links().is_empty());
}
fn create_typed_file(path: &str, links: Vec<(LinkType, &str)>) -> VaultFile {
let parsed_links: Vec<Link> = links
.into_iter()
.enumerate()
.map(|(i, (type_, target))| Link {
type_,
source_file: PathBuf::from(path),
target: target.to_string(),
display_text: None,
position: SourcePosition::new(0, 0, i * 10, 10),
resolved_target: None,
is_valid: true,
})
.collect();
let mut vault_file = create_test_file(path, vec![]);
vault_file.links = parsed_links;
vault_file
}
#[test]
fn test_okf_bundle_relative_markdown_link_resolves() {
let mut graph = LinkGraph::new();
let customers = create_test_file("/vault/tables/customers.md", vec![]);
let orders = create_typed_file(
"/vault/tables/orders.md",
vec![(LinkType::MarkdownLink, "/tables/customers.md")],
);
graph.add_file(&customers).unwrap();
graph.add_file(&orders).unwrap();
graph.update_links(&orders).unwrap();
assert_eq!(graph.edge_count(), 1);
assert!(graph.all_unresolved_links().is_empty());
}
#[test]
fn test_okf_relative_markdown_link_with_heading_resolves() {
let mut graph = LinkGraph::new();
let customers = create_test_file("/vault/tables/customers.md", vec![]);
let orders = create_typed_file(
"/vault/tables/orders.md",
vec![(LinkType::HeadingRef, "./customers.md#schema")],
);
graph.add_file(&customers).unwrap();
graph.add_file(&orders).unwrap();
graph.update_links(&orders).unwrap();
assert_eq!(graph.edge_count(), 1);
assert!(graph.all_unresolved_links().is_empty());
}
#[test]
fn test_markdown_link_to_non_md_is_not_a_graph_edge() {
let mut graph = LinkGraph::new();
let note = create_typed_file(
"/vault/note.md",
vec![
(LinkType::MarkdownLink, "/assets/diagram.png"),
(LinkType::ExternalLink, "https://example.com"),
],
);
graph.add_file(¬e).unwrap();
graph.update_links(¬e).unwrap();
assert_eq!(graph.edge_count(), 0);
assert!(graph.all_unresolved_links().is_empty());
}
#[test]
fn test_markdown_self_link_is_not_a_self_loop() {
let mut graph = LinkGraph::new();
let orders = create_typed_file(
"/vault/tables/orders.md",
vec![(LinkType::MarkdownLink, "/tables/orders.md")],
);
graph.add_file(&orders).unwrap();
graph.update_links(&orders).unwrap();
assert_eq!(graph.edge_count(), 0);
assert!(graph.all_unresolved_links().is_empty());
}
#[test]
fn test_multi_segment_alias_resolves() {
let mut graph = LinkGraph::new();
let mut target = create_test_file("/vault/team/roadmap.md", vec![]);
let mut data = std::collections::HashMap::new();
data.insert(
"aliases".to_string(),
serde_json::Value::Array(vec![serde_json::Value::String("Projects/Roadmap".into())]),
);
target.frontmatter = Some(turbovault_core::Frontmatter {
data,
position: SourcePosition::start(),
});
let linker = create_typed_file(
"/vault/notes/plan.md",
vec![(LinkType::WikiLink, "Projects/Roadmap")],
);
graph.add_file(&target).unwrap();
graph.add_file(&linker).unwrap();
graph.update_links(&linker).unwrap();
assert_eq!(
graph.edge_count(),
1,
"slash-containing alias should resolve"
);
assert!(graph.all_unresolved_links().is_empty());
}
#[test]
fn test_attachment_heading_ref_is_not_a_broken_link() {
let mut graph = LinkGraph::new();
let note = create_typed_file(
"/vault/note.md",
vec![
(LinkType::HeadingRef, "report.pdf#page=2"),
(LinkType::HeadingRef, "assets/diagram.svg#layer1"),
],
);
graph.add_file(¬e).unwrap();
graph.update_links(¬e).unwrap();
assert_eq!(graph.edge_count(), 0);
assert_eq!(graph.unresolved_link_count(), 0);
}
#[test]
fn test_image_embed_is_not_a_broken_link() {
let mut graph = LinkGraph::new();
let note = create_typed_file("/vault/note.md", vec![(LinkType::Embed, "chart.png")]);
graph.add_file(¬e).unwrap();
graph.update_links(¬e).unwrap();
assert_eq!(graph.edge_count(), 0);
assert_eq!(graph.unresolved_link_count(), 0);
}
#[test]
fn test_dotted_note_name_wikilink_resolves() {
let mut graph = LinkGraph::new();
let target = create_test_file("/vault/Release v1.2.md", vec![]);
let linker = create_typed_file(
"/vault/notes/plan.md",
vec![(LinkType::WikiLink, "Release v1.2")],
);
graph.add_file(&target).unwrap();
graph.add_file(&linker).unwrap();
graph.update_links(&linker).unwrap();
assert_eq!(graph.edge_count(), 1, "dotted note name should resolve");
assert!(graph.all_unresolved_links().is_empty());
}
#[test]
fn test_okf_broken_cross_link_tracked() {
let mut graph = LinkGraph::new();
let note = create_typed_file(
"/vault/note.md",
vec![(LinkType::MarkdownLink, "/tables/missing.md")],
);
graph.add_file(¬e).unwrap();
graph.update_links(¬e).unwrap();
assert_eq!(graph.edge_count(), 0);
assert_eq!(graph.unresolved_link_count(), 1);
}
}