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) = crate::parser::resolve_wikilink(
147                target,
148                Some(path),
149                stem_index.expect("stem index required for wiki-link resolution"),
150            ) else {
151                continue;
152            };
153            new_targets.insert(canonical.clone());
154            self.backward
155                .entry(canonical.clone())
156                .or_default()
157                .insert(path.to_string());
158            self.details.insert(
159                format!("{path}→{canonical}"),
160                vec![Backlink {
161                    source_path: path.to_string(),
162                    target_path: canonical.clone(),
163                    link_text: alias.clone().unwrap_or_else(|| target.clone()),
164                    line_number: 0,
165                }],
166            );
167        }
168        self.forward.insert(path.to_string(), new_targets);
169    }
170
171    /// Remove a file from the index.
172    pub fn remove_file(&mut self, path: &str) {
173        if let Some(targets) = self.forward.remove(path) {
174            for target in &targets {
175                if let Some(sources) = self.backward.get_mut(target) {
176                    sources.remove(path);
177                }
178            }
179        }
180        for sources in self.backward.values_mut() {
181            sources.remove(path);
182        }
183        self.details.retain(|k, _| !k.contains(path));
184    }
185
186    /// Get all backlinks pointing to a file (files that reference this one).
187    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
188        let sources = self.backward.get(path).cloned().unwrap_or_default();
189        let mut result = Vec::new();
190        for source in &sources {
191            let key = format!("{source}→{path}");
192            if let Some(details) = self.details.get(&key) {
193                result.extend(details.clone());
194            }
195        }
196        result
197    }
198
199    /// Get the set of source files that link to `target` (the backward index
200    /// entry). Used by `note_move` to find every file whose links must be
201    /// rewritten when the target is renamed.
202    pub fn sources_for(&self, target: &str) -> HashSet<String> {
203        self.backward.get(target).cloned().unwrap_or_default()
204    }
205
206    /// Get all forward links from a file (files this one references).
207    pub fn forward_links_for(&self, path: &str) -> Vec<String> {
208        self.forward
209            .get(path)
210            .cloned()
211            .unwrap_or_default()
212            .into_iter()
213            .collect()
214    }
215
216    /// Get the number of backlinks for a file.
217    pub fn backlink_count(&self, path: &str) -> usize {
218        self.backward.get(path).map(|s| s.len()).unwrap_or(0)
219    }
220
221    /// Get the full link graph for visualization.
222    pub fn link_graph(&self) -> LinkGraph {
223        let mut node_set = HashSet::new();
224        let mut edges = Vec::new();
225
226        for (source, targets) in &self.forward {
227            node_set.insert(source.clone());
228            for target in targets {
229                node_set.insert(target.clone());
230                edges.push(LinkEdge {
231                    source: source.clone(),
232                    target: target.clone(),
233                    label: String::new(),
234                });
235            }
236        }
237
238        let nodes: Vec<LinkNode> = node_set
239            .into_iter()
240            .map(|id| {
241                let label = id
242                    .trim_end_matches(".md")
243                    .rsplit('/')
244                    .next()
245                    .unwrap_or(&id)
246                    .to_string();
247                let group = id.split('/').next().unwrap_or("").to_string();
248                LinkNode { id, label, group }
249            })
250            .collect();
251
252        LinkGraph { nodes, edges }
253    }
254
255    /// Compute connection strength between two files (shared backlink sources).
256    pub fn connection_strength(&self, path_a: &str, path_b: &str) -> usize {
257        let sources_a = self.backward.get(path_a).cloned().unwrap_or_default();
258        let sources_b = self.backward.get(path_b).cloned().unwrap_or_default();
259        sources_a.intersection(&sources_b).count()
260    }
261
262    /// Number of files in the index.
263    pub fn len(&self) -> usize {
264        self.forward.len()
265    }
266
267    /// Whether the index is empty.
268    pub fn is_empty(&self) -> bool {
269        self.forward.is_empty()
270    }
271
272    /// Clear all indexed data.
273    pub fn clear(&mut self) {
274        self.forward.clear();
275        self.backward.clear();
276        self.details.clear();
277    }
278}
279
280/// Strip YAML frontmatter from content, returning the body.
281/// If no frontmatter is found, returns the original content unchanged.
282pub fn strip_frontmatter(content: &str) -> &str {
283    let trimmed = content.trim_start();
284    if !trimmed.starts_with("---") {
285        return content;
286    }
287    // Skip the opening ---
288    let after_first = &trimmed[3..];
289    let rest = after_first.trim_start_matches(['-', '\n', '\r']);
290    if let Some(idx) = rest.find("\n---") {
291        let body_start = idx + 4;
292        rest[body_start..].trim_start()
293    } else {
294        content
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_index_and_backlinks() {
304        let mut idx = BacklinkIndex::new();
305        idx.index_file(
306            "brain/Rust.md",
307            "See [Ownership](brain/Ownership.md) and [Go](brain/Go.md)",
308        );
309
310        let bl = idx.backlinks_for("brain/Ownership.md");
311        assert_eq!(bl.len(), 1);
312        assert_eq!(bl[0].source_path, "brain/Rust.md");
313    }
314
315    #[test]
316    fn test_forward_links() {
317        let mut idx = BacklinkIndex::new();
318        idx.index_file("a.md", "[b](b.md) [c](c.md)");
319        let fwd = idx.forward_links_for("a.md");
320        assert_eq!(fwd.len(), 2);
321    }
322
323    #[test]
324    fn test_remove_file() {
325        let mut idx = BacklinkIndex::new();
326        idx.index_file("a.md", "[b](b.md)");
327        idx.remove_file("a.md");
328        assert!(idx.backlinks_for("b.md").is_empty());
329    }
330
331    #[test]
332    fn test_connection_strength() {
333        let mut idx = BacklinkIndex::new();
334        idx.index_file("x.md", "[a](a.md) [b](b.md)");
335        idx.index_file("y.md", "[a](a.md) [b](b.md)");
336        assert_eq!(idx.connection_strength("a.md", "b.md"), 2);
337    }
338
339    #[test]
340    fn test_link_graph() {
341        let mut idx = BacklinkIndex::new();
342        idx.index_file("brain/A.md", "[B](brain/B.md)");
343        let graph = idx.link_graph();
344        assert_eq!(graph.edges.len(), 1);
345        assert_eq!(graph.nodes.len(), 2);
346    }
347
348    #[test]
349    fn test_update_replaces_old_links() {
350        let mut idx = BacklinkIndex::new();
351        idx.index_file("a.md", "[old](old.md)");
352        idx.index_file("a.md", "[new](new.md)");
353        assert!(idx.backlinks_for("old.md").is_empty());
354        assert_eq!(idx.backlinks_for("new.md").len(), 1);
355    }
356}