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