use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use anyhow::Result;
use docgen_core::graph::{build_link_graph, LinkGraph};
use docgen_core::model::{Doc, SearchEntry, TreeNode};
use docgen_core::pipeline::{partition_partials, prepare, render_doc, Partials, PreparedDoc};
use docgen_core::wikilink::SlugSet;
use docgen_render::{HomeRecent, HomeSection, Renderer, DEFAULT_PAGE_TEMPLATE};
use crate::{
build_site_inner, compute_home_rows, render_one_page, BuildMode, BuildOptions, PageShared,
HOME_SLUG,
};
pub(crate) struct CapturedBuild {
pub config: docgen_config::SiteConfig,
pub registry: docgen_components::Registry,
pub partials: Partials,
pub diagrams: docgen_core::pipeline::Diagrams,
pub plantuml_server: Option<String>,
pub bases_corpus: Option<docgen_bases::Corpus>,
pub base_inputs: Vec<docgen_core::discover::BaseFileInput>,
pub base_pages: Vec<Doc>,
pub has_base_consumers: bool,
pub prepared: Vec<PreparedDoc>,
pub docs: Vec<Doc>,
pub outbound: BTreeMap<String, Vec<String>>,
pub graph: LinkGraph,
pub tree: Vec<TreeNode>,
pub graph_payload: Option<(String, usize, usize)>,
pub island_components: BTreeSet<String>,
pub has_components_css: bool,
pub commit_hash: String,
pub built_stamp: String,
pub has_diff: bool,
pub search: Vec<SearchEntry>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RebuildKind {
Full,
Incremental,
}
#[derive(Debug, Clone)]
pub struct Rebuilt {
pub kind: RebuildKind,
pub page_count: usize,
}
pub struct DevState {
project_root: PathBuf,
out_dir: PathBuf,
renderer: Renderer,
cap: CapturedBuild,
}
impl DevState {
pub fn initial(project_root: &Path, out_dir: &Path) -> Result<(Self, Rebuilt)> {
let (outcome, cap) = build_site_inner(
&BuildOptions {
project_root,
out_dir,
mode: BuildMode::Dev,
},
true,
)?;
let cap = cap.expect("capture requested → CapturedBuild present");
let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
Ok((
Self {
project_root: project_root.to_path_buf(),
out_dir: out_dir.to_path_buf(),
renderer,
cap,
},
Rebuilt {
kind: RebuildKind::Full,
page_count: outcome.page_count,
},
))
}
pub fn rebuild(&mut self) -> Result<Rebuilt> {
match self.try_incremental()? {
Some(rebuilt) => {
crate::copy_assets(&self.project_root.join("docs"), &self.out_dir)?;
Ok(rebuilt)
}
None => self.full(),
}
}
fn full(&mut self) -> Result<Rebuilt> {
let (outcome, cap) = build_site_inner(
&BuildOptions {
project_root: &self.project_root,
out_dir: &self.out_dir,
mode: BuildMode::Dev,
},
true,
)?;
self.cap = cap.expect("capture requested → CapturedBuild present");
Ok(Rebuilt {
kind: RebuildKind::Full,
page_count: outcome.page_count,
})
}
fn try_incremental(&mut self) -> Result<Option<Rebuilt>> {
let docs_dir = self.project_root.join("docs");
let raws = match docgen_core::discover::discover_docs(&docs_dir) {
Ok(r) => r,
Err(_) => return Ok(None),
};
let (pages, partials_new) = partition_partials(raws);
let prepared_new: Vec<PreparedDoc> = pages.into_iter().map(prepare).collect();
if partials_new != self.cap.partials {
return Ok(None);
}
let diagrams_new = match docgen_core::discover::discover_diagrams(&docs_dir) {
Ok(d) => d,
Err(_) => return Ok(None),
};
if diagrams_new != self.cap.diagrams {
return Ok(None);
}
if self.cap.config.features.bases {
let bases_new = match docgen_core::discover::discover_bases(&docs_dir) {
Ok(b) => b,
Err(_) => return Ok(None),
};
if bases_new != self.cap.base_inputs {
return Ok(None);
}
}
if prepared_new.len() != self.cap.prepared.len() {
return Ok(None);
}
let mut changed: Vec<usize> = Vec::new();
for (i, (new, old)) in prepared_new.iter().zip(&self.cap.prepared).enumerate() {
if new.slug != old.slug {
return Ok(None);
}
if new.title != old.title || new.description != old.description {
return Ok(None);
}
if self.cap.config.features.bases && new.frontmatter != old.frontmatter {
return Ok(None);
}
if new.body_md != old.body_md {
changed.push(i);
}
}
if self.cap.has_base_consumers && self.cap.config.features.bases && !changed.is_empty() {
return Ok(None);
}
if changed.is_empty() {
return Ok(Some(Rebuilt {
kind: RebuildKind::Incremental,
page_count: self.cap.docs.len() + self.cap.base_pages.len(),
}));
}
let slugs: SlugSet = self.cap.prepared.iter().map(|p| p.slug.clone()).collect();
let rerendered: Vec<(usize, docgen_core::pipeline::RenderedDoc)> = {
let plantuml_renderer = self.cap.plantuml_server.as_ref().map(|server| {
docgen_plantuml::HttpRenderer::new(
server.clone(),
self.project_root.join(".docgen"),
)
});
let plantuml_support =
plantuml_renderer
.as_ref()
.map(|r| docgen_core::plantuml::PlantumlSupport {
diagrams: &self.cap.diagrams,
renderer: Some(r as &dyn docgen_core::PlantumlRenderer),
});
let mut acc = Vec::with_capacity(changed.len());
for &i in &changed {
let rd = render_doc(
&prepared_new[i],
&self.cap.config,
&self.cap.registry,
&slugs,
&partials_new,
None,
plantuml_support.as_ref(),
self.cap.bases_corpus.as_ref(),
);
acc.push((i, rd));
}
acc
};
let mut outbound_new = self.cap.outbound.clone();
for (i, rd) in &rerendered {
outbound_new.insert(
self.cap.prepared[*i].slug.clone(),
rd.resolved_links.clone(),
);
}
let doc_meta: Vec<(String, String, Option<String>)> = self
.cap
.docs
.iter()
.map(|d| (d.slug.clone(), d.title.clone(), d.description.clone()))
.collect();
let graph_new = build_link_graph(&doc_meta, &outbound_new);
if graph_new.edges != self.cap.graph.edges
|| graph_new.backlinks != self.cap.graph.backlinks
{
return Ok(None);
}
let island_new = self.island_set_after(&rerendered);
if island_new != self.cap.island_components {
return Ok(None);
}
for (i, rd) in rerendered {
self.cap.search[i] = SearchEntry {
slug: self.cap.docs[i].slug.clone(),
title: self.cap.docs[i].title.clone(),
text: rd.search_text,
};
self.cap.docs[i] = rd.doc;
}
self.cap.outbound = outbound_new;
self.cap.prepared = prepared_new;
self.cap.partials = partials_new;
let (section_rows, recent_rows) = compute_home_rows(&self.cap.docs);
let home_sections: Vec<HomeSection> = section_rows
.iter()
.map(|(label, slug, count)| HomeSection {
label,
slug,
count: *count,
})
.collect();
let home_recent: Vec<HomeRecent> = recent_rows
.iter()
.map(|(title, slug, section)| HomeRecent {
title,
slug,
section,
})
.collect();
let shared = PageShared {
tree: &self.cap.tree,
graph: &self.cap.graph,
commit: &self.cap.commit_hash,
built: &self.cap.built_stamp,
base: &self.cap.config.base,
site_title: self.cap.config.title.as_deref().unwrap_or(""),
search_enabled: self.cap.config.features.search,
has_diff: self.cap.has_diff,
has_components_css: self.cap.has_components_css,
island_components: &self.cap.island_components,
graph_payload: &self.cap.graph_payload,
home_sections: &home_sections,
home_recent: &home_recent,
pages_count: self.cap.docs.len(),
total_links: self.cap.graph.edges.len(),
};
for &i in &changed {
let doc = &self.cap.docs[i];
let html = render_one_page(&self.renderer, &shared, doc)?;
let dir = self.out_dir.join(&doc.slug);
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("index.html"), &html)?;
if doc.slug == HOME_SLUG {
std::fs::write(self.out_dir.join("index.html"), &html)?;
}
}
if self.cap.config.features.search {
std::fs::write(
self.out_dir.join("search-index.json"),
docgen_core::search::index_json(&self.cap.search),
)?;
}
Ok(Some(Rebuilt {
kind: RebuildKind::Incremental,
page_count: self.cap.docs.len() + self.cap.base_pages.len(),
}))
}
fn island_set_after(
&self,
rerendered: &[(usize, docgen_core::pipeline::RenderedDoc)],
) -> BTreeSet<String> {
let islands: BTreeSet<&str> = self
.cap
.registry
.islands()
.iter()
.map(|c| c.name.as_str())
.collect();
let mut used: BTreeSet<String> = BTreeSet::new();
for (i, doc) in self.cap.docs.iter().enumerate() {
let components = rerendered
.iter()
.find(|(j, _)| *j == i)
.map(|(_, rd)| &rd.doc.components_used)
.unwrap_or(&doc.components_used);
for c in components {
if islands.contains(c.as_str()) {
used.insert(c.clone());
}
}
}
used
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn corpus(dir: &Path) {
let docs = dir.join("docs");
fs::create_dir_all(docs.join("guide")).unwrap();
fs::write(
docs.join("index.md"),
"# Home\n\nWelcome. See [[guide/a]].\n",
)
.unwrap();
fs::write(
docs.join("guide/a.md"),
"# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
)
.unwrap();
fs::write(
docs.join("guide/b.md"),
"# Beta\n\nBeta body. Link to [[guide/a]].\n",
)
.unwrap();
}
fn mask_built(html: &str, stamp: &str) -> String {
if stamp.is_empty() {
return html.to_string();
}
html.replace(stamp, "BUILT")
}
#[test]
fn incremental_body_edit_matches_full_rebuild_and_leaves_others_untouched() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
corpus(root);
let out = root.join("out");
let (mut state, first) = DevState::initial(root, &out).unwrap();
assert_eq!(first.kind, RebuildKind::Full);
let init_stamp = state.cap.built_stamp.clone();
let index_before = fs::read(out.join("index.html")).unwrap();
let b_before = fs::read(out.join("guide/b/index.html")).unwrap();
fs::write(
root.join("docs/guide/a.md"),
"# Alpha\n\nAlpha body REVISED with new prose. Link to [[guide/b]].\n",
)
.unwrap();
let r = state.rebuild().unwrap();
assert_eq!(
r.kind,
RebuildKind::Incremental,
"body-only edit must be incremental"
);
let a_incremental = fs::read_to_string(out.join("guide/a/index.html")).unwrap();
assert!(
a_incremental.contains("REVISED with new prose"),
"incremental page reflects the edit"
);
assert_eq!(
fs::read(out.join("index.html")).unwrap(),
index_before,
"home page must not be rewritten by a body edit elsewhere"
);
assert_eq!(
fs::read(out.join("guide/b/index.html")).unwrap(),
b_before,
"sibling page must not be rewritten"
);
let ref_out = root.join("ref");
let (_outcome, refcap) = build_site_inner(
&BuildOptions {
project_root: root,
out_dir: &ref_out,
mode: BuildMode::Dev,
},
true,
)
.unwrap();
let refcap = refcap.unwrap();
let a_full = fs::read_to_string(ref_out.join("guide/a/index.html")).unwrap();
assert_eq!(
mask_built(&a_incremental, &init_stamp),
mask_built(&a_full, &refcap.built_stamp),
"incremental page is byte-identical to a full rebuild's page"
);
}
#[test]
fn title_change_falls_back_to_full() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
corpus(root);
let out = root.join("out");
let (mut state, _) = DevState::initial(root, &out).unwrap();
fs::write(
root.join("docs/guide/a.md"),
"# Alpha Renamed\n\nAlpha body. Link to [[guide/b]].\n",
)
.unwrap();
assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
}
#[test]
fn adding_a_link_falls_back_to_full() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
corpus(root);
let out = root.join("out");
let (mut state, _) = DevState::initial(root, &out).unwrap();
fs::write(
root.join("docs/guide/a.md"),
"# Alpha\n\nAlpha body. Link to [[guide/b]] and now [[index]].\n",
)
.unwrap();
assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
}
#[test]
fn adding_a_new_doc_falls_back_to_full() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
corpus(root);
let out = root.join("out");
let (mut state, _) = DevState::initial(root, &out).unwrap();
fs::write(root.join("docs/guide/c.md"), "# Gamma\n\nNew page.\n").unwrap();
assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
}
#[test]
fn body_edit_forces_full_rebuild_when_a_base_consumer_exists() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let docs = root.join("docs");
fs::create_dir_all(docs.join("guide")).unwrap();
fs::write(docs.join("index.md"), "# Home\n").unwrap();
fs::write(docs.join("guide/a.md"), "# Alpha\n\nBody with #tag.\n").unwrap();
fs::write(docs.join("all.base"), "views:\n - type: table\n").unwrap();
let out = root.join("out");
let (mut state, first) = DevState::initial(root, &out).unwrap();
assert_eq!(first.kind, RebuildKind::Full);
assert!(state.cap.has_base_consumers);
fs::write(
root.join("docs/guide/a.md"),
"# Alpha\n\nBody with #different.\n",
)
.unwrap();
assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
}
#[test]
fn no_op_change_is_incremental() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
corpus(root);
let out = root.join("out");
let (mut state, _) = DevState::initial(root, &out).unwrap();
fs::write(
root.join("docs/guide/a.md"),
"# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
)
.unwrap();
assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Incremental);
}
}