Skip to main content

turbovault_graph/
graph.rs

1//! Link graph using petgraph for vault relationship analysis
2
3use 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
10/// Node index type for graph
11type NodeIndex = petgraph::graph::NodeIndex;
12
13/// Link graph for analyzing vault relationships
14pub struct LinkGraph {
15    /// Directed graph: nodes are file paths, edges are links
16    graph: DiGraph<PathBuf, Link>,
17
18    /// Map from file name (stem, lowercased) to node indices.
19    /// Multiple files may share the same lowercased stem on case-sensitive
20    /// filesystems (e.g. `Note.md` and `NOTE.md` on ext4). We store all
21    /// candidates and resolve to the first match, mirroring Obsidian's
22    /// "first found wins" behaviour.
23    file_index: HashMap<String, Vec<NodeIndex>>,
24
25    /// Map from aliases (lowercased) to node indices.
26    /// Same multi-value semantics as `file_index`.
27    alias_index: HashMap<String, Vec<NodeIndex>>,
28
29    /// Map from full path to node index (for quick lookups)
30    path_index: HashMap<PathBuf, NodeIndex>,
31
32    /// Links that could not be resolved to a target file, grouped by source path.
33    /// Used by HealthAnalyzer for broken link detection.
34    unresolved_links: HashMap<PathBuf, Vec<Link>>,
35
36    /// Index from reversed lowercase path suffix to node indices for O(1) path-suffix resolution.
37    /// Used by `resolve_link` to avoid O(N) scans of `path_index`.
38    path_suffix_index: HashMap<Vec<String>, Vec<NodeIndex>>,
39}
40
41impl LinkGraph {
42    /// Create a new link graph
43    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    /// Total number of unresolved links across all source files.
55    pub fn unresolved_link_count(&self) -> usize {
56        self.unresolved_links.values().map(|v| v.len()).sum()
57    }
58
59    /// Add a file to the graph
60    pub fn add_file(&mut self, file: &VaultFile) -> Result<()> {
61        let path = file.path.clone();
62
63        // Create node if not exists
64        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            // Add to file_index by stem (lowercased for case-insensitive resolution)
71            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            // Build path suffix entries for folder-qualified lookups like [[Folder/Note]]
79            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        // Register aliases from frontmatter (lowercased for case-insensitive resolution).
96        // Guard against duplicates: add_file may be called multiple times for the
97        // same path (e.g. on every write_file), so only push if not already present.
98        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        // A previously broken link may become valid when its target note (or an
108        // alias for that target) is added later. Runtime writes add one file at
109        // a time, unlike full initialization which indexes every node before it
110        // builds edges, so reconcile the unresolved set after updating indices.
111        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                    // Resolved same-document links are valid but do not become
129                    // graph self-loops, matching update_links().
130                    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    /// Remove a file from the graph.
146    ///
147    /// **Important**: petgraph's `remove_node` uses swap-remove — the last node
148    /// in the graph is moved into the removed node's slot. We must update all
149    /// external index maps (`path_index`, `file_index`, `alias_index`) to reflect
150    /// the swapped node's new `NodeIndex`.
151    pub fn remove_file(&mut self, path: &PathBuf) -> Result<()> {
152        if let Some(&idx) = self.path_index.get(path) {
153            // Remove the target node from all indices
154            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            // Remove aliases pointing to this node
168            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            // Remove path_suffix_index entries pointing to this node
174            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            // Before removing, identify the node that will be swapped into `idx`.
181            // petgraph moves the last node (highest index) into the removed slot.
182            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            // Remove node and all edges
190            self.graph.remove_node(idx);
191
192            // Fix up index maps for the swapped node (formerly at last_idx, now at idx)
193            if let Some(swapped_path) = swapped_path {
194                // Update path_index
195                self.path_index.insert(swapped_path.clone(), idx);
196
197                // Update file_index: replace last_idx with idx
198                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                // Update alias_index: replace last_idx with idx
210                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                // Update path_suffix_index: replace last_idx with idx
219                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                // Update unresolved_links key if the swapped node had entries
228                // (key is by path, not by index, so no change needed — paths don't move)
229            }
230        }
231
232        Ok(())
233    }
234
235    /// Add links from a parsed file to the graph
236    pub fn update_links(&mut self, file: &VaultFile) -> Result<()> {
237        let source_path = &file.path;
238
239        // Get or create source node
240        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            // Also populate file_index and path_suffix_index for stem-based resolution
246            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        // Remove old outgoing edges and unresolved links for this source
268        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        // Add edges for each internal note link.
275        //
276        // A link becomes a graph edge iff its target is a *note* — i.e. not an
277        // attachment/media/data file (see `is_note_reference`). This one rule
278        // covers every link form uniformly:
279        // - Obsidian wikilinks/embeds/heading-refs/block-refs to notes
280        //   (`[[Note]]`, `[[Note#H]]`); `[[image.png]]`/`![[chart.svg]]` are
281        //   attachments and are skipped.
282        // - OKF cross-links (spec §5), which are standard markdown links to a
283        //   `.md` document (`[customers](/tables/customers.md)`); markdown links
284        //   to images/PDFs/external URLs are skipped.
285        // Skipped links never enter the note graph or broken-link reports.
286        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                // Skip same-document anchors like [[#Heading]]
297                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                    // Skip self-references (a note linking to itself) — they are
304                    // resolved, not broken, but must not become graph self-loops
305                    // (they would distort cycle detection and centrality).
306                    if target_idx != source_idx {
307                        self.graph.add_edge(source_idx, target_idx, link.clone());
308                    }
309                } else {
310                    // Track unresolved links for broken link detection
311                    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    /// Resolve a link target to a node index.
325    ///
326    /// Handles both Obsidian wikilink targets (`Note`, `Folder/Note`,
327    /// `Note#Heading`) and OKF cross-link targets (`/tables/orders.md`,
328    /// `./customers.md`). Resolution is case-insensitive to match Obsidian's
329    /// behaviour, and the `.md` suffix / leading `/` / `./` are normalized away
330    /// so both link styles share one resolution path.
331    fn resolve_link(&self, target: &str) -> Option<NodeIndex> {
332        // Normalize into lowercased, `.md`-stripped path components.
333        // Returns None for external URLs, pure anchors, and empty targets.
334        let parts = turbovault_core::okf::normalize_link_target(target)?;
335
336        // Single component (`Note`, `orders.md`): try file stem first.
337        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        // Alias match against the full target — aliases are arbitrary strings
345        // and may contain `/` (e.g. an alias literally `Projects/Roadmap`), so
346        // match the joined form, not just single-component targets.
347        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        // Folder-qualified or bundle-relative links: path-suffix match.
355        if let Some(candidates) = self.path_suffix_index.get(&parts) {
356            if candidates.len() == 1 {
357                return Some(candidates[0]);
358            }
359            // Multiple matches — pick the shortest path (most specific)
360            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    /// Get all backlinks to a file (files that link to this file)
372    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    /// Get all forward links from a file (files this file links to)
396    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    /// Find all orphaned notes (no incoming or outgoing links)
420    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    /// Find related notes within N hops (breadth-first search)
433    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                    // Add all neighbors
449                    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                    // Also traverse incoming edges
456                    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    /// Find strongly connected components (cycles in the graph)
472    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) // Only return actual cycles (size > 1)
476            .map(|scc| scc.iter().map(|&idx| self.graph[idx].clone()).collect())
477            .collect()
478    }
479
480    /// Get statistics about the graph
481    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    /// Get all file paths in the graph
502    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    /// Get node count
510    pub fn node_count(&self) -> usize {
511        self.graph.node_count()
512    }
513
514    /// Get edge count
515    pub fn edge_count(&self) -> usize {
516        self.graph.edge_count()
517    }
518
519    /// Get incoming links to a file (just the Link objects)
520    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    /// Get outgoing links from a file (just the Link objects)
534    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    /// Get all links in the graph, grouped by source file
548    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    /// Get all unresolved links, grouped by source file.
568    /// Each link has `is_valid == false` and represents a wikilink or embed
569    /// whose target could not be resolved to an existing vault file.
570    pub fn all_unresolved_links(&self) -> &HashMap<PathBuf, Vec<Link>> {
571        &self.unresolved_links
572    }
573
574    /// Find weakly connected components in the graph (treating edges as undirected).
575    /// Uses UnionFind for O(V + E * alpha(V)) performance.
576    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        // Group node indices by their representative
588        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
614/// True if a link target refers to a *note* (as opposed to an attachment,
615/// image, media, or data file), ignoring any `#fragment`.
616///
617/// The discriminator is the target's file extension, which is independent of
618/// link syntax (wikilink vs markdown) and of whether the vault is an OKF
619/// bundle: a target is a note unless its final path segment carries a known
620/// non-note extension. Extension-less targets (`Note`, `Folder/Note`) and
621/// dotted note names (`Release v1.2`) are notes; `image.png`, `report.pdf`,
622/// `data.csv` are not. A fragment-only target (`#heading`) is not a note
623/// reference.
624fn 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        // Has an extension with a non-empty stem → note unless it's an attachment.
632        Some((stem, ext)) if !stem.is_empty() => !is_attachment_ext(ext),
633        // No extension (or a leading-dot name) → treat as a note.
634        _ => true,
635    }
636}
637
638/// True if `ext` (any case) is a known non-note file extension — images,
639/// documents, data, web assets, media, archives, and office formats. `md`,
640/// `markdown`, and `txt` are intentionally absent (those are notes).
641fn is_attachment_ext(ext: &str) -> bool {
642    const ATTACHMENT_EXTS: &[&str] = &[
643        // images
644        "png", "jpg", "jpeg", "gif", "svg", "webp", "bmp", "ico", "avif", "tiff", //
645        // documents / data
646        "pdf", "csv", "tsv", "json", "yaml", "yml", "xml", "parquet", "sqlite", "db", //
647        // web assets
648        "html", "htm", "css", "js", "mjs", "wasm", //
649        // media
650        "mp4", "mov", "webm", "mkv", "mp3", "wav", "ogg", "m4a", "flac", //
651        // archives
652        "zip", "tar", "gz", "tgz", "7z", "rar", //
653        // office
654        "xlsx", "docx", "pptx", "key", "numbers", "pages",
655    ];
656    let lower = ext.to_ascii_lowercase();
657    ATTACHMENT_EXTS.contains(&lower.as_str())
658}
659
660/// Statistics about the graph
661#[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); // Both notes have links: note1 has incoming, note2 has outgoing
769    }
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        // note2 links to note1 (exists) and nonexistent (doesn't exist)
776        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        // Resolved link should be in the graph
783        assert_eq!(graph.edge_count(), 1);
784
785        // Unresolved link should be tracked
786        let unresolved = graph.all_unresolved_links();
787        let note2_path = PathBuf::from("note2.md");
788        assert!(unresolved.contains_key(&note2_path));
789        assert_eq!(unresolved[&note2_path].len(), 1);
790        assert_eq!(unresolved[&note2_path][0].target, "nonexistent");
791        assert!(!unresolved[&note2_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        // Link uses different case
799        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        // Should resolve despite case mismatch
806        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        // Now update note2 to link to note1 instead
823        let file2_fixed = create_test_file("note2.md", vec!["note1"]);
824        graph.update_links(&file2_fixed).unwrap();
825
826        // Unresolved links should be cleared
827        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        // On case-sensitive filesystems, Note.md and NOTE.md can coexist.
859        // Both should be in the graph and the first-added should win for
860        // resolution, but neither should be silently dropped.
861        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        // Both files should exist as nodes
872        assert_eq!(graph.node_count(), 3);
873
874        // Link should resolve (to whichever was added first)
875        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        // Remove first file — second should still be findable
890        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        // Should resolve to NOTE.md, not to linker itself (self-loop)
897        assert_eq!(graph.edge_count(), 1);
898        assert!(graph.all_unresolved_links().is_empty());
899
900        // Verify the edge target is actually NOTE.md
901        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        // Regression test for petgraph swap-remove index invalidation.
909        // When the first node is removed, petgraph moves the last node
910        // into its slot. Our index maps must be updated accordingly.
911        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(); // NodeIndex(0)
917        graph.add_file(&b).unwrap(); // NodeIndex(1)
918        graph.add_file(&c).unwrap(); // NodeIndex(2)
919        graph.update_links(&c).unwrap();
920
921        assert_eq!(graph.edge_count(), 1);
922
923        // Remove a.md — petgraph swaps c.md (last) into slot 0.
924        // All index maps for c.md must be updated.
925        graph.remove_file(&PathBuf::from("a.md")).unwrap();
926
927        assert_eq!(graph.node_count(), 2);
928
929        // Verify c.md is still reachable and its edges are correct
930        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        // Verify b.md backlinks still point to c.md
935        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        // Adding a new link to c.md should still work
940        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        // Obsidian wikilinks like [[folder/Note]] should resolve to
952        // folder/Note.md without requiring the .md extension.
953        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    // --- connected_components tests ---
966
967    #[test]
968    fn test_connected_components_weakly_connected() {
969        // A→B→C is a directed chain. Weakly connected: all 3 belong to one component.
970        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        // A→B and C→D with no link between the pairs → 2 components.
993        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    // --- path_suffix_index tests ---
1028
1029    #[test]
1030    fn test_path_suffix_index_basic() {
1031        // Two files share the stem "note" but live in different folders.
1032        // [[note]] resolves via file_index (stem) — hits one of them.
1033        // [[2024/note]] resolves via path_suffix_index to projects/2024/note.md only.
1034        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        // [[note]] stems match both → file_index has 2 entries; first-found wins.
1042        // Either way the link must resolve (edge count = 1).
1043        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        // Remove that edge so we can test suffix resolution cleanly.
1053        let linker_stem_path = PathBuf::from("linker_stem.md");
1054        graph.remove_file(&linker_stem_path).unwrap();
1055
1056        // [[2024/note]] — path suffix ["2024", "note"] should match only projects/2024/note.md.
1057        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        // a/shared.md and b/shared.md share stem "shared".
1075        // [[shared]] matches both via file_index → first-found wins.
1076        // [[a/shared]] matches only a/shared.md via path_suffix_index.
1077        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        // [[shared]] → file_index, multiple candidates, first wins → exactly 1 edge.
1085        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        // Remove linker1 to test suffix resolution in isolation.
1095        graph.remove_file(&PathBuf::from("linker1.md")).unwrap();
1096
1097        // [[a/shared]] → path_suffix_index, should match only a/shared.md.
1098        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    // --- HeadingRef / BlockRef edge creation tests ---
1112
1113    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        // [[B#heading]] — HeadingRef — should create an edge from A to B.
1150        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        // [[B#^blockid]] — BlockRef — should create an edge from A to B.
1173        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        // [[#heading]] — target is "#heading", clean_target is "" after split('#').
1192        // update_links must skip it: no self-loop and not in unresolved_links.
1193        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    // --- BFS order test ---
1210
1211    #[test]
1212    fn test_related_notes_bfs_order() {
1213        // A→B, A→C, B→D.
1214        // related_notes("A", 2) should return B and C before D (hop-1 before hop-2).
1215        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        // A links to B and C
1227        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        // B links to D
1248        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        // All three of B, C, D must be present
1259        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        // B and C (hop 1) must appear before D (hop 2)
1264        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    // --- update_links creates file_index for nodes not previously add_file()'d ---
1278
1279    #[test]
1280    fn test_update_links_creates_file_index_for_new_node() {
1281        // Call update_links() for a source file that was never add_file()'d.
1282        // The file should appear in the graph and be resolvable by stem.
1283        let mut graph = LinkGraph::new();
1284
1285        // target.md is registered via add_file
1286        let target = create_test_file("target.md", vec![]);
1287        graph.add_file(&target).unwrap();
1288
1289        // source.md is never add_file()'d; update_links should create its node
1290        // (and populate file_index so it can be resolved by others).
1291        let source = create_test_file("source.md", vec!["target"]);
1292        graph.update_links(&source).unwrap();
1293
1294        // source node must exist in the graph now
1295        assert_eq!(graph.node_count(), 2);
1296
1297        // The edge source→target must exist
1298        assert_eq!(graph.edge_count(), 1);
1299        assert!(graph.all_unresolved_links().is_empty());
1300
1301        // source.md should be resolvable by stem: a third file linking to "source"
1302        // should create an edge, not an unresolved link.
1303        let third = create_test_file("third.md", vec!["source"]);
1304        graph.add_file(&third).unwrap();
1305        graph.update_links(&third).unwrap();
1306
1307        // Now we should have 2 edges: source→target and third→source
1308        assert_eq!(graph.edge_count(), 2);
1309        assert!(graph.all_unresolved_links().is_empty());
1310    }
1311
1312    /// Build a file whose links are typed (for OKF markdown-link tests).
1313    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        // OKF cross-link `[customers](/tables/customers.md)` must become an edge.
1336        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        // `[schema](./customers.md#schema)` classifies as HeadingRef and resolves.
1354        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        // Links to images/attachments/external resources must not pollute the
1372        // note graph or broken-link reports.
1373        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(&note).unwrap();
1383        graph.update_links(&note).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        // A note linking to itself (common in OKF index/log docs) must not
1392        // produce a graph self-loop.
1393        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        // An alias containing '/' (a legal frontmatter alias) must still resolve
1408        // via a wikilink — regression guard for the resolve_link rewrite.
1409        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        // A markdown link to a non-note resource with a fragment classifies as
1442        // HeadingRef; it must NOT be treated as a note edge or a broken link.
1443        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(&note).unwrap();
1452        graph.update_links(&note).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        // `![[chart.png]]` embeds an attachment, not a note — it must not be
1461        // tracked as a broken note link.
1462        let mut graph = LinkGraph::new();
1463        let note = create_typed_file("/vault/note.md", vec![(LinkType::Embed, "chart.png")]);
1464        graph.add_file(&note).unwrap();
1465        graph.update_links(&note).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        // A note whose name contains a dot (`Release v1.2.md`) must still
1474        // resolve via a wikilink — the extension heuristic must not reject it.
1475        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        // A `.md` markdown link with no target file is a genuine broken link.
1493        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(&note).unwrap();
1500        graph.update_links(&note).unwrap();
1501
1502        assert_eq!(graph.edge_count(), 0);
1503        assert_eq!(graph.unresolved_link_count(), 1);
1504    }
1505}