1use petgraph::prelude::*;
4use petgraph::unionfind::UnionFind;
5use petgraph::visit::{EdgeRef, NodeIndexable};
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::PathBuf;
8use turbovault_core::prelude::*;
9
10type NodeIndex = petgraph::graph::NodeIndex;
12
13pub struct LinkGraph {
15 graph: DiGraph<PathBuf, Link>,
17
18 file_index: HashMap<String, Vec<NodeIndex>>,
24
25 alias_index: HashMap<String, Vec<NodeIndex>>,
28
29 path_index: HashMap<PathBuf, NodeIndex>,
31
32 unresolved_links: HashMap<PathBuf, Vec<Link>>,
35
36 path_suffix_index: HashMap<Vec<String>, Vec<NodeIndex>>,
39}
40
41impl LinkGraph {
42 pub fn new() -> Self {
44 Self {
45 graph: DiGraph::new(),
46 file_index: HashMap::new(),
47 alias_index: HashMap::new(),
48 path_index: HashMap::new(),
49 unresolved_links: HashMap::new(),
50 path_suffix_index: HashMap::new(),
51 }
52 }
53
54 pub fn unresolved_link_count(&self) -> usize {
56 self.unresolved_links.values().map(|v| v.len()).sum()
57 }
58
59 pub fn add_file(&mut self, file: &VaultFile) -> Result<()> {
61 let path = file.path.clone();
62
63 let node_idx = if let Some(&idx) = self.path_index.get(&path) {
65 idx
66 } else {
67 let idx = self.graph.add_node(path.clone());
68 self.path_index.insert(path.clone(), idx);
69
70 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
72 self.file_index
73 .entry(stem.to_lowercase())
74 .or_default()
75 .push(idx);
76 }
77
78 let components: Vec<String> = path
80 .iter()
81 .filter_map(|c| c.to_str())
82 .map(|s| {
83 let lower = s.to_lowercase();
84 lower.strip_suffix(".md").unwrap_or(&lower).to_string()
85 })
86 .collect();
87 for i in (0..components.len()).rev() {
88 let suffix = components[i..].to_vec();
89 self.path_suffix_index.entry(suffix).or_default().push(idx);
90 }
91
92 idx
93 };
94
95 if let Some(fm) = &file.frontmatter {
99 for alias in fm.aliases() {
100 let entries = self.alias_index.entry(alias.to_lowercase()).or_default();
101 if !entries.contains(&node_idx) {
102 entries.push(node_idx);
103 }
104 }
105 }
106
107 self.reconcile_unresolved_links();
112
113 Ok(())
114 }
115
116 fn reconcile_unresolved_links(&mut self) {
117 let unresolved = std::mem::take(&mut self.unresolved_links);
118
119 for (source_path, links) in unresolved {
120 let Some(&source_idx) = self.path_index.get(&source_path) else {
121 self.unresolved_links.insert(source_path, links);
122 continue;
123 };
124 let mut remaining = Vec::new();
125
126 for mut link in links {
127 if let Some(target_idx) = self.resolve_link(&link.target) {
128 if target_idx != source_idx {
131 link.is_valid = true;
132 self.graph.add_edge(source_idx, target_idx, link);
133 }
134 } else {
135 remaining.push(link);
136 }
137 }
138
139 if !remaining.is_empty() {
140 self.unresolved_links.insert(source_path, remaining);
141 }
142 }
143 }
144
145 pub fn remove_file(&mut self, path: &PathBuf) -> Result<()> {
152 if let Some(&idx) = self.path_index.get(path) {
153 self.path_index.remove(path);
155 self.unresolved_links.remove(path);
156
157 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
158 let key = stem.to_lowercase();
159 if let Some(indices) = self.file_index.get_mut(&key) {
160 indices.retain(|&i| i != idx);
161 if indices.is_empty() {
162 self.file_index.remove(&key);
163 }
164 }
165 }
166
167 for indices in self.alias_index.values_mut() {
169 indices.retain(|&i| i != idx);
170 }
171 self.alias_index.retain(|_, indices| !indices.is_empty());
172
173 for indices in self.path_suffix_index.values_mut() {
175 indices.retain(|&i| i != idx);
176 }
177 self.path_suffix_index
178 .retain(|_, indices| !indices.is_empty());
179
180 let last_idx = NodeIndex::new(self.graph.node_count() - 1);
183 let swapped_path = if last_idx != idx {
184 Some(self.graph[last_idx].clone())
185 } else {
186 None
187 };
188
189 self.graph.remove_node(idx);
191
192 if let Some(swapped_path) = swapped_path {
194 self.path_index.insert(swapped_path.clone(), idx);
196
197 if let Some(stem) = swapped_path.file_stem().and_then(|s| s.to_str()) {
199 let key = stem.to_lowercase();
200 if let Some(indices) = self.file_index.get_mut(&key) {
201 for node_idx in indices.iter_mut() {
202 if *node_idx == last_idx {
203 *node_idx = idx;
204 }
205 }
206 }
207 }
208
209 for indices in self.alias_index.values_mut() {
211 for node_idx in indices.iter_mut() {
212 if *node_idx == last_idx {
213 *node_idx = idx;
214 }
215 }
216 }
217
218 for indices in self.path_suffix_index.values_mut() {
220 for node_idx in indices.iter_mut() {
221 if *node_idx == last_idx {
222 *node_idx = idx;
223 }
224 }
225 }
226
227 }
230 }
231
232 Ok(())
233 }
234
235 pub fn update_links(&mut self, file: &VaultFile) -> Result<()> {
237 let source_path = &file.path;
238
239 let source_idx = if let Some(&idx) = self.path_index.get(source_path) {
241 idx
242 } else {
243 let idx = self.graph.add_node(source_path.clone());
244 self.path_index.insert(source_path.clone(), idx);
245 if let Some(stem) = source_path.file_stem().and_then(|s| s.to_str()) {
247 self.file_index
248 .entry(stem.to_lowercase())
249 .or_default()
250 .push(idx);
251 }
252 let components: Vec<String> = source_path
253 .iter()
254 .filter_map(|c| c.to_str())
255 .map(|s| {
256 let lower = s.to_lowercase();
257 lower.strip_suffix(".md").unwrap_or(&lower).to_string()
258 })
259 .collect();
260 for i in (0..components.len()).rev() {
261 let suffix = components[i..].to_vec();
262 self.path_suffix_index.entry(suffix).or_default().push(idx);
263 }
264 idx
265 };
266
267 let outgoing: Vec<_> = self.graph.edges(source_idx).map(|e| e.id()).collect();
269 for edge_id in outgoing {
270 self.graph.remove_edge(edge_id);
271 }
272 self.unresolved_links.remove(source_path);
273
274 for link in &file.links {
287 let is_graph_link = match link.type_ {
288 LinkType::WikiLink
289 | LinkType::Embed
290 | LinkType::BlockRef
291 | LinkType::HeadingRef
292 | LinkType::MarkdownLink => is_note_reference(&link.target),
293 LinkType::Anchor | LinkType::ExternalLink => false,
294 };
295 if is_graph_link {
296 let clean_target = link.target.split('#').next().unwrap_or("").trim();
298 if clean_target.is_empty() {
299 continue;
300 }
301
302 if let Some(target_idx) = self.resolve_link(&link.target) {
303 if target_idx != source_idx {
307 self.graph.add_edge(source_idx, target_idx, link.clone());
308 }
309 } else {
310 let mut broken = link.clone();
312 broken.is_valid = false;
313 self.unresolved_links
314 .entry(source_path.clone())
315 .or_default()
316 .push(broken);
317 }
318 }
319 }
320
321 Ok(())
322 }
323
324 fn resolve_link(&self, target: &str) -> Option<NodeIndex> {
332 let parts = turbovault_core::okf::normalize_link_target(target)?;
335
336 if parts.len() == 1
338 && let Some(indices) = self.file_index.get(&parts[0])
339 && let Some(&idx) = indices.first()
340 {
341 return Some(idx);
342 }
343
344 let joined = parts.join("/");
348 if let Some(indices) = self.alias_index.get(&joined)
349 && let Some(&idx) = indices.first()
350 {
351 return Some(idx);
352 }
353
354 if let Some(candidates) = self.path_suffix_index.get(&parts) {
356 if candidates.len() == 1 {
357 return Some(candidates[0]);
358 }
359 if !candidates.is_empty() {
361 return candidates
362 .iter()
363 .min_by_key(|&&idx| self.graph[idx].components().count())
364 .copied();
365 }
366 }
367
368 None
369 }
370
371 pub fn backlinks(&self, path: &PathBuf) -> Result<Vec<(PathBuf, Vec<Link>)>> {
373 if let Some(&target_idx) = self.path_index.get(path) {
374 let backlinks: Vec<_> = self
375 .graph
376 .edges_directed(target_idx, Incoming)
377 .map(|edge| {
378 let source_idx = edge.source();
379 let source_path = self.graph[source_idx].clone();
380 (source_path, edge.weight().clone())
381 })
382 .fold(HashMap::new(), |mut acc, (path, link)| {
383 acc.entry(path).or_insert_with(Vec::new).push(link);
384 acc
385 })
386 .into_iter()
387 .collect();
388
389 Ok(backlinks)
390 } else {
391 Ok(vec![])
392 }
393 }
394
395 pub fn forward_links(&self, path: &PathBuf) -> Result<Vec<(PathBuf, Vec<Link>)>> {
397 if let Some(&source_idx) = self.path_index.get(path) {
398 let forward_links: Vec<_> = self
399 .graph
400 .edges(source_idx)
401 .map(|edge| {
402 let target_idx = edge.target();
403 let target_path = self.graph[target_idx].clone();
404 (target_path, edge.weight().clone())
405 })
406 .fold(HashMap::new(), |mut acc, (path, link)| {
407 acc.entry(path).or_insert_with(Vec::new).push(link);
408 acc
409 })
410 .into_iter()
411 .collect();
412
413 Ok(forward_links)
414 } else {
415 Ok(vec![])
416 }
417 }
418
419 pub fn orphaned_notes(&self) -> Vec<PathBuf> {
421 self.graph
422 .node_indices()
423 .filter(|&idx| {
424 let in_degree = self.graph.edges_directed(idx, Incoming).count();
425 let out_degree = self.graph.edges(idx).count();
426 in_degree == 0 && out_degree == 0
427 })
428 .map(|idx| self.graph[idx].clone())
429 .collect()
430 }
431
432 pub fn related_notes(&self, path: &PathBuf, max_hops: usize) -> Result<Vec<PathBuf>> {
434 if let Some(&start_idx) = self.path_index.get(path) {
435 let mut visited = HashSet::new();
436 let mut queue = VecDeque::new();
437 queue.push_back((start_idx, 0));
438 let mut related = Vec::new();
439
440 visited.insert(start_idx);
441
442 while let Some((idx, hops)) = queue.pop_front() {
443 if hops > 0 {
444 related.push(self.graph[idx].clone());
445 }
446
447 if hops < max_hops {
448 for neighbor_idx in self.graph.neighbors(idx) {
450 if visited.insert(neighbor_idx) {
451 queue.push_back((neighbor_idx, hops + 1));
452 }
453 }
454
455 for neighbor_idx in self.graph.edges_directed(idx, Incoming).map(|e| e.source())
457 {
458 if visited.insert(neighbor_idx) {
459 queue.push_back((neighbor_idx, hops + 1));
460 }
461 }
462 }
463 }
464
465 Ok(related)
466 } else {
467 Ok(vec![])
468 }
469 }
470
471 pub fn cycles(&self) -> Vec<Vec<PathBuf>> {
473 let sccs = petgraph::algo::kosaraju_scc(&self.graph);
474 sccs.into_iter()
475 .filter(|scc| scc.len() > 1) .map(|scc| scc.iter().map(|&idx| self.graph[idx].clone()).collect())
477 .collect()
478 }
479
480 pub fn stats(&self) -> GraphStats {
482 let node_count = self.graph.node_count();
483 let edge_count = self.graph.edge_count();
484
485 let orphaned_count = self.orphaned_notes().len();
486
487 let avg_links_per_file = if node_count > 0 {
488 edge_count as f64 / node_count as f64
489 } else {
490 0.0
491 };
492
493 GraphStats {
494 total_files: node_count,
495 total_links: edge_count,
496 orphaned_files: orphaned_count,
497 average_links_per_file: avg_links_per_file,
498 }
499 }
500
501 pub fn all_files(&self) -> Vec<PathBuf> {
503 self.graph
504 .node_indices()
505 .map(|idx| self.graph[idx].clone())
506 .collect()
507 }
508
509 pub fn node_count(&self) -> usize {
511 self.graph.node_count()
512 }
513
514 pub fn edge_count(&self) -> usize {
516 self.graph.edge_count()
517 }
518
519 pub fn incoming_links(&self, path: &PathBuf) -> Result<Vec<Link>> {
521 if let Some(&target_idx) = self.path_index.get(path) {
522 let links: Vec<Link> = self
523 .graph
524 .edges_directed(target_idx, Incoming)
525 .map(|edge| edge.weight().clone())
526 .collect();
527 Ok(links)
528 } else {
529 Ok(vec![])
530 }
531 }
532
533 pub fn outgoing_links(&self, path: &PathBuf) -> Result<Vec<Link>> {
535 if let Some(&source_idx) = self.path_index.get(path) {
536 let links: Vec<Link> = self
537 .graph
538 .edges(source_idx)
539 .map(|edge| edge.weight().clone())
540 .collect();
541 Ok(links)
542 } else {
543 Ok(vec![])
544 }
545 }
546
547 pub fn all_links(&self) -> HashMap<PathBuf, Vec<Link>> {
549 let mut result = HashMap::new();
550
551 for node_idx in self.graph.node_indices() {
552 let source_path = self.graph[node_idx].clone();
553 let links: Vec<Link> = self
554 .graph
555 .edges(node_idx)
556 .map(|edge| edge.weight().clone())
557 .collect();
558
559 if !links.is_empty() {
560 result.insert(source_path, links);
561 }
562 }
563
564 result
565 }
566
567 pub fn all_unresolved_links(&self) -> &HashMap<PathBuf, Vec<Link>> {
571 &self.unresolved_links
572 }
573
574 pub fn connected_components(&self) -> Result<Vec<Vec<PathBuf>>> {
577 let node_bound = self.graph.node_bound();
578 if node_bound == 0 {
579 return Ok(Vec::new());
580 }
581
582 let mut uf = UnionFind::new(node_bound);
583 for edge in self.graph.edge_references() {
584 uf.union(edge.source().index(), edge.target().index());
585 }
586
587 let mut groups: HashMap<usize, Vec<NodeIndex>> = HashMap::new();
589 for idx in self.graph.node_indices() {
590 let rep = uf.find(idx.index());
591 groups.entry(rep).or_default().push(idx);
592 }
593
594 let result: Vec<Vec<PathBuf>> = groups
595 .into_values()
596 .map(|component| {
597 component
598 .iter()
599 .map(|&idx| self.graph[idx].clone())
600 .collect()
601 })
602 .collect();
603
604 Ok(result)
605 }
606}
607
608impl Default for LinkGraph {
609 fn default() -> Self {
610 Self::new()
611 }
612}
613
614fn is_note_reference(target: &str) -> bool {
625 let path = target.split('#').next().unwrap_or("").trim_end();
626 if path.is_empty() {
627 return false;
628 }
629 let last = path.rsplit(['/', '\\']).next().unwrap_or(path);
630 match last.rsplit_once('.') {
631 Some((stem, ext)) if !stem.is_empty() => !is_attachment_ext(ext),
633 _ => true,
635 }
636}
637
638fn is_attachment_ext(ext: &str) -> bool {
642 const ATTACHMENT_EXTS: &[&str] = &[
643 "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",
655 ];
656 let lower = ext.to_ascii_lowercase();
657 ATTACHMENT_EXTS.contains(&lower.as_str())
658}
659
660#[derive(Debug, Clone)]
662pub struct GraphStats {
663 pub total_files: usize,
664 pub total_links: usize,
665 pub orphaned_files: usize,
666 pub average_links_per_file: f64,
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672
673 fn create_test_file(path: &str, links: Vec<&str>) -> VaultFile {
674 let parsed_links: Vec<Link> = links
675 .into_iter()
676 .enumerate()
677 .map(|(i, target)| Link {
678 type_: LinkType::WikiLink,
679 source_file: PathBuf::from(path),
680 target: target.to_string(),
681 display_text: None,
682 position: SourcePosition::new(0, 0, i * 10, 10),
683 resolved_target: None,
684 is_valid: true,
685 })
686 .collect();
687
688 let mut vault_file = VaultFile::new(
689 PathBuf::from(path),
690 String::new(),
691 FileMetadata {
692 path: PathBuf::from(path),
693 size: 0,
694 created_at: 0.0,
695 modified_at: 0.0,
696 checksum: String::new(),
697 is_attachment: false,
698 },
699 );
700 vault_file.links = parsed_links;
701 vault_file
702 }
703
704 #[test]
705 fn test_add_file() {
706 let mut graph = LinkGraph::new();
707 let file = create_test_file("note.md", vec![]);
708
709 assert!(graph.add_file(&file).is_ok());
710 assert_eq!(graph.node_count(), 1);
711 }
712
713 #[test]
714 fn test_add_multiple_files() {
715 let mut graph = LinkGraph::new();
716 let file1 = create_test_file("note1.md", vec![]);
717 let file2 = create_test_file("note2.md", vec![]);
718
719 graph.add_file(&file1).unwrap();
720 graph.add_file(&file2).unwrap();
721
722 assert_eq!(graph.node_count(), 2);
723 }
724
725 #[test]
726 fn test_update_links() {
727 let mut graph = LinkGraph::new();
728 let file1 = create_test_file("note1.md", vec![]);
729 let file2 = create_test_file("note2.md", vec!["note1"]);
730
731 graph.add_file(&file1).unwrap();
732 graph.add_file(&file2).unwrap();
733 graph.update_links(&file2).unwrap();
734
735 assert_eq!(graph.edge_count(), 1);
736 }
737
738 #[test]
739 fn test_orphaned_notes() {
740 let mut graph = LinkGraph::new();
741 let orphan = create_test_file("orphan.md", vec![]);
742 let linked1 = create_test_file("note1.md", vec![]);
743 let linked2 = create_test_file("note2.md", vec!["note1"]);
744
745 graph.add_file(&orphan).unwrap();
746 graph.add_file(&linked1).unwrap();
747 graph.add_file(&linked2).unwrap();
748 graph.update_links(&linked2).unwrap();
749
750 let orphans = graph.orphaned_notes();
751 assert_eq!(orphans.len(), 1);
752 assert_eq!(orphans[0], PathBuf::from("orphan.md"));
753 }
754
755 #[test]
756 fn test_graph_stats() {
757 let mut graph = LinkGraph::new();
758 let file1 = create_test_file("note1.md", vec![]);
759 let file2 = create_test_file("note2.md", vec!["note1"]);
760
761 graph.add_file(&file1).unwrap();
762 graph.add_file(&file2).unwrap();
763 graph.update_links(&file2).unwrap();
764
765 let stats = graph.stats();
766 assert_eq!(stats.total_files, 2);
767 assert_eq!(stats.total_links, 1);
768 assert_eq!(stats.orphaned_files, 0); }
770
771 #[test]
772 fn test_unresolved_links_tracked() {
773 let mut graph = LinkGraph::new();
774 let file1 = create_test_file("note1.md", vec![]);
775 let file2 = create_test_file("note2.md", vec!["note1", "nonexistent"]);
777
778 graph.add_file(&file1).unwrap();
779 graph.add_file(&file2).unwrap();
780 graph.update_links(&file2).unwrap();
781
782 assert_eq!(graph.edge_count(), 1);
784
785 let unresolved = graph.all_unresolved_links();
787 let note2_path = PathBuf::from("note2.md");
788 assert!(unresolved.contains_key(¬e2_path));
789 assert_eq!(unresolved[¬e2_path].len(), 1);
790 assert_eq!(unresolved[¬e2_path][0].target, "nonexistent");
791 assert!(!unresolved[¬e2_path][0].is_valid);
792 }
793
794 #[test]
795 fn test_case_insensitive_resolution() {
796 let mut graph = LinkGraph::new();
797 let file1 = create_test_file("My Note.md", vec![]);
798 let file2 = create_test_file("linker.md", vec!["my note"]);
800
801 graph.add_file(&file1).unwrap();
802 graph.add_file(&file2).unwrap();
803 graph.update_links(&file2).unwrap();
804
805 assert_eq!(graph.edge_count(), 1);
807 assert!(graph.all_unresolved_links().is_empty());
808 }
809
810 #[test]
811 fn test_unresolved_links_cleared_on_update() {
812 let mut graph = LinkGraph::new();
813 let file1 = create_test_file("note1.md", vec![]);
814 let file2_broken = create_test_file("note2.md", vec!["nonexistent"]);
815
816 graph.add_file(&file1).unwrap();
817 graph.add_file(&file2_broken).unwrap();
818 graph.update_links(&file2_broken).unwrap();
819
820 assert_eq!(graph.all_unresolved_links().len(), 1);
821
822 let file2_fixed = create_test_file("note2.md", vec!["note1"]);
824 graph.update_links(&file2_fixed).unwrap();
825
826 assert!(graph.all_unresolved_links().is_empty());
828 assert_eq!(graph.edge_count(), 1);
829 }
830
831 #[test]
832 fn test_unresolved_link_resolves_when_target_is_added_later() {
833 let mut graph = LinkGraph::new();
834 let source = create_test_file("source.md", vec!["late-target"]);
835
836 graph.add_file(&source).unwrap();
837 graph.update_links(&source).unwrap();
838 assert_eq!(graph.edge_count(), 0);
839 assert_eq!(graph.unresolved_link_count(), 1);
840
841 let target = create_test_file("late-target.md", vec![]);
842 graph.add_file(&target).unwrap();
843
844 assert_eq!(graph.edge_count(), 1);
845 assert_eq!(graph.unresolved_link_count(), 0);
846 assert_eq!(
847 graph.outgoing_links(&PathBuf::from("source.md")).unwrap()[0].target,
848 "late-target"
849 );
850 assert_eq!(
851 graph.backlinks(&PathBuf::from("late-target.md")).unwrap()[0].0,
852 PathBuf::from("source.md")
853 );
854 }
855
856 #[test]
857 fn test_case_insensitive_collision_both_indexed() {
858 let mut graph = LinkGraph::new();
862 let file1 = create_test_file("Note.md", vec![]);
863 let file2 = create_test_file("NOTE.md", vec![]);
864 let linker = create_test_file("linker.md", vec!["note"]);
865
866 graph.add_file(&file1).unwrap();
867 graph.add_file(&file2).unwrap();
868 graph.add_file(&linker).unwrap();
869 graph.update_links(&linker).unwrap();
870
871 assert_eq!(graph.node_count(), 3);
873
874 assert_eq!(graph.edge_count(), 1);
876 assert!(graph.all_unresolved_links().is_empty());
877 }
878
879 #[test]
880 fn test_remove_file_with_case_collision() {
881 let mut graph = LinkGraph::new();
882 let file1 = create_test_file("Note.md", vec![]);
883 let file2 = create_test_file("NOTE.md", vec![]);
884
885 graph.add_file(&file1).unwrap();
886 graph.add_file(&file2).unwrap();
887 assert_eq!(graph.node_count(), 2);
888
889 graph.remove_file(&PathBuf::from("Note.md")).unwrap();
891
892 let linker = create_test_file("linker.md", vec!["note"]);
893 graph.add_file(&linker).unwrap();
894 graph.update_links(&linker).unwrap();
895
896 assert_eq!(graph.edge_count(), 1);
898 assert!(graph.all_unresolved_links().is_empty());
899
900 let forward = graph.forward_links(&PathBuf::from("linker.md")).unwrap();
902 assert_eq!(forward.len(), 1);
903 assert_eq!(forward[0].0, PathBuf::from("NOTE.md"));
904 }
905
906 #[test]
907 fn test_remove_node_swap_fixup_three_nodes() {
908 let mut graph = LinkGraph::new();
912 let a = create_test_file("a.md", vec![]);
913 let b = create_test_file("b.md", vec![]);
914 let c = create_test_file("c.md", vec!["b"]);
915
916 graph.add_file(&a).unwrap(); graph.add_file(&b).unwrap(); graph.add_file(&c).unwrap(); graph.update_links(&c).unwrap();
920
921 assert_eq!(graph.edge_count(), 1);
922
923 graph.remove_file(&PathBuf::from("a.md")).unwrap();
926
927 assert_eq!(graph.node_count(), 2);
928
929 let forward = graph.forward_links(&PathBuf::from("c.md")).unwrap();
931 assert_eq!(forward.len(), 1);
932 assert_eq!(forward[0].0, PathBuf::from("b.md"));
933
934 let back = graph.backlinks(&PathBuf::from("b.md")).unwrap();
936 assert_eq!(back.len(), 1);
937 assert_eq!(back[0].0, PathBuf::from("c.md"));
938
939 let d = create_test_file("d.md", vec!["c"]);
941 graph.add_file(&d).unwrap();
942 graph.update_links(&d).unwrap();
943
944 let c_back = graph.backlinks(&PathBuf::from("c.md")).unwrap();
945 assert_eq!(c_back.len(), 1);
946 assert_eq!(c_back[0].0, PathBuf::from("d.md"));
947 }
948
949 #[test]
950 fn test_resolve_link_path_suffix_without_extension() {
951 let mut graph = LinkGraph::new();
954 let file = create_test_file("projects/ideas/My Note.md", vec![]);
955 let linker = create_test_file("index.md", vec!["ideas/My Note"]);
956
957 graph.add_file(&file).unwrap();
958 graph.add_file(&linker).unwrap();
959 graph.update_links(&linker).unwrap();
960
961 assert_eq!(graph.edge_count(), 1);
962 assert!(graph.all_unresolved_links().is_empty());
963 }
964
965 #[test]
968 fn test_connected_components_weakly_connected() {
969 let mut graph = LinkGraph::new();
971 let a = create_test_file("a.md", vec![]);
972 let b = create_test_file("b.md", vec!["a"]);
973 let c = create_test_file("c.md", vec!["b"]);
974
975 graph.add_file(&a).unwrap();
976 graph.add_file(&b).unwrap();
977 graph.add_file(&c).unwrap();
978 graph.update_links(&b).unwrap();
979 graph.update_links(&c).unwrap();
980
981 let components = graph.connected_components().unwrap();
982 assert_eq!(
983 components.len(),
984 1,
985 "chain A→B→C should form a single weakly-connected component"
986 );
987 assert_eq!(components[0].len(), 3);
988 }
989
990 #[test]
991 fn test_connected_components_two_islands() {
992 let mut graph = LinkGraph::new();
994 let a = create_test_file("island_a1.md", vec![]);
995 let b = create_test_file("island_a2.md", vec!["island_a1"]);
996 let c = create_test_file("island_b1.md", vec![]);
997 let d = create_test_file("island_b2.md", vec!["island_b1"]);
998
999 graph.add_file(&a).unwrap();
1000 graph.add_file(&b).unwrap();
1001 graph.add_file(&c).unwrap();
1002 graph.add_file(&d).unwrap();
1003 graph.update_links(&b).unwrap();
1004 graph.update_links(&d).unwrap();
1005
1006 let components = graph.connected_components().unwrap();
1007 assert_eq!(
1008 components.len(),
1009 2,
1010 "two disconnected pairs should yield 2 components"
1011 );
1012 let sizes: Vec<usize> = {
1013 let mut s: Vec<usize> = components.iter().map(|c| c.len()).collect();
1014 s.sort_unstable();
1015 s
1016 };
1017 assert_eq!(sizes, vec![2, 2]);
1018 }
1019
1020 #[test]
1021 fn test_connected_components_empty_graph() {
1022 let graph = LinkGraph::new();
1023 let components = graph.connected_components().unwrap();
1024 assert!(components.is_empty(), "empty graph should return empty vec");
1025 }
1026
1027 #[test]
1030 fn test_path_suffix_index_basic() {
1031 let mut graph = LinkGraph::new();
1035 let deep = create_test_file("projects/2024/note.md", vec![]);
1036 let daily = create_test_file("daily/note.md", vec![]);
1037
1038 graph.add_file(&deep).unwrap();
1039 graph.add_file(&daily).unwrap();
1040
1041 let linker_stem = create_test_file("linker_stem.md", vec!["note"]);
1044 graph.add_file(&linker_stem).unwrap();
1045 graph.update_links(&linker_stem).unwrap();
1046 assert_eq!(
1047 graph.edge_count(),
1048 1,
1049 "[[note]] should resolve via file_index to one of the two files"
1050 );
1051
1052 let linker_stem_path = PathBuf::from("linker_stem.md");
1054 graph.remove_file(&linker_stem_path).unwrap();
1055
1056 let linker_suffix = create_test_file("linker_suffix.md", vec!["2024/note"]);
1058 graph.add_file(&linker_suffix).unwrap();
1059 graph.update_links(&linker_suffix).unwrap();
1060
1061 assert!(
1062 graph.all_unresolved_links().is_empty(),
1063 "[[2024/note]] should resolve successfully"
1064 );
1065 let forward = graph
1066 .forward_links(&PathBuf::from("linker_suffix.md"))
1067 .unwrap();
1068 assert_eq!(forward.len(), 1);
1069 assert_eq!(forward[0].0, PathBuf::from("projects/2024/note.md"));
1070 }
1071
1072 #[test]
1073 fn test_path_suffix_index_disambiguation() {
1074 let mut graph = LinkGraph::new();
1078 let a = create_test_file("a/shared.md", vec![]);
1079 let b = create_test_file("b/shared.md", vec![]);
1080
1081 graph.add_file(&a).unwrap();
1082 graph.add_file(&b).unwrap();
1083
1084 let linker1 = create_test_file("linker1.md", vec!["shared"]);
1086 graph.add_file(&linker1).unwrap();
1087 graph.update_links(&linker1).unwrap();
1088 assert_eq!(
1089 graph.edge_count(),
1090 1,
1091 "[[shared]] should resolve to first-added candidate"
1092 );
1093
1094 graph.remove_file(&PathBuf::from("linker1.md")).unwrap();
1096
1097 let linker2 = create_test_file("linker2.md", vec!["a/shared"]);
1099 graph.add_file(&linker2).unwrap();
1100 graph.update_links(&linker2).unwrap();
1101
1102 assert!(
1103 graph.all_unresolved_links().is_empty(),
1104 "[[a/shared]] should resolve"
1105 );
1106 let forward = graph.forward_links(&PathBuf::from("linker2.md")).unwrap();
1107 assert_eq!(forward.len(), 1);
1108 assert_eq!(forward[0].0, PathBuf::from("a/shared.md"));
1109 }
1110
1111 fn create_link_with_type(path: &str, target: &str, link_type: LinkType) -> Link {
1114 Link {
1115 type_: link_type,
1116 source_file: PathBuf::from(path),
1117 target: target.to_string(),
1118 display_text: None,
1119 position: SourcePosition::new(0, 0, 0, 10),
1120 resolved_target: None,
1121 is_valid: true,
1122 }
1123 }
1124
1125 fn create_test_file_with_typed_link(
1126 path: &str,
1127 target: &str,
1128 link_type: LinkType,
1129 ) -> VaultFile {
1130 let link = create_link_with_type(path, target, link_type);
1131 let mut file = VaultFile::new(
1132 PathBuf::from(path),
1133 String::new(),
1134 FileMetadata {
1135 path: PathBuf::from(path),
1136 size: 0,
1137 created_at: 0.0,
1138 modified_at: 0.0,
1139 checksum: String::new(),
1140 is_attachment: false,
1141 },
1142 );
1143 file.links = vec![link];
1144 file
1145 }
1146
1147 #[test]
1148 fn test_heading_ref_creates_edge() {
1149 let mut graph = LinkGraph::new();
1151 let b = create_test_file("B.md", vec![]);
1152 graph.add_file(&b).unwrap();
1153
1154 let a = create_test_file_with_typed_link("A.md", "B#heading", LinkType::HeadingRef);
1155 graph.add_file(&a).unwrap();
1156 graph.update_links(&a).unwrap();
1157
1158 assert_eq!(
1159 graph.edge_count(),
1160 1,
1161 "HeadingRef link should create an edge"
1162 );
1163 assert!(graph.all_unresolved_links().is_empty());
1164
1165 let forward = graph.forward_links(&PathBuf::from("A.md")).unwrap();
1166 assert_eq!(forward.len(), 1);
1167 assert_eq!(forward[0].0, PathBuf::from("B.md"));
1168 }
1169
1170 #[test]
1171 fn test_block_ref_creates_edge() {
1172 let mut graph = LinkGraph::new();
1174 let b = create_test_file("B.md", vec![]);
1175 graph.add_file(&b).unwrap();
1176
1177 let a = create_test_file_with_typed_link("A.md", "B#^blockid", LinkType::BlockRef);
1178 graph.add_file(&a).unwrap();
1179 graph.update_links(&a).unwrap();
1180
1181 assert_eq!(graph.edge_count(), 1, "BlockRef link should create an edge");
1182 assert!(graph.all_unresolved_links().is_empty());
1183
1184 let forward = graph.forward_links(&PathBuf::from("A.md")).unwrap();
1185 assert_eq!(forward.len(), 1);
1186 assert_eq!(forward[0].0, PathBuf::from("B.md"));
1187 }
1188
1189 #[test]
1190 fn test_same_document_anchor_skipped() {
1191 let mut graph = LinkGraph::new();
1194 let a = create_test_file_with_typed_link("A.md", "#heading", LinkType::HeadingRef);
1195 graph.add_file(&a).unwrap();
1196 graph.update_links(&a).unwrap();
1197
1198 assert_eq!(
1199 graph.edge_count(),
1200 0,
1201 "same-document anchor must not create any edge"
1202 );
1203 assert!(
1204 graph.all_unresolved_links().is_empty(),
1205 "same-document anchor must not appear in unresolved_links"
1206 );
1207 }
1208
1209 #[test]
1212 fn test_related_notes_bfs_order() {
1213 let mut graph = LinkGraph::new();
1216 let a = create_test_file("A.md", vec![]);
1217 let b = create_test_file("B.md", vec![]);
1218 let c = create_test_file("C.md", vec![]);
1219 let d = create_test_file("D.md", vec![]);
1220
1221 graph.add_file(&a).unwrap();
1222 graph.add_file(&b).unwrap();
1223 graph.add_file(&c).unwrap();
1224 graph.add_file(&d).unwrap();
1225
1226 let a_linked = {
1228 let link_b = create_link_with_type("A.md", "B", LinkType::WikiLink);
1229 let link_c = create_link_with_type("A.md", "C", LinkType::WikiLink);
1230 let mut f = VaultFile::new(
1231 PathBuf::from("A.md"),
1232 String::new(),
1233 FileMetadata {
1234 path: PathBuf::from("A.md"),
1235 size: 0,
1236 created_at: 0.0,
1237 modified_at: 0.0,
1238 checksum: String::new(),
1239 is_attachment: false,
1240 },
1241 );
1242 f.links = vec![link_b, link_c];
1243 f
1244 };
1245 graph.update_links(&a_linked).unwrap();
1246
1247 let b_linked = create_test_file_with_typed_link("B.md", "D", LinkType::WikiLink);
1249 graph.update_links(&b_linked).unwrap();
1250
1251 let path_a = PathBuf::from("A.md");
1252 let path_b = PathBuf::from("B.md");
1253 let path_c = PathBuf::from("C.md");
1254 let path_d = PathBuf::from("D.md");
1255
1256 let related = graph.related_notes(&path_a, 2).unwrap();
1257
1258 assert!(related.contains(&path_b), "B should be related to A");
1260 assert!(related.contains(&path_c), "C should be related to A");
1261 assert!(related.contains(&path_d), "D should be related to A");
1262
1263 let pos_b = related.iter().position(|p| p == &path_b).unwrap();
1265 let pos_c = related.iter().position(|p| p == &path_c).unwrap();
1266 let pos_d = related.iter().position(|p| p == &path_d).unwrap();
1267 let hop1_max = pos_b.max(pos_c);
1268 assert!(
1269 hop1_max < pos_d,
1270 "B and C (hop 1) must appear before D (hop 2) in BFS order; got pos_b={}, pos_c={}, pos_d={}",
1271 pos_b,
1272 pos_c,
1273 pos_d
1274 );
1275 }
1276
1277 #[test]
1280 fn test_update_links_creates_file_index_for_new_node() {
1281 let mut graph = LinkGraph::new();
1284
1285 let target = create_test_file("target.md", vec![]);
1287 graph.add_file(&target).unwrap();
1288
1289 let source = create_test_file("source.md", vec!["target"]);
1292 graph.update_links(&source).unwrap();
1293
1294 assert_eq!(graph.node_count(), 2);
1296
1297 assert_eq!(graph.edge_count(), 1);
1299 assert!(graph.all_unresolved_links().is_empty());
1300
1301 let third = create_test_file("third.md", vec!["source"]);
1304 graph.add_file(&third).unwrap();
1305 graph.update_links(&third).unwrap();
1306
1307 assert_eq!(graph.edge_count(), 2);
1309 assert!(graph.all_unresolved_links().is_empty());
1310 }
1311
1312 fn create_typed_file(path: &str, links: Vec<(LinkType, &str)>) -> VaultFile {
1314 let parsed_links: Vec<Link> = links
1315 .into_iter()
1316 .enumerate()
1317 .map(|(i, (type_, target))| Link {
1318 type_,
1319 source_file: PathBuf::from(path),
1320 target: target.to_string(),
1321 display_text: None,
1322 position: SourcePosition::new(0, 0, i * 10, 10),
1323 resolved_target: None,
1324 is_valid: true,
1325 })
1326 .collect();
1327
1328 let mut vault_file = create_test_file(path, vec![]);
1329 vault_file.links = parsed_links;
1330 vault_file
1331 }
1332
1333 #[test]
1334 fn test_okf_bundle_relative_markdown_link_resolves() {
1335 let mut graph = LinkGraph::new();
1337 let customers = create_test_file("/vault/tables/customers.md", vec![]);
1338 let orders = create_typed_file(
1339 "/vault/tables/orders.md",
1340 vec![(LinkType::MarkdownLink, "/tables/customers.md")],
1341 );
1342
1343 graph.add_file(&customers).unwrap();
1344 graph.add_file(&orders).unwrap();
1345 graph.update_links(&orders).unwrap();
1346
1347 assert_eq!(graph.edge_count(), 1);
1348 assert!(graph.all_unresolved_links().is_empty());
1349 }
1350
1351 #[test]
1352 fn test_okf_relative_markdown_link_with_heading_resolves() {
1353 let mut graph = LinkGraph::new();
1355 let customers = create_test_file("/vault/tables/customers.md", vec![]);
1356 let orders = create_typed_file(
1357 "/vault/tables/orders.md",
1358 vec![(LinkType::HeadingRef, "./customers.md#schema")],
1359 );
1360
1361 graph.add_file(&customers).unwrap();
1362 graph.add_file(&orders).unwrap();
1363 graph.update_links(&orders).unwrap();
1364
1365 assert_eq!(graph.edge_count(), 1);
1366 assert!(graph.all_unresolved_links().is_empty());
1367 }
1368
1369 #[test]
1370 fn test_markdown_link_to_non_md_is_not_a_graph_edge() {
1371 let mut graph = LinkGraph::new();
1374 let note = create_typed_file(
1375 "/vault/note.md",
1376 vec![
1377 (LinkType::MarkdownLink, "/assets/diagram.png"),
1378 (LinkType::ExternalLink, "https://example.com"),
1379 ],
1380 );
1381
1382 graph.add_file(¬e).unwrap();
1383 graph.update_links(¬e).unwrap();
1384
1385 assert_eq!(graph.edge_count(), 0);
1386 assert!(graph.all_unresolved_links().is_empty());
1387 }
1388
1389 #[test]
1390 fn test_markdown_self_link_is_not_a_self_loop() {
1391 let mut graph = LinkGraph::new();
1394 let orders = create_typed_file(
1395 "/vault/tables/orders.md",
1396 vec![(LinkType::MarkdownLink, "/tables/orders.md")],
1397 );
1398 graph.add_file(&orders).unwrap();
1399 graph.update_links(&orders).unwrap();
1400
1401 assert_eq!(graph.edge_count(), 0);
1402 assert!(graph.all_unresolved_links().is_empty());
1403 }
1404
1405 #[test]
1406 fn test_multi_segment_alias_resolves() {
1407 let mut graph = LinkGraph::new();
1410
1411 let mut target = create_test_file("/vault/team/roadmap.md", vec![]);
1412 let mut data = std::collections::HashMap::new();
1413 data.insert(
1414 "aliases".to_string(),
1415 serde_json::Value::Array(vec![serde_json::Value::String("Projects/Roadmap".into())]),
1416 );
1417 target.frontmatter = Some(turbovault_core::Frontmatter {
1418 data,
1419 position: SourcePosition::start(),
1420 });
1421
1422 let linker = create_typed_file(
1423 "/vault/notes/plan.md",
1424 vec![(LinkType::WikiLink, "Projects/Roadmap")],
1425 );
1426
1427 graph.add_file(&target).unwrap();
1428 graph.add_file(&linker).unwrap();
1429 graph.update_links(&linker).unwrap();
1430
1431 assert_eq!(
1432 graph.edge_count(),
1433 1,
1434 "slash-containing alias should resolve"
1435 );
1436 assert!(graph.all_unresolved_links().is_empty());
1437 }
1438
1439 #[test]
1440 fn test_attachment_heading_ref_is_not_a_broken_link() {
1441 let mut graph = LinkGraph::new();
1444 let note = create_typed_file(
1445 "/vault/note.md",
1446 vec![
1447 (LinkType::HeadingRef, "report.pdf#page=2"),
1448 (LinkType::HeadingRef, "assets/diagram.svg#layer1"),
1449 ],
1450 );
1451 graph.add_file(¬e).unwrap();
1452 graph.update_links(¬e).unwrap();
1453
1454 assert_eq!(graph.edge_count(), 0);
1455 assert_eq!(graph.unresolved_link_count(), 0);
1456 }
1457
1458 #[test]
1459 fn test_image_embed_is_not_a_broken_link() {
1460 let mut graph = LinkGraph::new();
1463 let note = create_typed_file("/vault/note.md", vec![(LinkType::Embed, "chart.png")]);
1464 graph.add_file(¬e).unwrap();
1465 graph.update_links(¬e).unwrap();
1466
1467 assert_eq!(graph.edge_count(), 0);
1468 assert_eq!(graph.unresolved_link_count(), 0);
1469 }
1470
1471 #[test]
1472 fn test_dotted_note_name_wikilink_resolves() {
1473 let mut graph = LinkGraph::new();
1476 let target = create_test_file("/vault/Release v1.2.md", vec![]);
1477 let linker = create_typed_file(
1478 "/vault/notes/plan.md",
1479 vec![(LinkType::WikiLink, "Release v1.2")],
1480 );
1481
1482 graph.add_file(&target).unwrap();
1483 graph.add_file(&linker).unwrap();
1484 graph.update_links(&linker).unwrap();
1485
1486 assert_eq!(graph.edge_count(), 1, "dotted note name should resolve");
1487 assert!(graph.all_unresolved_links().is_empty());
1488 }
1489
1490 #[test]
1491 fn test_okf_broken_cross_link_tracked() {
1492 let mut graph = LinkGraph::new();
1494 let note = create_typed_file(
1495 "/vault/note.md",
1496 vec![(LinkType::MarkdownLink, "/tables/missing.md")],
1497 );
1498
1499 graph.add_file(¬e).unwrap();
1500 graph.update_links(¬e).unwrap();
1501
1502 assert_eq!(graph.edge_count(), 0);
1503 assert_eq!(graph.unresolved_link_count(), 1);
1504 }
1505}