Skip to main content

docgen_core/
graph.rs

1use std::collections::BTreeMap;
2
3use serde::Serialize;
4
5use crate::model::{Backlink, LinkEdge};
6
7/// The full directed link graph plus the inverted backlinks map.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
9pub struct LinkGraph {
10    pub edges: Vec<LinkEdge>,
11    pub backlinks: BTreeMap<String, Vec<Backlink>>,
12}
13
14/// Build a LinkGraph from per-doc resolved outbound targets.
15/// `docs`: (slug, title, description) for every doc. `outbound`: slug -> resolved
16/// target slugs. Self-links are dropped. Edges are sorted (from, to); backlink
17/// lists sorted by linking slug. The backlink card carries the linking doc's
18/// description (for the rail's `<small>` line). Deterministic.
19pub 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        // index is linked from a and b (sorted by linking slug).
107        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        // `from` slug "orphan" has no entry in `docs`, so the title-of lookup
136        // misses and the backlink title falls back to the slug itself.
137        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}