use std::collections::{HashMap, HashSet};
use serde::{Deserialize, Serialize};
use crate::parser::extract_markdown_links;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Backlink {
pub source_path: String,
pub target_path: String,
pub link_text: String,
pub line_number: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkGraph {
pub nodes: Vec<LinkNode>,
pub edges: Vec<LinkEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkNode {
pub id: String,
pub label: String,
pub group: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LinkEdge {
pub source: String,
pub target: String,
pub label: String,
}
#[derive(Debug, Clone, Default)]
pub struct BacklinkIndex {
forward: HashMap<String, HashSet<String>>,
backward: HashMap<String, HashSet<String>>,
details: HashMap<String, Vec<Backlink>>,
}
impl BacklinkIndex {
pub fn new() -> Self {
Self::default()
}
pub fn index_file(&mut self, path: &str, content: &str) {
if let Some(old_targets) = self.forward.remove(path) {
for target in &old_targets {
if let Some(sources) = self.backward.get_mut(target) {
sources.remove(path);
}
}
self.details
.retain(|k, _| !k.starts_with(&format!("{}→", path)));
}
let links = extract_markdown_links(content);
let mut targets = HashSet::new();
for (line_num, line) in content.lines().enumerate() {
for (text, target) in extract_markdown_links(line) {
targets.insert(target.clone());
self.backward
.entry(target.clone())
.or_default()
.insert(path.to_string());
self.details.insert(
format!("{}→{}", path, target),
vec![Backlink {
source_path: path.to_string(),
target_path: target,
link_text: text,
line_number: line_num + 1,
}],
);
}
}
self.backward.values_mut().for_each(|s| {
s.remove(path);
});
self.details
.retain(|k, _| !k.starts_with(&format!("{}→", path)));
let mut new_targets = HashSet::new();
for (text, target) in &links {
new_targets.insert(target.clone());
self.backward
.entry(target.clone())
.or_default()
.insert(path.to_string());
self.details.insert(
format!("{}→{}", path, target),
vec![Backlink {
source_path: path.to_string(),
target_path: target.clone(),
link_text: text.clone(),
line_number: 0, }],
);
}
self.forward.insert(path.to_string(), new_targets);
}
pub fn remove_file(&mut self, path: &str) {
if let Some(targets) = self.forward.remove(path) {
for target in &targets {
if let Some(sources) = self.backward.get_mut(target) {
sources.remove(path);
}
}
}
for sources in self.backward.values_mut() {
sources.remove(path);
}
self.details.retain(|k, _| !k.contains(path));
}
pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
let sources = self.backward.get(path).cloned().unwrap_or_default();
let mut result = Vec::new();
for source in &sources {
let key = format!("{}→{}", source, path);
if let Some(details) = self.details.get(&key) {
result.extend(details.clone());
}
}
result
}
pub fn forward_links_for(&self, path: &str) -> Vec<String> {
self.forward
.get(path)
.cloned()
.unwrap_or_default()
.into_iter()
.collect()
}
pub fn backlink_count(&self, path: &str) -> usize {
self.backward.get(path).map(|s| s.len()).unwrap_or(0)
}
pub fn link_graph(&self) -> LinkGraph {
let mut node_set = HashSet::new();
let mut edges = Vec::new();
for (source, targets) in &self.forward {
node_set.insert(source.clone());
for target in targets {
node_set.insert(target.clone());
edges.push(LinkEdge {
source: source.clone(),
target: target.clone(),
label: String::new(),
});
}
}
let nodes: Vec<LinkNode> = node_set
.into_iter()
.map(|id| {
let label = id
.trim_end_matches(".md")
.rsplit('/')
.next()
.unwrap_or(&id)
.to_string();
let group = id.split('/').next().unwrap_or("").to_string();
LinkNode { id, label, group }
})
.collect();
LinkGraph { nodes, edges }
}
pub fn connection_strength(&self, path_a: &str, path_b: &str) -> usize {
let sources_a = self.backward.get(path_a).cloned().unwrap_or_default();
let sources_b = self.backward.get(path_b).cloned().unwrap_or_default();
sources_a.intersection(&sources_b).count()
}
pub fn len(&self) -> usize {
self.forward.len()
}
pub fn is_empty(&self) -> bool {
self.forward.is_empty()
}
pub fn clear(&mut self) {
self.forward.clear();
self.backward.clear();
self.details.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_index_and_backlinks() {
let mut idx = BacklinkIndex::new();
idx.index_file(
"brain/Rust.md",
"See [Ownership](brain/Ownership.md) and [Go](brain/Go.md)",
);
let bl = idx.backlinks_for("brain/Ownership.md");
assert_eq!(bl.len(), 1);
assert_eq!(bl[0].source_path, "brain/Rust.md");
}
#[test]
fn test_forward_links() {
let mut idx = BacklinkIndex::new();
idx.index_file("a.md", "[b](b.md) [c](c.md)");
let fwd = idx.forward_links_for("a.md");
assert_eq!(fwd.len(), 2);
}
#[test]
fn test_remove_file() {
let mut idx = BacklinkIndex::new();
idx.index_file("a.md", "[b](b.md)");
idx.remove_file("a.md");
assert!(idx.backlinks_for("b.md").is_empty());
}
#[test]
fn test_connection_strength() {
let mut idx = BacklinkIndex::new();
idx.index_file("x.md", "[a](a.md) [b](b.md)");
idx.index_file("y.md", "[a](a.md) [b](b.md)");
assert_eq!(idx.connection_strength("a.md", "b.md"), 2);
}
#[test]
fn test_link_graph() {
let mut idx = BacklinkIndex::new();
idx.index_file("brain/A.md", "[B](brain/B.md)");
let graph = idx.link_graph();
assert_eq!(graph.edges.len(), 1);
assert_eq!(graph.nodes.len(), 2);
}
#[test]
fn test_update_replaces_old_links() {
let mut idx = BacklinkIndex::new();
idx.index_file("a.md", "[old](old.md)");
idx.index_file("a.md", "[new](new.md)");
assert!(idx.backlinks_for("old.md").is_empty());
assert_eq!(idx.backlinks_for("new.md").len(), 1);
}
}