Skip to main content

oxios_markdown/
backlinks.rs

1//! Bidirectional link tracking between markdown notes.
2//!
3//! Tracks `[text](path)` links in the knowledge base, enabling
4//! forward-link and backlink queries in O(1) time.
5
6use std::collections::{HashMap, HashSet};
7
8use serde::{Deserialize, Serialize};
9
10use crate::parser::extract_markdown_links;
11
12/// A single backlink: a link from one note to another.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Backlink {
15    /// File that contains the link.
16    pub source_path: String,
17    /// File that the link points to.
18    pub target_path: String,
19    /// Link display text.
20    pub link_text: String,
21    /// Line number where the link appears (1-indexed).
22    pub line_number: usize,
23}
24
25/// Link graph data for visualization.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct LinkGraph {
28    /// Node entries.
29    pub nodes: Vec<LinkNode>,
30    /// Edge entries.
31    pub edges: Vec<LinkEdge>,
32}
33
34/// A node in the link graph.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct LinkNode {
37    /// File path (unique ID).
38    pub id: String,
39    /// Display label.
40    pub label: String,
41    /// Group (directory name).
42    pub group: String,
43}
44
45/// An edge in the link graph.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct LinkEdge {
48    /// Source file path.
49    pub source: String,
50    /// Target file path.
51    pub target: String,
52    /// Link text.
53    pub label: String,
54}
55
56/// Bidirectional link index.
57///
58/// Maintains forward links (source → targets) and backward links
59/// (target → sources) for O(1) lookup.
60#[derive(Debug, Clone, Default)]
61pub struct BacklinkIndex {
62    /// Forward: source_path → set of target_paths.
63    forward: HashMap<String, HashSet<String>>,
64    /// Backward: target_path → set of source_paths.
65    backward: HashMap<String, HashSet<String>>,
66    /// Detailed backlink records.
67    details: HashMap<String, Vec<Backlink>>,
68}
69
70impl BacklinkIndex {
71    /// Create a new empty index.
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Index all links in a file's content.
77    ///
78    /// Replaces any previously indexed links for this file (incremental update).
79    /// Markdown links only — wikilinks are not resolved.
80    pub fn index_file(&mut self, path: &str, content: &str) {
81        self.index_file_inner(path, content, None);
82    }
83
84    /// Index both markdown links AND wikilinks. Wikilink targets are
85    /// resolved against `stem_index` (basename → paths) so a bare
86    /// `[[Rust]]` lands under the canonical path it actually points at.
87    /// Ambiguous/unresolved wikilinks are silently skipped — they're not
88    /// indexed, so a later rename of any single candidate won't rewrite
89    /// them (design doc §6).
90    pub fn index_file_with(
91        &mut self,
92        path: &str,
93        content: &str,
94        stem_index: &crate::parser::StemIndex,
95    ) {
96        self.index_file_inner(path, content, Some(stem_index));
97    }
98
99    fn index_file_inner(
100        &mut self,
101        path: &str,
102        content: &str,
103        stem_index: Option<&crate::parser::StemIndex>,
104    ) {
105        let body = strip_frontmatter(content);
106        let md_links = extract_markdown_links(body);
107        let wiki_links = match stem_index {
108            Some(_) => crate::parser::extract_wikilinks(body),
109            None => Vec::new(),
110        };
111
112        // Tear down the previous forward set for this source so re-indexing
113        // never accumulates stale entries.
114        if let Some(old_targets) = self.forward.remove(path) {
115            for target in &old_targets {
116                if let Some(sources) = self.backward.get_mut(target) {
117                    sources.remove(path);
118                }
119            }
120        }
121        self.details
122            .retain(|k, _| !k.starts_with(&format!("{path}→")));
123
124        // Canonical targets this file points at. Markdown links are already
125        // path-shaped (their captured target IS the key); wikilinks are
126        // resolved to a canonical path before keying, so both link kinds
127        // unify under the same backward[target] bucket.
128        let mut new_targets: HashSet<String> = HashSet::new();
129        for (text, target) in &md_links {
130            new_targets.insert(target.clone());
131            self.backward
132                .entry(target.clone())
133                .or_default()
134                .insert(path.to_string());
135            self.details.insert(
136                format!("{path}→{target}"),
137                vec![Backlink {
138                    source_path: path.to_string(),
139                    target_path: target.clone(),
140                    link_text: text.clone(),
141                    line_number: 0,
142                }],
143            );
144        }
145        for (target, alias) in &wiki_links {
146            let Some(canonical) =
147                crate::parser::resolve_wikilink(target, Some(path), stem_index.unwrap())
148            else {
149                continue;
150            };
151            new_targets.insert(canonical.clone());
152            self.backward
153                .entry(canonical.clone())
154                .or_default()
155                .insert(path.to_string());
156            self.details.insert(
157                format!("{path}→{canonical}"),
158                vec![Backlink {
159                    source_path: path.to_string(),
160                    target_path: canonical.clone(),
161                    link_text: alias.clone().unwrap_or_else(|| target.clone()),
162                    line_number: 0,
163                }],
164            );
165        }
166        self.forward.insert(path.to_string(), new_targets);
167    }
168
169    /// Remove a file from the index.
170    pub fn remove_file(&mut self, path: &str) {
171        if let Some(targets) = self.forward.remove(path) {
172            for target in &targets {
173                if let Some(sources) = self.backward.get_mut(target) {
174                    sources.remove(path);
175                }
176            }
177        }
178        for sources in self.backward.values_mut() {
179            sources.remove(path);
180        }
181        self.details.retain(|k, _| !k.contains(path));
182    }
183
184    /// Get all backlinks pointing to a file (files that reference this one).
185    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
186        let sources = self.backward.get(path).cloned().unwrap_or_default();
187        let mut result = Vec::new();
188        for source in &sources {
189            let key = format!("{source}→{path}");
190            if let Some(details) = self.details.get(&key) {
191                result.extend(details.clone());
192            }
193        }
194        result
195    }
196
197    /// Get the set of source files that link to `target` (the backward index
198    /// entry). Used by `note_move` to find every file whose links must be
199    /// rewritten when the target is renamed.
200    pub fn sources_for(&self, target: &str) -> HashSet<String> {
201        self.backward.get(target).cloned().unwrap_or_default()
202    }
203
204    /// Get all forward links from a file (files this one references).
205    pub fn forward_links_for(&self, path: &str) -> Vec<String> {
206        self.forward
207            .get(path)
208            .cloned()
209            .unwrap_or_default()
210            .into_iter()
211            .collect()
212    }
213
214    /// Get the number of backlinks for a file.
215    pub fn backlink_count(&self, path: &str) -> usize {
216        self.backward.get(path).map(|s| s.len()).unwrap_or(0)
217    }
218
219    /// Get the full link graph for visualization.
220    pub fn link_graph(&self) -> LinkGraph {
221        let mut node_set = HashSet::new();
222        let mut edges = Vec::new();
223
224        for (source, targets) in &self.forward {
225            node_set.insert(source.clone());
226            for target in targets {
227                node_set.insert(target.clone());
228                edges.push(LinkEdge {
229                    source: source.clone(),
230                    target: target.clone(),
231                    label: String::new(),
232                });
233            }
234        }
235
236        let nodes: Vec<LinkNode> = node_set
237            .into_iter()
238            .map(|id| {
239                let label = id
240                    .trim_end_matches(".md")
241                    .rsplit('/')
242                    .next()
243                    .unwrap_or(&id)
244                    .to_string();
245                let group = id.split('/').next().unwrap_or("").to_string();
246                LinkNode { id, label, group }
247            })
248            .collect();
249
250        LinkGraph { nodes, edges }
251    }
252
253    /// Compute connection strength between two files (shared backlink sources).
254    pub fn connection_strength(&self, path_a: &str, path_b: &str) -> usize {
255        let sources_a = self.backward.get(path_a).cloned().unwrap_or_default();
256        let sources_b = self.backward.get(path_b).cloned().unwrap_or_default();
257        sources_a.intersection(&sources_b).count()
258    }
259
260    /// Number of files in the index.
261    pub fn len(&self) -> usize {
262        self.forward.len()
263    }
264
265    /// Whether the index is empty.
266    pub fn is_empty(&self) -> bool {
267        self.forward.is_empty()
268    }
269
270    /// Clear all indexed data.
271    pub fn clear(&mut self) {
272        self.forward.clear();
273        self.backward.clear();
274        self.details.clear();
275    }
276}
277
278/// Strip YAML frontmatter from content, returning the body.
279/// If no frontmatter is found, returns the original content unchanged.
280pub fn strip_frontmatter(content: &str) -> &str {
281    let trimmed = content.trim_start();
282    if !trimmed.starts_with("---") {
283        return content;
284    }
285    // Skip the opening ---
286    let after_first = &trimmed[3..];
287    let rest = after_first.trim_start_matches(['-', '\n', '\r']);
288    if let Some(idx) = rest.find("\n---") {
289        let body_start = idx + 4;
290        rest[body_start..].trim_start()
291    } else {
292        content
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_index_and_backlinks() {
302        let mut idx = BacklinkIndex::new();
303        idx.index_file(
304            "brain/Rust.md",
305            "See [Ownership](brain/Ownership.md) and [Go](brain/Go.md)",
306        );
307
308        let bl = idx.backlinks_for("brain/Ownership.md");
309        assert_eq!(bl.len(), 1);
310        assert_eq!(bl[0].source_path, "brain/Rust.md");
311    }
312
313    #[test]
314    fn test_forward_links() {
315        let mut idx = BacklinkIndex::new();
316        idx.index_file("a.md", "[b](b.md) [c](c.md)");
317        let fwd = idx.forward_links_for("a.md");
318        assert_eq!(fwd.len(), 2);
319    }
320
321    #[test]
322    fn test_remove_file() {
323        let mut idx = BacklinkIndex::new();
324        idx.index_file("a.md", "[b](b.md)");
325        idx.remove_file("a.md");
326        assert!(idx.backlinks_for("b.md").is_empty());
327    }
328
329    #[test]
330    fn test_connection_strength() {
331        let mut idx = BacklinkIndex::new();
332        idx.index_file("x.md", "[a](a.md) [b](b.md)");
333        idx.index_file("y.md", "[a](a.md) [b](b.md)");
334        assert_eq!(idx.connection_strength("a.md", "b.md"), 2);
335    }
336
337    #[test]
338    fn test_link_graph() {
339        let mut idx = BacklinkIndex::new();
340        idx.index_file("brain/A.md", "[B](brain/B.md)");
341        let graph = idx.link_graph();
342        assert_eq!(graph.edges.len(), 1);
343        assert_eq!(graph.nodes.len(), 2);
344    }
345
346    #[test]
347    fn test_update_replaces_old_links() {
348        let mut idx = BacklinkIndex::new();
349        idx.index_file("a.md", "[old](old.md)");
350        idx.index_file("a.md", "[new](new.md)");
351        assert!(idx.backlinks_for("old.md").is_empty());
352        assert_eq!(idx.backlinks_for("new.md").len(), 1);
353    }
354}