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