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) => {
117                // The fast path rewrites only changed pages and never touches the
118                // static-asset tree. A watcher event for an added/edited image
119                // takes this path (the doc set is unchanged), so refresh the
120                // copied assets here — otherwise a new image wouldn't appear until
121                // a structural change forced a full rebuild. Full builds copy
122                // assets themselves via `build_site_inner`.
123                crate::copy_assets(&self.project_root.join("docs"), &self.out_dir)?;
124                Ok(rebuilt)
125            }
126            None => self.full(),
127        }
128    }
129
130    /// Run a full build and replace the cached artifacts.
131    fn full(&mut self) -> Result<Rebuilt> {
132        let (outcome, cap) = build_site_inner(
133            &BuildOptions {
134                project_root: &self.project_root,
135                out_dir: &self.out_dir,
136                mode: BuildMode::Dev,
137            },
138            true,
139        )?;
140        self.cap = cap.expect("capture requested → CapturedBuild present");
141        Ok(Rebuilt {
142            kind: RebuildKind::Full,
143            page_count: outcome.page_count,
144        })
145    }
146
147    /// Attempt the fast path. Returns `Ok(Some(_))` when it succeeded, `Ok(None)`
148    /// when the change is structural and the caller must fall back to a full
149    /// build, or `Err` on a hard I/O/discovery failure.
150    fn try_incremental(&mut self) -> Result<Option<Rebuilt>> {
151        let docs_dir = self.project_root.join("docs");
152        let raws = match docgen_core::discover::discover_docs(&docs_dir) {
153            Ok(r) => r,
154            // A discovery failure is a hard error → let the full path surface it.
155            Err(_) => return Ok(None),
156        };
157        let (pages, partials_new) = partition_partials(raws);
158        let prepared_new: Vec<PreparedDoc> = pages.into_iter().map(prepare).collect();
159
160        // Partials feed `:include` transclusions whose dependents we don't track;
161        // any partial change forces a full rebuild.
162        if partials_new != self.cap.partials {
163            return Ok(None);
164        }
165        // The doc set + order must match exactly: an add/remove/rename/reorder
166        // changes the tree, sections, recent list, and graph node order.
167        if prepared_new.len() != self.cap.prepared.len() {
168            return Ok(None);
169        }
170        let mut changed: Vec<usize> = Vec::new();
171        for (i, (new, old)) in prepared_new.iter().zip(&self.cap.prepared).enumerate() {
172            if new.slug != old.slug {
173                return Ok(None);
174            }
175            // Title/description feed the sidebar tree, backlink cards on other
176            // pages, and the home sections/recent — all cross-page. Defer to full.
177            if new.title != old.title || new.description != old.description {
178                return Ok(None);
179            }
180            if new.body_md != old.body_md {
181                changed.push(i);
182            }
183        }
184
185        // Nothing actually changed (e.g. a touch / metadata-only fs event): no
186        // pages to rewrite, but report a successful incremental so the caller
187        // still fires a reload.
188        if changed.is_empty() {
189            return Ok(Some(Rebuilt {
190                kind: RebuildKind::Incremental,
191                page_count: self.cap.docs.len(),
192            }));
193        }
194
195        // Re-render only the changed docs against the (unchanged) site slug set.
196        let slugs: SlugSet = self.cap.prepared.iter().map(|p| p.slug.clone()).collect();
197        let mut rerendered: Vec<(usize, docgen_core::pipeline::RenderedDoc)> =
198            Vec::with_capacity(changed.len());
199        for &i in &changed {
200            let rd = render_doc(
201                &prepared_new[i],
202                &self.cap.config,
203                &self.cap.registry,
204                &slugs,
205                &partials_new,
206                None,
207            );
208            rerendered.push((i, rd));
209        }
210
211        // Rebuild the link graph from the cached outbound map with the changed
212        // docs' entries swapped in. If the topology (edges) or backlinks differ,
213        // the layout and other pages' backlink rails are affected → full rebuild.
214        let mut outbound_new = self.cap.outbound.clone();
215        for (i, rd) in &rerendered {
216            outbound_new.insert(
217                self.cap.prepared[*i].slug.clone(),
218                rd.resolved_links.clone(),
219            );
220        }
221        let doc_meta: Vec<(String, String, Option<String>)> = self
222            .cap
223            .docs
224            .iter()
225            .map(|d| (d.slug.clone(), d.title.clone(), d.description.clone()))
226            .collect();
227        let graph_new = build_link_graph(&doc_meta, &outbound_new);
228        if graph_new.edges != self.cap.graph.edges
229            || graph_new.backlinks != self.cap.graph.backlinks
230        {
231            return Ok(None);
232        }
233
234        // The used component-island set drives the shared components.js bundle and
235        // every page's island link gating; if it changed, the bundle + other pages
236        // are affected → full rebuild. (Compute the prospective set from the new
237        // docs and compare to the cached one.)
238        let island_new = self.island_set_after(&rerendered);
239        if island_new != self.cap.island_components {
240            return Ok(None);
241        }
242
243        // ---- Fast path committed: every gate proved equivalence. ----
244        // Patch the cache with the re-rendered docs.
245        for (i, rd) in rerendered {
246            self.cap.search[i] = SearchEntry {
247                slug: self.cap.docs[i].slug.clone(),
248                title: self.cap.docs[i].title.clone(),
249                text: rd.search_text,
250            };
251            self.cap.docs[i] = rd.doc;
252        }
253        self.cap.outbound = outbound_new;
254        self.cap.prepared = prepared_new;
255        self.cap.partials = partials_new;
256
257        // Re-render + write only the changed pages, reusing the cached tree,
258        // graph layout, home rows, and per-page chrome.
259        let (section_rows, recent_rows) = compute_home_rows(&self.cap.docs);
260        let home_sections: Vec<HomeSection> = section_rows
261            .iter()
262            .map(|(label, slug, count)| HomeSection {
263                label,
264                slug,
265                count: *count,
266            })
267            .collect();
268        let home_recent: Vec<HomeRecent> = recent_rows
269            .iter()
270            .map(|(title, slug, section)| HomeRecent {
271                title,
272                slug,
273                section,
274            })
275            .collect();
276        let shared = PageShared {
277            tree: &self.cap.tree,
278            graph: &self.cap.graph,
279            commit: &self.cap.commit_hash,
280            built: &self.cap.built_stamp,
281            base: &self.cap.config.base,
282            site_title: self.cap.config.title.as_deref().unwrap_or(""),
283            search_enabled: self.cap.config.features.search,
284            has_diff: self.cap.has_diff,
285            has_components_css: self.cap.has_components_css,
286            island_components: &self.cap.island_components,
287            graph_payload: &self.cap.graph_payload,
288            home_sections: &home_sections,
289            home_recent: &home_recent,
290            pages_count: self.cap.docs.len(),
291            total_links: self.cap.graph.edges.len(),
292        };
293
294        for &i in &changed {
295            let doc = &self.cap.docs[i];
296            let html = render_one_page(&self.renderer, &shared, doc)?;
297            let dir = self.out_dir.join(&doc.slug);
298            std::fs::create_dir_all(&dir)?;
299            std::fs::write(dir.join("index.html"), &html)?;
300            // The home doc is also served at the site root.
301            if doc.slug == HOME_SLUG {
302                std::fs::write(self.out_dir.join("index.html"), &html)?;
303            }
304        }
305
306        // The search index aggregates every doc's text, so a single changed doc
307        // means rewriting it — cheap relative to a full O(n²) rebuild.
308        if self.cap.config.features.search {
309            std::fs::write(
310                self.out_dir.join("search-index.json"),
311                docgen_core::search::index_json(&self.cap.search),
312            )?;
313        }
314
315        Ok(Some(Rebuilt {
316            kind: RebuildKind::Incremental,
317            page_count: self.cap.docs.len(),
318        }))
319    }
320
321    /// The used component-island set the site would have after applying the
322    /// re-rendered docs: every doc's `components_used` ∩ the registry's islands.
323    /// Mirrors the `island_components` set [`build_site_inner`] computes.
324    fn island_set_after(
325        &self,
326        rerendered: &[(usize, docgen_core::pipeline::RenderedDoc)],
327    ) -> BTreeSet<String> {
328        let islands: BTreeSet<&str> = self
329            .cap
330            .registry
331            .islands()
332            .iter()
333            .map(|c| c.name.as_str())
334            .collect();
335        let mut used: BTreeSet<String> = BTreeSet::new();
336        for (i, doc) in self.cap.docs.iter().enumerate() {
337            // Use the re-rendered components for changed docs, the cached ones else.
338            let components = rerendered
339                .iter()
340                .find(|(j, _)| *j == i)
341                .map(|(_, rd)| &rd.doc.components_used)
342                .unwrap_or(&doc.components_used);
343            for c in components {
344                if islands.contains(c.as_str()) {
345                    used.insert(c.clone());
346                }
347            }
348        }
349        used
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use std::fs;
357
358    /// Write a small multi-doc corpus into `<root>/docs` and return the root.
359    fn corpus(dir: &Path) {
360        let docs = dir.join("docs");
361        fs::create_dir_all(docs.join("guide")).unwrap();
362        fs::write(
363            docs.join("index.md"),
364            "# Home\n\nWelcome. See [[guide/a]].\n",
365        )
366        .unwrap();
367        fs::write(
368            docs.join("guide/a.md"),
369            "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
370        )
371        .unwrap();
372        fs::write(
373            docs.join("guide/b.md"),
374            "# Beta\n\nBeta body. Link to [[guide/a]].\n",
375        )
376        .unwrap();
377    }
378
379    /// The "Built" timestamp is wall-clock and the only field that legitimately
380    /// varies between two builds, so mask it before comparing for equivalence.
381    fn mask_built(html: &str, stamp: &str) -> String {
382        if stamp.is_empty() {
383            return html.to_string();
384        }
385        html.replace(stamp, "BUILT")
386    }
387
388    #[test]
389    fn incremental_body_edit_matches_full_rebuild_and_leaves_others_untouched() {
390        let tmp = tempfile::tempdir().unwrap();
391        let root = tmp.path();
392        corpus(root);
393        let out = root.join("out");
394
395        let (mut state, first) = DevState::initial(root, &out).unwrap();
396        assert_eq!(first.kind, RebuildKind::Full);
397        let init_stamp = state.cap.built_stamp.clone();
398
399        // Record the bytes of the pages we expect NOT to change.
400        let index_before = fs::read(out.join("index.html")).unwrap();
401        let b_before = fs::read(out.join("guide/b/index.html")).unwrap();
402
403        // Edit ONLY doc A's body (same title, same outbound links).
404        fs::write(
405            root.join("docs/guide/a.md"),
406            "# Alpha\n\nAlpha body REVISED with new prose. Link to [[guide/b]].\n",
407        )
408        .unwrap();
409
410        let r = state.rebuild().unwrap();
411        assert_eq!(
412            r.kind,
413            RebuildKind::Incremental,
414            "body-only edit must be incremental"
415        );
416
417        let a_incremental = fs::read_to_string(out.join("guide/a/index.html")).unwrap();
418        assert!(
419            a_incremental.contains("REVISED with new prose"),
420            "incremental page reflects the edit"
421        );
422
423        // The unrelated pages are byte-for-byte untouched.
424        assert_eq!(
425            fs::read(out.join("index.html")).unwrap(),
426            index_before,
427            "home page must not be rewritten by a body edit elsewhere"
428        );
429        assert_eq!(
430            fs::read(out.join("guide/b/index.html")).unwrap(),
431            b_before,
432            "sibling page must not be rewritten"
433        );
434
435        // Equivalence: a full rebuild of the edited corpus produces the same A page
436        // (modulo the wall-clock Built stamp).
437        let ref_out = root.join("ref");
438        let (_outcome, refcap) = build_site_inner(
439            &BuildOptions {
440                project_root: root,
441                out_dir: &ref_out,
442                mode: BuildMode::Dev,
443            },
444            true,
445        )
446        .unwrap();
447        let refcap = refcap.unwrap();
448        let a_full = fs::read_to_string(ref_out.join("guide/a/index.html")).unwrap();
449        assert_eq!(
450            mask_built(&a_incremental, &init_stamp),
451            mask_built(&a_full, &refcap.built_stamp),
452            "incremental page is byte-identical to a full rebuild's page"
453        );
454    }
455
456    #[test]
457    fn title_change_falls_back_to_full() {
458        let tmp = tempfile::tempdir().unwrap();
459        let root = tmp.path();
460        corpus(root);
461        let out = root.join("out");
462        let (mut state, _) = DevState::initial(root, &out).unwrap();
463
464        // Changing the H1 changes the derived title → sidebar + cross-page → full.
465        fs::write(
466            root.join("docs/guide/a.md"),
467            "# Alpha Renamed\n\nAlpha body. Link to [[guide/b]].\n",
468        )
469        .unwrap();
470        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
471    }
472
473    #[test]
474    fn adding_a_link_falls_back_to_full() {
475        let tmp = tempfile::tempdir().unwrap();
476        let root = tmp.path();
477        corpus(root);
478        let out = root.join("out");
479        let (mut state, _) = DevState::initial(root, &out).unwrap();
480
481        // Adding an outbound wikilink changes graph topology + a backlink → full.
482        fs::write(
483            root.join("docs/guide/a.md"),
484            "# Alpha\n\nAlpha body. Link to [[guide/b]] and now [[index]].\n",
485        )
486        .unwrap();
487        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
488    }
489
490    #[test]
491    fn adding_a_new_doc_falls_back_to_full() {
492        let tmp = tempfile::tempdir().unwrap();
493        let root = tmp.path();
494        corpus(root);
495        let out = root.join("out");
496        let (mut state, _) = DevState::initial(root, &out).unwrap();
497
498        fs::write(root.join("docs/guide/c.md"), "# Gamma\n\nNew page.\n").unwrap();
499        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
500    }
501
502    #[test]
503    fn no_op_change_is_incremental() {
504        let tmp = tempfile::tempdir().unwrap();
505        let root = tmp.path();
506        corpus(root);
507        let out = root.join("out");
508        let (mut state, _) = DevState::initial(root, &out).unwrap();
509
510        // Rewrite identical bytes (a bare `touch`-like save): no changed docs.
511        fs::write(
512            root.join("docs/guide/a.md"),
513            "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
514        )
515        .unwrap();
516        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Incremental);
517    }
518}