1use std::collections::{HashMap, HashSet};
7
8use serde::{Deserialize, Serialize};
9
10use crate::parser::extract_markdown_links;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Backlink {
15 pub source_path: String,
17 pub target_path: String,
19 pub link_text: String,
21 pub line_number: usize,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct LinkGraph {
28 pub nodes: Vec<LinkNode>,
30 pub edges: Vec<LinkEdge>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct LinkNode {
37 pub id: String,
39 pub label: String,
41 pub group: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct LinkEdge {
48 pub source: String,
50 pub target: String,
52 pub label: String,
54}
55
56#[derive(Debug, Clone, Default)]
61pub struct BacklinkIndex {
62 forward: HashMap<String, HashSet<String>>,
64 backward: HashMap<String, HashSet<String>>,
66 details: HashMap<String, Vec<Backlink>>,
68}
69
70impl BacklinkIndex {
71 pub fn new() -> Self {
73 Self::default()
74 }
75
76 pub fn index_file(&mut self, path: &str, content: &str) {
81 self.index_file_inner(path, content, None);
82 }
83
84 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 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 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 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 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 pub fn sources_for(&self, target: &str) -> HashSet<String> {
203 self.backward.get(target).cloned().unwrap_or_default()
204 }
205
206 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 pub fn backlink_count(&self, path: &str) -> usize {
218 self.backward.get(path).map(|s| s.len()).unwrap_or(0)
219 }
220
221 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 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 pub fn len(&self) -> usize {
264 self.forward.len()
265 }
266
267 pub fn is_empty(&self) -> bool {
269 self.forward.is_empty()
270 }
271
272 pub fn clear(&mut self) {
274 self.forward.clear();
275 self.backward.clear();
276 self.details.clear();
277 }
278}
279
280pub fn strip_frontmatter(content: &str) -> &str {
283 let trimmed = content.trim_start();
284 if !trimmed.starts_with("---") {
285 return content;
286 }
287 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}