1use std::collections::BTreeMap;
2
3use serde::Serialize;
4
5use crate::model::{Backlink, LinkEdge};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
9pub struct LinkGraph {
10 pub edges: Vec<LinkEdge>,
11 pub backlinks: BTreeMap<String, Vec<Backlink>>,
12}
13
14pub fn build_link_graph(
20 docs: &[(String, String, Option<String>)],
21 outbound: &BTreeMap<String, Vec<String>>,
22) -> LinkGraph {
23 let title_of: BTreeMap<&str, &str> = docs
24 .iter()
25 .map(|(s, t, _)| (s.as_str(), t.as_str()))
26 .collect();
27 let desc_of: BTreeMap<&str, &str> = docs
28 .iter()
29 .filter_map(|(s, _, d)| d.as_deref().map(|d| (s.as_str(), d)))
30 .collect();
31
32 let mut edges: Vec<LinkEdge> = Vec::new();
33 let mut backlinks: BTreeMap<String, Vec<Backlink>> = BTreeMap::new();
34
35 for (from, targets) in outbound {
36 for to in targets {
37 if to == from {
38 continue;
39 }
40 edges.push(LinkEdge {
41 from: from.clone(),
42 to: to.clone(),
43 });
44 let title = title_of
45 .get(from.as_str())
46 .copied()
47 .unwrap_or(from.as_str());
48 let description = desc_of.get(from.as_str()).map(|d| d.to_string());
49 backlinks.entry(to.clone()).or_default().push(Backlink {
50 slug: from.clone(),
51 title: title.to_string(),
52 description,
53 });
54 }
55 }
56
57 edges.sort();
58 edges.dedup();
59 for list in backlinks.values_mut() {
60 list.sort();
61 list.dedup();
62 }
63
64 LinkGraph { edges, backlinks }
65}
66
67#[cfg(test)]
68mod tests {
69 use super::*;
70 use std::collections::BTreeMap;
71
72 #[test]
73 fn builds_edges_and_inverted_backlinks() {
74 let docs = vec![
75 ("index".to_string(), "Home".to_string(), None),
76 (
77 "a".to_string(),
78 "Page A".to_string(),
79 Some("Desc A".to_string()),
80 ),
81 ("b".to_string(), "Page B".to_string(), None),
82 ];
83 let mut outbound: BTreeMap<String, Vec<String>> = BTreeMap::new();
84 outbound.insert("a".into(), vec!["index".into(), "b".into()]);
85 outbound.insert("b".into(), vec!["index".into()]);
86
87 let g = build_link_graph(&docs, &outbound);
88
89 assert_eq!(
90 g.edges,
91 vec![
92 LinkEdge {
93 from: "a".into(),
94 to: "b".into()
95 },
96 LinkEdge {
97 from: "a".into(),
98 to: "index".into()
99 },
100 LinkEdge {
101 from: "b".into(),
102 to: "index".into()
103 },
104 ]
105 );
106 assert_eq!(
108 g.backlinks.get("index").unwrap(),
109 &vec![
110 Backlink {
111 slug: "a".into(),
112 title: "Page A".into(),
113 description: Some("Desc A".into())
114 },
115 Backlink {
116 slug: "b".into(),
117 title: "Page B".into(),
118 description: None
119 },
120 ]
121 );
122 assert_eq!(
123 g.backlinks.get("b").unwrap(),
124 &vec![Backlink {
125 slug: "a".into(),
126 title: "Page A".into(),
127 description: Some("Desc A".into())
128 }]
129 );
130 assert!(!g.backlinks.contains_key("a"));
131 }
132
133 #[test]
134 fn backlink_title_falls_back_to_slug_when_meta_missing() {
135 let docs = vec![("index".to_string(), "Home".to_string(), None)];
138 let mut outbound = BTreeMap::new();
139 outbound.insert("orphan".to_string(), vec!["index".to_string()]);
140 let g = build_link_graph(&docs, &outbound);
141 assert_eq!(
142 g.backlinks.get("index").unwrap(),
143 &vec![Backlink {
144 slug: "orphan".into(),
145 title: "orphan".into(),
146 description: None
147 }]
148 );
149 }
150
151 #[test]
152 fn self_links_are_dropped() {
153 let docs = vec![("a".to_string(), "A".to_string(), None)];
154 let mut outbound = BTreeMap::new();
155 outbound.insert("a".to_string(), vec!["a".to_string()]);
156 let g = build_link_graph(&docs, &outbound);
157 assert!(g.edges.is_empty());
158 assert!(g.backlinks.is_empty());
159 }
160}