1use crate::resolve::embeds::MAX_EMBED_DEPTH;
20use crate::resolve::{LinkType, OutgoingLink};
21use std::collections::HashMap;
22
23#[derive(Debug, Clone, Default)]
31pub struct DepGraph {
32 forward_links: HashMap<String, Vec<String>>,
33 forward_embeds: HashMap<String, Vec<String>>,
34 backlinks: HashMap<String, Vec<String>>,
35 back_embeds: HashMap<String, Vec<String>>,
36}
37
38impl DepGraph {
39 pub fn build<'a, I>(pages: I) -> Self
44 where
45 I: IntoIterator<Item = (&'a str, &'a [OutgoingLink])>,
46 {
47 let mut graph = DepGraph::default();
48 for (source_path, outgoing) in pages {
49 for link in outgoing {
50 graph
51 .forward_links
52 .entry(source_path.to_string())
53 .or_default()
54 .push(link.target_path.clone());
55 graph
56 .backlinks
57 .entry(link.target_path.clone())
58 .or_default()
59 .push(source_path.to_string());
60 if link.link_type == LinkType::Embed {
61 graph
62 .forward_embeds
63 .entry(source_path.to_string())
64 .or_default()
65 .push(link.target_path.clone());
66 graph
67 .back_embeds
68 .entry(link.target_path.clone())
69 .or_default()
70 .push(source_path.to_string());
71 }
72 }
73 }
74 graph
75 }
76
77 pub fn with_embed_pairs<'a, I>(mut self, pairs: I) -> Self
99 where
100 I: IntoIterator<Item = (&'a str, &'a str)>,
101 {
102 for (target, embedder) in pairs {
103 let forward = self.forward_embeds.entry(embedder.to_string()).or_default();
104 if !forward.iter().any(|t| t == target) {
105 forward.push(target.to_string());
106 }
107 let back = self.back_embeds.entry(target.to_string()).or_default();
108 if !back.iter().any(|s| s == embedder) {
109 back.push(embedder.to_string());
110 }
111 }
112 self
113 }
114
115 pub fn embed_closure(&self, path: &str) -> Vec<String> {
127 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
128 let mut closure: Vec<String> = Vec::new();
129 let mut frontier: Vec<String> = vec![path.to_string()];
130 for _ in 0..MAX_EMBED_DEPTH {
131 if frontier.is_empty() {
132 break;
133 }
134 let mut next: Vec<String> = Vec::new();
135 for node in &frontier {
136 for target in self.forward_embeds(node) {
137 if target != path && seen.insert(target.clone()) {
138 closure.push(target.clone());
139 next.push(target.clone());
140 }
141 }
142 }
143 frontier = next;
144 }
145 closure
146 }
147
148 pub fn forward_links(&self, path: &str) -> &[String] {
151 self.forward_links.get(path).map(Vec::as_slice).unwrap_or(&[])
152 }
153
154 pub fn forward_embeds(&self, path: &str) -> &[String] {
156 self.forward_embeds.get(path).map(Vec::as_slice).unwrap_or(&[])
157 }
158
159 pub fn backlinks(&self, path: &str) -> &[String] {
161 self.backlinks.get(path).map(Vec::as_slice).unwrap_or(&[])
162 }
163
164 pub fn back_embeds(&self, path: &str) -> &[String] {
166 self.back_embeds.get(path).map(Vec::as_slice).unwrap_or(&[])
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 fn link(target: &str, link_type: LinkType) -> OutgoingLink {
175 OutgoingLink {
176 target_path: target.to_string(),
177 display_text: target.to_string(),
178 link_type,
179 }
180 }
181
182 #[test]
183 fn empty_graph_has_no_edges() {
184 let graph = DepGraph::build(std::iter::empty());
185 assert!(graph.backlinks("a.md").is_empty());
186 assert!(graph.forward_links("a.md").is_empty());
187 }
188
189 #[test]
190 fn forward_and_backlinks_are_reciprocal() {
191 let a_links = [link("b.md", LinkType::Wikilink)];
192 let graph = DepGraph::build([("a.md", a_links.as_slice())]);
193 assert_eq!(graph.forward_links("a.md"), ["b.md"]);
194 assert_eq!(graph.backlinks("b.md"), ["a.md"]);
195 assert!(graph.backlinks("a.md").is_empty());
196 }
197
198 #[test]
199 fn embed_edges_are_a_subset_of_link_edges() {
200 let a_links = [
201 link("b.md", LinkType::Wikilink),
202 link("c.md", LinkType::Embed),
203 ];
204 let graph = DepGraph::build([("a.md", a_links.as_slice())]);
205 assert_eq!(graph.forward_links("a.md"), ["b.md", "c.md"]);
206 assert_eq!(graph.forward_embeds("a.md"), ["c.md"]);
207 assert_eq!(graph.backlinks("c.md"), ["a.md"]);
208 assert_eq!(graph.back_embeds("c.md"), ["a.md"]);
209 assert!(graph.back_embeds("b.md").is_empty());
210 }
211
212 #[test]
213 fn multiple_sources_linking_the_same_target_accumulate() {
214 let a_links = [link("shared.md", LinkType::Wikilink)];
215 let b_links = [link("shared.md", LinkType::Wikilink)];
216 let graph = DepGraph::build([
217 ("a.md", a_links.as_slice()),
218 ("b.md", b_links.as_slice()),
219 ]);
220 assert_eq!(graph.backlinks("shared.md"), ["a.md", "b.md"]);
221 }
222
223 #[test]
224 fn embed_pairs_group_by_their_immediate_embedder() {
225 let graph = DepGraph::default()
229 .with_embed_pairs([("a.md", "index.md"), ("b.md", "a.md")]);
230 assert_eq!(graph.forward_embeds("index.md"), ["a.md"]);
231 assert_eq!(graph.forward_embeds("a.md"), ["b.md"]);
232 assert_eq!(graph.back_embeds("b.md"), ["a.md"]);
233 }
234
235 #[test]
236 fn embed_closure_follows_multiple_hops() {
237 let graph = DepGraph::default()
242 .with_embed_pairs([("a.md", "index.md"), ("b.md", "a.md"), ("c.md", "b.md")]);
243 let mut closure = graph.embed_closure("index.md");
244 closure.sort();
245 assert_eq!(closure, ["a.md", "b.md", "c.md"]);
246 assert_eq!(graph.embed_closure("b.md"), ["c.md"]);
247 assert!(graph.embed_closure("c.md").is_empty());
248 }
249
250 #[test]
251 fn embed_closure_terminates_on_a_cycle() {
252 let graph = DepGraph::default().with_embed_pairs([("b.md", "a.md"), ("a.md", "b.md")]);
253 let mut closure = graph.embed_closure("a.md");
254 closure.sort();
255 assert_eq!(closure, ["b.md"]);
257 }
258
259 #[test]
260 fn embed_closure_stops_at_the_resolver_depth_limit() {
261 let names: Vec<String> = (0..MAX_EMBED_DEPTH + 5).map(|i| format!("{i}.md")).collect();
264 let pairs: Vec<(&str, &str)> = names
265 .windows(2)
266 .map(|w| (w[1].as_str(), w[0].as_str()))
267 .collect();
268 let graph = DepGraph::default().with_embed_pairs(pairs);
269 assert_eq!(graph.embed_closure("0.md").len(), MAX_EMBED_DEPTH);
270 }
271
272 #[test]
273 fn duplicate_embed_pairs_are_collapsed() {
274 let graph = DepGraph::default()
277 .with_embed_pairs([("b.md", "a.md"), ("b.md", "a.md"), ("b.md", "a.md")]);
278 assert_eq!(graph.forward_embeds("a.md"), ["b.md"]);
279 assert_eq!(graph.back_embeds("b.md"), ["a.md"]);
280 }
281
282 #[test]
283 fn embed_pairs_compose_with_link_edges() {
284 let a_links = [link("b.md", LinkType::Wikilink)];
285 let graph = DepGraph::build([("a.md", a_links.as_slice())])
286 .with_embed_pairs([("c.md", "a.md")]);
287 assert_eq!(graph.forward_links("a.md"), ["b.md"]);
288 assert_eq!(graph.forward_embeds("a.md"), ["c.md"]);
289 assert_eq!(graph.back_embeds("c.md"), ["a.md"]);
290 }
291
292 #[test]
293 fn unknown_path_returns_empty_slice() {
294 let graph = DepGraph::build(std::iter::empty());
295 assert!(graph.forward_links("nowhere.md").is_empty());
296 assert!(graph.forward_embeds("nowhere.md").is_empty());
297 assert!(graph.back_embeds("nowhere.md").is_empty());
298 }
299}