Skip to main content

docgen_build/
incremental.rs

1//! Incremental rebuilds for the dev server.
2//!
3//! A full [`build_site`](crate::build_site) is O(n²) in the doc count — the
4//! force-directed graph layout and the per-page nav-tree render both dominate at
5//! scale (a 2.5k-doc corpus takes ~10s). That cost is fine for `docgen build`
6//! and acceptable for the dev server's *initial* build, but rebuilding the whole
7//! site on every keystroke-save makes large workspaces painful to edit.
8//!
9//! [`DevState`] seeds itself from the initial full build's in-memory artifacts
10//! (no second pass) and, on each subsequent change, attempts a **fast path**: it
11//! re-discovers the docs, re-renders only the doc(s) whose body actually changed,
12//! and rewrites only those pages — reusing the cached nav tree, graph layout,
13//! diff workspace, and assets untouched. The fast path is taken ONLY when it is
14//! provably equivalent to a full rebuild: the set/order of slugs, every title and
15//! description, the partial set, the link graph (edges + backlinks), and the used
16//! component-island set must all be unchanged. Any structural change falls back
17//! to a full rebuild, which re-seeds the cache. The result is byte-identical to a
18//! full build for the pages it touches, and leaves every other file exactly as the
19//! last full build wrote it.
20
21use std::collections::{BTreeMap, BTreeSet};
22use std::path::{Path, PathBuf};
23
24use anyhow::Result;
25use docgen_core::graph::{build_link_graph, LinkGraph};
26use docgen_core::model::{Doc, SearchEntry, TreeNode};
27use docgen_core::pipeline::{partition_partials, prepare, render_doc, Partials, PreparedDoc};
28use docgen_core::wikilink::SlugSet;
29use docgen_render::{HomeRecent, HomeSection, Renderer, DEFAULT_PAGE_TEMPLATE};
30
31use crate::{
32    build_site_inner, compute_home_rows, render_one_page, BuildMode, BuildOptions, PageShared,
33    HOME_SLUG,
34};
35
36/// In-memory artifacts captured from a full build, enough to (a) detect whether a
37/// later change is structural and (b) re-render any single page without touching
38/// the rest of the site. Produced by [`build_site_inner`] when `capture` is set.
39pub(crate) struct CapturedBuild {
40    pub config: docgen_config::SiteConfig,
41    pub registry: docgen_components::Registry,
42    pub partials: Partials,
43    pub prepared: Vec<PreparedDoc>,
44    pub docs: Vec<Doc>,
45    pub outbound: BTreeMap<String, Vec<String>>,
46    pub graph: LinkGraph,
47    pub tree: Vec<TreeNode>,
48    pub graph_payload: Option<(String, usize, usize)>,
49    pub island_components: BTreeSet<String>,
50    pub has_components_css: bool,
51    pub commit_hash: String,
52    pub built_stamp: String,
53    pub has_diff: bool,
54    pub search: Vec<SearchEntry>,
55}
56
57/// Which kind of rebuild [`DevState::rebuild`] performed. `Full` wipes and
58/// repopulates `out_dir` (via the atomic staging swap), so the dev server must
59/// re-emit its dev-only assets afterward; `Incremental` writes only the changed
60/// pages in place and leaves everything else (including dev assets) intact.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum RebuildKind {
63    Full,
64    Incremental,
65}
66
67/// Outcome of a dev rebuild: the kind taken plus the page count.
68#[derive(Debug, Clone)]
69pub struct Rebuilt {
70    pub kind: RebuildKind,
71    pub page_count: usize,
72}
73
74/// The dev server's persistent incremental build engine. Holds the renderer and
75/// the last full build's artifacts; [`rebuild`](DevState::rebuild) takes the fast
76/// path when it can prove equivalence and falls back to a full build otherwise.
77pub struct DevState {
78    project_root: PathBuf,
79    out_dir: PathBuf,
80    renderer: Renderer,
81    cap: CapturedBuild,
82}
83
84impl DevState {
85    /// Run the initial full build (Dev mode) and seed the engine from it.
86    pub fn initial(project_root: &Path, out_dir: &Path) -> Result<(Self, Rebuilt)> {
87        let (outcome, cap) = build_site_inner(
88            &BuildOptions {
89                project_root,
90                out_dir,
91                mode: BuildMode::Dev,
92            },
93            true,
94        )?;
95        let cap = cap.expect("capture requested → CapturedBuild present");
96        let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
97        Ok((
98            Self {
99                project_root: project_root.to_path_buf(),
100                out_dir: out_dir.to_path_buf(),
101                renderer,
102                cap,
103            },
104            Rebuilt {
105                kind: RebuildKind::Full,
106                page_count: outcome.page_count,
107            },
108        ))
109    }
110
111    /// Rebuild after a filesystem change. Re-discovers the docs and takes the fast
112    /// path (re-render only changed pages) when the change is provably non-
113    /// structural; otherwise falls back to a full build and re-seeds the cache.
114    pub fn rebuild(&mut self) -> Result<Rebuilt> {
115        match self.try_incremental()? {
116            Some(rebuilt) => Ok(rebuilt),
117            None => self.full(),
118        }
119    }
120
121    /// Run a full build and replace the cached artifacts.
122    fn full(&mut self) -> Result<Rebuilt> {
123        let (outcome, cap) = build_site_inner(
124            &BuildOptions {
125                project_root: &self.project_root,
126                out_dir: &self.out_dir,
127                mode: BuildMode::Dev,
128            },
129            true,
130        )?;
131        self.cap = cap.expect("capture requested → CapturedBuild present");
132        Ok(Rebuilt {
133            kind: RebuildKind::Full,
134            page_count: outcome.page_count,
135        })
136    }
137
138    /// Attempt the fast path. Returns `Ok(Some(_))` when it succeeded, `Ok(None)`
139    /// when the change is structural and the caller must fall back to a full
140    /// build, or `Err` on a hard I/O/discovery failure.
141    fn try_incremental(&mut self) -> Result<Option<Rebuilt>> {
142        let docs_dir = self.project_root.join("docs");
143        let raws = match docgen_core::discover::discover_docs(&docs_dir) {
144            Ok(r) => r,
145            // A discovery failure is a hard error → let the full path surface it.
146            Err(_) => return Ok(None),
147        };
148        let (pages, partials_new) = partition_partials(raws);
149        let prepared_new: Vec<PreparedDoc> = pages.into_iter().map(prepare).collect();
150
151        // Partials feed `:include` transclusions whose dependents we don't track;
152        // any partial change forces a full rebuild.
153        if partials_new != self.cap.partials {
154            return Ok(None);
155        }
156        // The doc set + order must match exactly: an add/remove/rename/reorder
157        // changes the tree, sections, recent list, and graph node order.
158        if prepared_new.len() != self.cap.prepared.len() {
159            return Ok(None);
160        }
161        let mut changed: Vec<usize> = Vec::new();
162        for (i, (new, old)) in prepared_new.iter().zip(&self.cap.prepared).enumerate() {
163            if new.slug != old.slug {
164                return Ok(None);
165            }
166            // Title/description feed the sidebar tree, backlink cards on other
167            // pages, and the home sections/recent — all cross-page. Defer to full.
168            if new.title != old.title || new.description != old.description {
169                return Ok(None);
170            }
171            if new.body_md != old.body_md {
172                changed.push(i);
173            }
174        }
175
176        // Nothing actually changed (e.g. a touch / metadata-only fs event): no
177        // pages to rewrite, but report a successful incremental so the caller
178        // still fires a reload.
179        if changed.is_empty() {
180            return Ok(Some(Rebuilt {
181                kind: RebuildKind::Incremental,
182                page_count: self.cap.docs.len(),
183            }));
184        }
185
186        // Re-render only the changed docs against the (unchanged) site slug set.
187        let slugs: SlugSet = self.cap.prepared.iter().map(|p| p.slug.clone()).collect();
188        let mut rerendered: Vec<(usize, docgen_core::pipeline::RenderedDoc)> =
189            Vec::with_capacity(changed.len());
190        for &i in &changed {
191            let rd = render_doc(
192                &prepared_new[i],
193                &self.cap.config,
194                &self.cap.registry,
195                &slugs,
196                &partials_new,
197            );
198            rerendered.push((i, rd));
199        }
200
201        // Rebuild the link graph from the cached outbound map with the changed
202        // docs' entries swapped in. If the topology (edges) or backlinks differ,
203        // the layout and other pages' backlink rails are affected → full rebuild.
204        let mut outbound_new = self.cap.outbound.clone();
205        for (i, rd) in &rerendered {
206            outbound_new.insert(
207                self.cap.prepared[*i].slug.clone(),
208                rd.resolved_links.clone(),
209            );
210        }
211        let doc_meta: Vec<(String, String, Option<String>)> = self
212            .cap
213            .docs
214            .iter()
215            .map(|d| (d.slug.clone(), d.title.clone(), d.description.clone()))
216            .collect();
217        let graph_new = build_link_graph(&doc_meta, &outbound_new);
218        if graph_new.edges != self.cap.graph.edges
219            || graph_new.backlinks != self.cap.graph.backlinks
220        {
221            return Ok(None);
222        }
223
224        // The used component-island set drives the shared components.js bundle and
225        // every page's island link gating; if it changed, the bundle + other pages
226        // are affected → full rebuild. (Compute the prospective set from the new
227        // docs and compare to the cached one.)
228        let island_new = self.island_set_after(&rerendered);
229        if island_new != self.cap.island_components {
230            return Ok(None);
231        }
232
233        // ---- Fast path committed: every gate proved equivalence. ----
234        // Patch the cache with the re-rendered docs.
235        for (i, rd) in rerendered {
236            self.cap.search[i] = SearchEntry {
237                slug: self.cap.docs[i].slug.clone(),
238                title: self.cap.docs[i].title.clone(),
239                text: rd.search_text,
240            };
241            self.cap.docs[i] = rd.doc;
242        }
243        self.cap.outbound = outbound_new;
244        self.cap.prepared = prepared_new;
245        self.cap.partials = partials_new;
246
247        // Re-render + write only the changed pages, reusing the cached tree,
248        // graph layout, home rows, and per-page chrome.
249        let (section_rows, recent_rows) = compute_home_rows(&self.cap.docs);
250        let home_sections: Vec<HomeSection> = section_rows
251            .iter()
252            .map(|(label, slug, count)| HomeSection {
253                label,
254                slug,
255                count: *count,
256            })
257            .collect();
258        let home_recent: Vec<HomeRecent> = recent_rows
259            .iter()
260            .map(|(title, slug, section)| HomeRecent {
261                title,
262                slug,
263                section,
264            })
265            .collect();
266        let shared = PageShared {
267            tree: &self.cap.tree,
268            graph: &self.cap.graph,
269            commit: &self.cap.commit_hash,
270            built: &self.cap.built_stamp,
271            base: &self.cap.config.base,
272            site_title: self.cap.config.title.as_deref().unwrap_or(""),
273            search_enabled: self.cap.config.features.search,
274            has_diff: self.cap.has_diff,
275            has_components_css: self.cap.has_components_css,
276            island_components: &self.cap.island_components,
277            graph_payload: &self.cap.graph_payload,
278            home_sections: &home_sections,
279            home_recent: &home_recent,
280            pages_count: self.cap.docs.len(),
281            total_links: self.cap.graph.edges.len(),
282        };
283
284        for &i in &changed {
285            let doc = &self.cap.docs[i];
286            let html = render_one_page(&self.renderer, &shared, doc)?;
287            let dir = self.out_dir.join(&doc.slug);
288            std::fs::create_dir_all(&dir)?;
289            std::fs::write(dir.join("index.html"), &html)?;
290            // The home doc is also served at the site root.
291            if doc.slug == HOME_SLUG {
292                std::fs::write(self.out_dir.join("index.html"), &html)?;
293            }
294        }
295
296        // The search index aggregates every doc's text, so a single changed doc
297        // means rewriting it — cheap relative to a full O(n²) rebuild.
298        if self.cap.config.features.search {
299            std::fs::write(
300                self.out_dir.join("search-index.json"),
301                docgen_core::search::index_json(&self.cap.search),
302            )?;
303        }
304
305        Ok(Some(Rebuilt {
306            kind: RebuildKind::Incremental,
307            page_count: self.cap.docs.len(),
308        }))
309    }
310
311    /// The used component-island set the site would have after applying the
312    /// re-rendered docs: every doc's `components_used` ∩ the registry's islands.
313    /// Mirrors the `island_components` set [`build_site_inner`] computes.
314    fn island_set_after(
315        &self,
316        rerendered: &[(usize, docgen_core::pipeline::RenderedDoc)],
317    ) -> BTreeSet<String> {
318        let islands: BTreeSet<&str> = self
319            .cap
320            .registry
321            .islands()
322            .iter()
323            .map(|c| c.name.as_str())
324            .collect();
325        let mut used: BTreeSet<String> = BTreeSet::new();
326        for (i, doc) in self.cap.docs.iter().enumerate() {
327            // Use the re-rendered components for changed docs, the cached ones else.
328            let components = rerendered
329                .iter()
330                .find(|(j, _)| *j == i)
331                .map(|(_, rd)| &rd.doc.components_used)
332                .unwrap_or(&doc.components_used);
333            for c in components {
334                if islands.contains(c.as_str()) {
335                    used.insert(c.clone());
336                }
337            }
338        }
339        used
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::fs;
347
348    /// Write a small multi-doc corpus into `<root>/docs` and return the root.
349    fn corpus(dir: &Path) {
350        let docs = dir.join("docs");
351        fs::create_dir_all(docs.join("guide")).unwrap();
352        fs::write(
353            docs.join("index.md"),
354            "# Home\n\nWelcome. See [[guide/a]].\n",
355        )
356        .unwrap();
357        fs::write(
358            docs.join("guide/a.md"),
359            "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
360        )
361        .unwrap();
362        fs::write(
363            docs.join("guide/b.md"),
364            "# Beta\n\nBeta body. Link to [[guide/a]].\n",
365        )
366        .unwrap();
367    }
368
369    /// The "Built" timestamp is wall-clock and the only field that legitimately
370    /// varies between two builds, so mask it before comparing for equivalence.
371    fn mask_built(html: &str, stamp: &str) -> String {
372        if stamp.is_empty() {
373            return html.to_string();
374        }
375        html.replace(stamp, "BUILT")
376    }
377
378    #[test]
379    fn incremental_body_edit_matches_full_rebuild_and_leaves_others_untouched() {
380        let tmp = tempfile::tempdir().unwrap();
381        let root = tmp.path();
382        corpus(root);
383        let out = root.join("out");
384
385        let (mut state, first) = DevState::initial(root, &out).unwrap();
386        assert_eq!(first.kind, RebuildKind::Full);
387        let init_stamp = state.cap.built_stamp.clone();
388
389        // Record the bytes of the pages we expect NOT to change.
390        let index_before = fs::read(out.join("index.html")).unwrap();
391        let b_before = fs::read(out.join("guide/b/index.html")).unwrap();
392
393        // Edit ONLY doc A's body (same title, same outbound links).
394        fs::write(
395            root.join("docs/guide/a.md"),
396            "# Alpha\n\nAlpha body REVISED with new prose. Link to [[guide/b]].\n",
397        )
398        .unwrap();
399
400        let r = state.rebuild().unwrap();
401        assert_eq!(
402            r.kind,
403            RebuildKind::Incremental,
404            "body-only edit must be incremental"
405        );
406
407        let a_incremental = fs::read_to_string(out.join("guide/a/index.html")).unwrap();
408        assert!(
409            a_incremental.contains("REVISED with new prose"),
410            "incremental page reflects the edit"
411        );
412
413        // The unrelated pages are byte-for-byte untouched.
414        assert_eq!(
415            fs::read(out.join("index.html")).unwrap(),
416            index_before,
417            "home page must not be rewritten by a body edit elsewhere"
418        );
419        assert_eq!(
420            fs::read(out.join("guide/b/index.html")).unwrap(),
421            b_before,
422            "sibling page must not be rewritten"
423        );
424
425        // Equivalence: a full rebuild of the edited corpus produces the same A page
426        // (modulo the wall-clock Built stamp).
427        let ref_out = root.join("ref");
428        let (_outcome, refcap) = build_site_inner(
429            &BuildOptions {
430                project_root: root,
431                out_dir: &ref_out,
432                mode: BuildMode::Dev,
433            },
434            true,
435        )
436        .unwrap();
437        let refcap = refcap.unwrap();
438        let a_full = fs::read_to_string(ref_out.join("guide/a/index.html")).unwrap();
439        assert_eq!(
440            mask_built(&a_incremental, &init_stamp),
441            mask_built(&a_full, &refcap.built_stamp),
442            "incremental page is byte-identical to a full rebuild's page"
443        );
444    }
445
446    #[test]
447    fn title_change_falls_back_to_full() {
448        let tmp = tempfile::tempdir().unwrap();
449        let root = tmp.path();
450        corpus(root);
451        let out = root.join("out");
452        let (mut state, _) = DevState::initial(root, &out).unwrap();
453
454        // Changing the H1 changes the derived title → sidebar + cross-page → full.
455        fs::write(
456            root.join("docs/guide/a.md"),
457            "# Alpha Renamed\n\nAlpha body. Link to [[guide/b]].\n",
458        )
459        .unwrap();
460        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
461    }
462
463    #[test]
464    fn adding_a_link_falls_back_to_full() {
465        let tmp = tempfile::tempdir().unwrap();
466        let root = tmp.path();
467        corpus(root);
468        let out = root.join("out");
469        let (mut state, _) = DevState::initial(root, &out).unwrap();
470
471        // Adding an outbound wikilink changes graph topology + a backlink → full.
472        fs::write(
473            root.join("docs/guide/a.md"),
474            "# Alpha\n\nAlpha body. Link to [[guide/b]] and now [[index]].\n",
475        )
476        .unwrap();
477        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
478    }
479
480    #[test]
481    fn adding_a_new_doc_falls_back_to_full() {
482        let tmp = tempfile::tempdir().unwrap();
483        let root = tmp.path();
484        corpus(root);
485        let out = root.join("out");
486        let (mut state, _) = DevState::initial(root, &out).unwrap();
487
488        fs::write(root.join("docs/guide/c.md"), "# Gamma\n\nNew page.\n").unwrap();
489        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
490    }
491
492    #[test]
493    fn no_op_change_is_incremental() {
494        let tmp = tempfile::tempdir().unwrap();
495        let root = tmp.path();
496        corpus(root);
497        let out = root.join("out");
498        let (mut state, _) = DevState::initial(root, &out).unwrap();
499
500        // Rewrite identical bytes (a bare `touch`-like save): no changed docs.
501        fs::write(
502            root.join("docs/guide/a.md"),
503            "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
504        )
505        .unwrap();
506        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Incremental);
507    }
508}