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            );
207            rerendered.push((i, rd));
208        }
209
210        // Rebuild the link graph from the cached outbound map with the changed
211        // docs' entries swapped in. If the topology (edges) or backlinks differ,
212        // the layout and other pages' backlink rails are affected → full rebuild.
213        let mut outbound_new = self.cap.outbound.clone();
214        for (i, rd) in &rerendered {
215            outbound_new.insert(
216                self.cap.prepared[*i].slug.clone(),
217                rd.resolved_links.clone(),
218            );
219        }
220        let doc_meta: Vec<(String, String, Option<String>)> = self
221            .cap
222            .docs
223            .iter()
224            .map(|d| (d.slug.clone(), d.title.clone(), d.description.clone()))
225            .collect();
226        let graph_new = build_link_graph(&doc_meta, &outbound_new);
227        if graph_new.edges != self.cap.graph.edges
228            || graph_new.backlinks != self.cap.graph.backlinks
229        {
230            return Ok(None);
231        }
232
233        // The used component-island set drives the shared components.js bundle and
234        // every page's island link gating; if it changed, the bundle + other pages
235        // are affected → full rebuild. (Compute the prospective set from the new
236        // docs and compare to the cached one.)
237        let island_new = self.island_set_after(&rerendered);
238        if island_new != self.cap.island_components {
239            return Ok(None);
240        }
241
242        // ---- Fast path committed: every gate proved equivalence. ----
243        // Patch the cache with the re-rendered docs.
244        for (i, rd) in rerendered {
245            self.cap.search[i] = SearchEntry {
246                slug: self.cap.docs[i].slug.clone(),
247                title: self.cap.docs[i].title.clone(),
248                text: rd.search_text,
249            };
250            self.cap.docs[i] = rd.doc;
251        }
252        self.cap.outbound = outbound_new;
253        self.cap.prepared = prepared_new;
254        self.cap.partials = partials_new;
255
256        // Re-render + write only the changed pages, reusing the cached tree,
257        // graph layout, home rows, and per-page chrome.
258        let (section_rows, recent_rows) = compute_home_rows(&self.cap.docs);
259        let home_sections: Vec<HomeSection> = section_rows
260            .iter()
261            .map(|(label, slug, count)| HomeSection {
262                label,
263                slug,
264                count: *count,
265            })
266            .collect();
267        let home_recent: Vec<HomeRecent> = recent_rows
268            .iter()
269            .map(|(title, slug, section)| HomeRecent {
270                title,
271                slug,
272                section,
273            })
274            .collect();
275        let shared = PageShared {
276            tree: &self.cap.tree,
277            graph: &self.cap.graph,
278            commit: &self.cap.commit_hash,
279            built: &self.cap.built_stamp,
280            base: &self.cap.config.base,
281            site_title: self.cap.config.title.as_deref().unwrap_or(""),
282            search_enabled: self.cap.config.features.search,
283            has_diff: self.cap.has_diff,
284            has_components_css: self.cap.has_components_css,
285            island_components: &self.cap.island_components,
286            graph_payload: &self.cap.graph_payload,
287            home_sections: &home_sections,
288            home_recent: &home_recent,
289            pages_count: self.cap.docs.len(),
290            total_links: self.cap.graph.edges.len(),
291        };
292
293        for &i in &changed {
294            let doc = &self.cap.docs[i];
295            let html = render_one_page(&self.renderer, &shared, doc)?;
296            let dir = self.out_dir.join(&doc.slug);
297            std::fs::create_dir_all(&dir)?;
298            std::fs::write(dir.join("index.html"), &html)?;
299            // The home doc is also served at the site root.
300            if doc.slug == HOME_SLUG {
301                std::fs::write(self.out_dir.join("index.html"), &html)?;
302            }
303        }
304
305        // The search index aggregates every doc's text, so a single changed doc
306        // means rewriting it — cheap relative to a full O(n²) rebuild.
307        if self.cap.config.features.search {
308            std::fs::write(
309                self.out_dir.join("search-index.json"),
310                docgen_core::search::index_json(&self.cap.search),
311            )?;
312        }
313
314        Ok(Some(Rebuilt {
315            kind: RebuildKind::Incremental,
316            page_count: self.cap.docs.len(),
317        }))
318    }
319
320    /// The used component-island set the site would have after applying the
321    /// re-rendered docs: every doc's `components_used` ∩ the registry's islands.
322    /// Mirrors the `island_components` set [`build_site_inner`] computes.
323    fn island_set_after(
324        &self,
325        rerendered: &[(usize, docgen_core::pipeline::RenderedDoc)],
326    ) -> BTreeSet<String> {
327        let islands: BTreeSet<&str> = self
328            .cap
329            .registry
330            .islands()
331            .iter()
332            .map(|c| c.name.as_str())
333            .collect();
334        let mut used: BTreeSet<String> = BTreeSet::new();
335        for (i, doc) in self.cap.docs.iter().enumerate() {
336            // Use the re-rendered components for changed docs, the cached ones else.
337            let components = rerendered
338                .iter()
339                .find(|(j, _)| *j == i)
340                .map(|(_, rd)| &rd.doc.components_used)
341                .unwrap_or(&doc.components_used);
342            for c in components {
343                if islands.contains(c.as_str()) {
344                    used.insert(c.clone());
345                }
346            }
347        }
348        used
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use std::fs;
356
357    /// Write a small multi-doc corpus into `<root>/docs` and return the root.
358    fn corpus(dir: &Path) {
359        let docs = dir.join("docs");
360        fs::create_dir_all(docs.join("guide")).unwrap();
361        fs::write(
362            docs.join("index.md"),
363            "# Home\n\nWelcome. See [[guide/a]].\n",
364        )
365        .unwrap();
366        fs::write(
367            docs.join("guide/a.md"),
368            "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
369        )
370        .unwrap();
371        fs::write(
372            docs.join("guide/b.md"),
373            "# Beta\n\nBeta body. Link to [[guide/a]].\n",
374        )
375        .unwrap();
376    }
377
378    /// The "Built" timestamp is wall-clock and the only field that legitimately
379    /// varies between two builds, so mask it before comparing for equivalence.
380    fn mask_built(html: &str, stamp: &str) -> String {
381        if stamp.is_empty() {
382            return html.to_string();
383        }
384        html.replace(stamp, "BUILT")
385    }
386
387    #[test]
388    fn incremental_body_edit_matches_full_rebuild_and_leaves_others_untouched() {
389        let tmp = tempfile::tempdir().unwrap();
390        let root = tmp.path();
391        corpus(root);
392        let out = root.join("out");
393
394        let (mut state, first) = DevState::initial(root, &out).unwrap();
395        assert_eq!(first.kind, RebuildKind::Full);
396        let init_stamp = state.cap.built_stamp.clone();
397
398        // Record the bytes of the pages we expect NOT to change.
399        let index_before = fs::read(out.join("index.html")).unwrap();
400        let b_before = fs::read(out.join("guide/b/index.html")).unwrap();
401
402        // Edit ONLY doc A's body (same title, same outbound links).
403        fs::write(
404            root.join("docs/guide/a.md"),
405            "# Alpha\n\nAlpha body REVISED with new prose. Link to [[guide/b]].\n",
406        )
407        .unwrap();
408
409        let r = state.rebuild().unwrap();
410        assert_eq!(
411            r.kind,
412            RebuildKind::Incremental,
413            "body-only edit must be incremental"
414        );
415
416        let a_incremental = fs::read_to_string(out.join("guide/a/index.html")).unwrap();
417        assert!(
418            a_incremental.contains("REVISED with new prose"),
419            "incremental page reflects the edit"
420        );
421
422        // The unrelated pages are byte-for-byte untouched.
423        assert_eq!(
424            fs::read(out.join("index.html")).unwrap(),
425            index_before,
426            "home page must not be rewritten by a body edit elsewhere"
427        );
428        assert_eq!(
429            fs::read(out.join("guide/b/index.html")).unwrap(),
430            b_before,
431            "sibling page must not be rewritten"
432        );
433
434        // Equivalence: a full rebuild of the edited corpus produces the same A page
435        // (modulo the wall-clock Built stamp).
436        let ref_out = root.join("ref");
437        let (_outcome, refcap) = build_site_inner(
438            &BuildOptions {
439                project_root: root,
440                out_dir: &ref_out,
441                mode: BuildMode::Dev,
442            },
443            true,
444        )
445        .unwrap();
446        let refcap = refcap.unwrap();
447        let a_full = fs::read_to_string(ref_out.join("guide/a/index.html")).unwrap();
448        assert_eq!(
449            mask_built(&a_incremental, &init_stamp),
450            mask_built(&a_full, &refcap.built_stamp),
451            "incremental page is byte-identical to a full rebuild's page"
452        );
453    }
454
455    #[test]
456    fn title_change_falls_back_to_full() {
457        let tmp = tempfile::tempdir().unwrap();
458        let root = tmp.path();
459        corpus(root);
460        let out = root.join("out");
461        let (mut state, _) = DevState::initial(root, &out).unwrap();
462
463        // Changing the H1 changes the derived title → sidebar + cross-page → full.
464        fs::write(
465            root.join("docs/guide/a.md"),
466            "# Alpha Renamed\n\nAlpha body. Link to [[guide/b]].\n",
467        )
468        .unwrap();
469        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
470    }
471
472    #[test]
473    fn adding_a_link_falls_back_to_full() {
474        let tmp = tempfile::tempdir().unwrap();
475        let root = tmp.path();
476        corpus(root);
477        let out = root.join("out");
478        let (mut state, _) = DevState::initial(root, &out).unwrap();
479
480        // Adding an outbound wikilink changes graph topology + a backlink → full.
481        fs::write(
482            root.join("docs/guide/a.md"),
483            "# Alpha\n\nAlpha body. Link to [[guide/b]] and now [[index]].\n",
484        )
485        .unwrap();
486        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
487    }
488
489    #[test]
490    fn adding_a_new_doc_falls_back_to_full() {
491        let tmp = tempfile::tempdir().unwrap();
492        let root = tmp.path();
493        corpus(root);
494        let out = root.join("out");
495        let (mut state, _) = DevState::initial(root, &out).unwrap();
496
497        fs::write(root.join("docs/guide/c.md"), "# Gamma\n\nNew page.\n").unwrap();
498        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
499    }
500
501    #[test]
502    fn no_op_change_is_incremental() {
503        let tmp = tempfile::tempdir().unwrap();
504        let root = tmp.path();
505        corpus(root);
506        let out = root.join("out");
507        let (mut state, _) = DevState::initial(root, &out).unwrap();
508
509        // Rewrite identical bytes (a bare `touch`-like save): no changed docs.
510        fs::write(
511            root.join("docs/guide/a.md"),
512            "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
513        )
514        .unwrap();
515        assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Incremental);
516    }
517}