Skip to main content

moss_core/
dep_graph.rs

1//! Forward and backward link/embed edges between pages (moss#922 Stage 4).
2//!
3//! `DepGraph` answers "who links to / embeds this page?" — the question
4//! Stage 5's facade-gated render skip needs to widen a changed page's
5//! minimal render set to its backlinks. It is a standalone type, not a
6//! `ContentGraph` field: `ContentGraph` is a structural path index built
7//! from file paths alone (zero content reads, see `content_graph.rs`)
8//! while `DepGraph` is built from parsed per-page edges — same
9//! type-vs-populator split as `ContentGraph` itself (the type here; the
10//! `Vec<ParsedDocument>` → edges glue lives in `src-tauri`, which is not a
11//! moss-core dependency).
12//!
13//! Rebuilt fresh every build from scratch — cheap (a handful of `HashMap`
14//! inserts over ~one page count), same argument as why `ContentGraph`
15//! itself is never persisted. See the "Don't persist ContentGraph; don't
16//! use ObjectStore" section of
17//! `docs/archive/2026-07-31-incremental-build-facade-diff.md`.
18
19use crate::resolve::embeds::MAX_EMBED_DEPTH;
20use crate::resolve::{LinkType, OutgoingLink};
21use std::collections::HashMap;
22
23/// Directed link/embed edges between pages, keyed by source path.
24///
25/// Built once per build via [`DepGraph::build`]; queried via [`DepGraph::backlinks`]
26/// and [`DepGraph::back_embeds`]. `Embed` edges are a subset already present
27/// in `forward_links`/`backlinks` — `forward_embeds`/`back_embeds` narrow to
28/// just `LinkType::Embed` because embeds are more render-relevant than plain
29/// links (a transcluded page's body IS part of the embedding page's output).
30#[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    /// Build a `DepGraph` from each page's source path and the outgoing
40    /// links it resolved during parsing. Order of `pages` does not affect
41    /// the result — edge lists within a bucket follow input order for
42    /// determinism, but no page's presence depends on any other's.
43    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    /// Fold in transclusion edges recorded by the resolve phase (moss#922
78    /// Stage 7).
79    ///
80    /// `pairs` are `(target, immediate_embedder)` — exactly the shape
81    /// `ResolveResult::embed_deps` produces (`resolve/embeds.rs`), i.e. DIRECT
82    /// one-hop edges: for `index.md` embedding `a.md` embedding `b.md` the
83    /// resolver reports `[("a.md", "index.md"), ("b.md", "a.md")]`. The second
84    /// element is the file the marker was found in, NOT the top-level page
85    /// being resolved, so grouping by it yields a correct adjacency list and
86    /// multi-hop chains are answered by [`Self::embed_closure`], never by
87    /// filtering pairs on the page under test (which would silently miss
88    /// `b.md` as a dependency of `index.md`).
89    ///
90    /// This exists as a separate builder step, `ContentGraph::with_output_overrides`
91    /// style, because these edges are produced by a different phase than
92    /// `outgoing_links`: transclusion is spliced from disk bytes during resolve,
93    /// before the AST dispatcher that populates `outgoing_links` ever runs, so
94    /// no page→page `LinkType::Embed` link exists to carry them.
95    ///
96    /// Duplicate edges (the same nested pair is reported once per ancestor that
97    /// transitively embeds it) are collapsed.
98    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    /// Every file whose bytes are spliced into `path`'s markdown, transitively.
116    ///
117    /// A breadth-first walk of `forward_embeds` from `path`, excluding `path`
118    /// itself, bounded by [`MAX_EMBED_DEPTH`] — the same limit
119    /// `resolve_embeds_inner` stops recursing at, so the closure never claims a
120    /// dependency on content the resolver refused to splice. Cycles terminate
121    /// on the visited set.
122    ///
123    /// This is the parse cache's validity input (moss#922 Stage 7): `path`'s
124    /// cached `ParsedDocument` is only reusable if every member of this set
125    /// still hashes to what it hashed to when the entry was written.
126    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    /// Pages `path` links to (any `LinkType`), in resolution order. Empty if
149    /// `path` has no outgoing links or is not a source in this graph.
150    pub fn forward_links(&self, path: &str) -> &[String] {
151        self.forward_links.get(path).map(Vec::as_slice).unwrap_or(&[])
152    }
153
154    /// Pages `path` embeds (`LinkType::Embed` only). Subset of `forward_links`.
155    pub fn forward_embeds(&self, path: &str) -> &[String] {
156        self.forward_embeds.get(path).map(Vec::as_slice).unwrap_or(&[])
157    }
158
159    /// Pages that link to `path` (any `LinkType`). Empty if nothing links here.
160    pub fn backlinks(&self, path: &str) -> &[String] {
161        self.backlinks.get(path).map(Vec::as_slice).unwrap_or(&[])
162    }
163
164    /// Pages that embed `path` (`LinkType::Embed` only). Subset of `backlinks`.
165    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        // `resolve_embeds` reports the file the MARKER was found in, so a
226        // nested chain arrives as two one-hop edges, not two edges from the
227        // top-level page.
228        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        // THE regression this design exists for: index.md embeds a.md embeds
238        // b.md. A flat filter over `embed_deps` pairs whose source is
239        // "index.md" would return only a.md and leave index.md silently stale
240        // when b.md is edited.
241        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        // `a.md` itself is never reported as its own dependency.
256        assert_eq!(closure, ["b.md"]);
257    }
258
259    #[test]
260    fn embed_closure_stops_at_the_resolver_depth_limit() {
261        // A chain longer than MAX_EMBED_DEPTH: the resolver refuses to splice
262        // past the limit, so the closure must not claim a dependency there.
263        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        // The same nested pair is reported once per ancestor that transitively
275        // embeds it, so the raw input is full of duplicates.
276        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}