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) =
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 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 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 pub fn sources_for(&self, target: &str) -> HashSet<String> {
201 self.backward.get(target).cloned().unwrap_or_default()
202 }
203
204 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 pub fn backlink_count(&self, path: &str) -> usize {
216 self.backward.get(path).map(|s| s.len()).unwrap_or(0)
217 }
218
219 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 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 pub fn len(&self) -> usize {
262 self.forward.len()
263 }
264
265 pub fn is_empty(&self) -> bool {
267 self.forward.is_empty()
268 }
269
270 pub fn clear(&mut self) {
272 self.forward.clear();
273 self.backward.clear();
274 self.details.clear();
275 }
276}
277
278pub fn strip_frontmatter(content: &str) -> &str {
281 let trimmed = content.trim_start();
282 if !trimmed.starts_with("---") {
283 return content;
284 }
285 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}