1use 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
36pub(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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum RebuildKind {
63 Full,
64 Incremental,
65}
66
67#[derive(Debug, Clone)]
69pub struct Rebuilt {
70 pub kind: RebuildKind,
71 pub page_count: usize,
72}
73
74pub struct DevState {
78 project_root: PathBuf,
79 out_dir: PathBuf,
80 renderer: Renderer,
81 cap: CapturedBuild,
82}
83
84impl DevState {
85 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 pub fn rebuild(&mut self) -> Result<Rebuilt> {
115 match self.try_incremental()? {
116 Some(rebuilt) => Ok(rebuilt),
117 None => self.full(),
118 }
119 }
120
121 fn full(&mut self) -> Result<Rebuilt> {
123 let (outcome, cap) = build_site_inner(
124 &BuildOptions {
125 project_root: &self.project_root,
126 out_dir: &self.out_dir,
127 mode: BuildMode::Dev,
128 },
129 true,
130 )?;
131 self.cap = cap.expect("capture requested → CapturedBuild present");
132 Ok(Rebuilt {
133 kind: RebuildKind::Full,
134 page_count: outcome.page_count,
135 })
136 }
137
138 fn try_incremental(&mut self) -> Result<Option<Rebuilt>> {
142 let docs_dir = self.project_root.join("docs");
143 let raws = match docgen_core::discover::discover_docs(&docs_dir) {
144 Ok(r) => r,
145 Err(_) => return Ok(None),
147 };
148 let (pages, partials_new) = partition_partials(raws);
149 let prepared_new: Vec<PreparedDoc> = pages.into_iter().map(prepare).collect();
150
151 if partials_new != self.cap.partials {
154 return Ok(None);
155 }
156 if prepared_new.len() != self.cap.prepared.len() {
159 return Ok(None);
160 }
161 let mut changed: Vec<usize> = Vec::new();
162 for (i, (new, old)) in prepared_new.iter().zip(&self.cap.prepared).enumerate() {
163 if new.slug != old.slug {
164 return Ok(None);
165 }
166 if new.title != old.title || new.description != old.description {
169 return Ok(None);
170 }
171 if new.body_md != old.body_md {
172 changed.push(i);
173 }
174 }
175
176 if changed.is_empty() {
180 return Ok(Some(Rebuilt {
181 kind: RebuildKind::Incremental,
182 page_count: self.cap.docs.len(),
183 }));
184 }
185
186 let slugs: SlugSet = self.cap.prepared.iter().map(|p| p.slug.clone()).collect();
188 let mut rerendered: Vec<(usize, docgen_core::pipeline::RenderedDoc)> =
189 Vec::with_capacity(changed.len());
190 for &i in &changed {
191 let rd = render_doc(
192 &prepared_new[i],
193 &self.cap.config,
194 &self.cap.registry,
195 &slugs,
196 &partials_new,
197 );
198 rerendered.push((i, rd));
199 }
200
201 let mut outbound_new = self.cap.outbound.clone();
205 for (i, rd) in &rerendered {
206 outbound_new.insert(
207 self.cap.prepared[*i].slug.clone(),
208 rd.resolved_links.clone(),
209 );
210 }
211 let doc_meta: Vec<(String, String, Option<String>)> = self
212 .cap
213 .docs
214 .iter()
215 .map(|d| (d.slug.clone(), d.title.clone(), d.description.clone()))
216 .collect();
217 let graph_new = build_link_graph(&doc_meta, &outbound_new);
218 if graph_new.edges != self.cap.graph.edges
219 || graph_new.backlinks != self.cap.graph.backlinks
220 {
221 return Ok(None);
222 }
223
224 let island_new = self.island_set_after(&rerendered);
229 if island_new != self.cap.island_components {
230 return Ok(None);
231 }
232
233 for (i, rd) in rerendered {
236 self.cap.search[i] = SearchEntry {
237 slug: self.cap.docs[i].slug.clone(),
238 title: self.cap.docs[i].title.clone(),
239 text: rd.search_text,
240 };
241 self.cap.docs[i] = rd.doc;
242 }
243 self.cap.outbound = outbound_new;
244 self.cap.prepared = prepared_new;
245 self.cap.partials = partials_new;
246
247 let (section_rows, recent_rows) = compute_home_rows(&self.cap.docs);
250 let home_sections: Vec<HomeSection> = section_rows
251 .iter()
252 .map(|(label, slug, count)| HomeSection {
253 label,
254 slug,
255 count: *count,
256 })
257 .collect();
258 let home_recent: Vec<HomeRecent> = recent_rows
259 .iter()
260 .map(|(title, slug, section)| HomeRecent {
261 title,
262 slug,
263 section,
264 })
265 .collect();
266 let shared = PageShared {
267 tree: &self.cap.tree,
268 graph: &self.cap.graph,
269 commit: &self.cap.commit_hash,
270 built: &self.cap.built_stamp,
271 base: &self.cap.config.base,
272 site_title: self.cap.config.title.as_deref().unwrap_or(""),
273 search_enabled: self.cap.config.features.search,
274 has_diff: self.cap.has_diff,
275 has_components_css: self.cap.has_components_css,
276 island_components: &self.cap.island_components,
277 graph_payload: &self.cap.graph_payload,
278 home_sections: &home_sections,
279 home_recent: &home_recent,
280 pages_count: self.cap.docs.len(),
281 total_links: self.cap.graph.edges.len(),
282 };
283
284 for &i in &changed {
285 let doc = &self.cap.docs[i];
286 let html = render_one_page(&self.renderer, &shared, doc)?;
287 let dir = self.out_dir.join(&doc.slug);
288 std::fs::create_dir_all(&dir)?;
289 std::fs::write(dir.join("index.html"), &html)?;
290 if doc.slug == HOME_SLUG {
292 std::fs::write(self.out_dir.join("index.html"), &html)?;
293 }
294 }
295
296 if self.cap.config.features.search {
299 std::fs::write(
300 self.out_dir.join("search-index.json"),
301 docgen_core::search::index_json(&self.cap.search),
302 )?;
303 }
304
305 Ok(Some(Rebuilt {
306 kind: RebuildKind::Incremental,
307 page_count: self.cap.docs.len(),
308 }))
309 }
310
311 fn island_set_after(
315 &self,
316 rerendered: &[(usize, docgen_core::pipeline::RenderedDoc)],
317 ) -> BTreeSet<String> {
318 let islands: BTreeSet<&str> = self
319 .cap
320 .registry
321 .islands()
322 .iter()
323 .map(|c| c.name.as_str())
324 .collect();
325 let mut used: BTreeSet<String> = BTreeSet::new();
326 for (i, doc) in self.cap.docs.iter().enumerate() {
327 let components = rerendered
329 .iter()
330 .find(|(j, _)| *j == i)
331 .map(|(_, rd)| &rd.doc.components_used)
332 .unwrap_or(&doc.components_used);
333 for c in components {
334 if islands.contains(c.as_str()) {
335 used.insert(c.clone());
336 }
337 }
338 }
339 used
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use std::fs;
347
348 fn corpus(dir: &Path) {
350 let docs = dir.join("docs");
351 fs::create_dir_all(docs.join("guide")).unwrap();
352 fs::write(
353 docs.join("index.md"),
354 "# Home\n\nWelcome. See [[guide/a]].\n",
355 )
356 .unwrap();
357 fs::write(
358 docs.join("guide/a.md"),
359 "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
360 )
361 .unwrap();
362 fs::write(
363 docs.join("guide/b.md"),
364 "# Beta\n\nBeta body. Link to [[guide/a]].\n",
365 )
366 .unwrap();
367 }
368
369 fn mask_built(html: &str, stamp: &str) -> String {
372 if stamp.is_empty() {
373 return html.to_string();
374 }
375 html.replace(stamp, "BUILT")
376 }
377
378 #[test]
379 fn incremental_body_edit_matches_full_rebuild_and_leaves_others_untouched() {
380 let tmp = tempfile::tempdir().unwrap();
381 let root = tmp.path();
382 corpus(root);
383 let out = root.join("out");
384
385 let (mut state, first) = DevState::initial(root, &out).unwrap();
386 assert_eq!(first.kind, RebuildKind::Full);
387 let init_stamp = state.cap.built_stamp.clone();
388
389 let index_before = fs::read(out.join("index.html")).unwrap();
391 let b_before = fs::read(out.join("guide/b/index.html")).unwrap();
392
393 fs::write(
395 root.join("docs/guide/a.md"),
396 "# Alpha\n\nAlpha body REVISED with new prose. Link to [[guide/b]].\n",
397 )
398 .unwrap();
399
400 let r = state.rebuild().unwrap();
401 assert_eq!(
402 r.kind,
403 RebuildKind::Incremental,
404 "body-only edit must be incremental"
405 );
406
407 let a_incremental = fs::read_to_string(out.join("guide/a/index.html")).unwrap();
408 assert!(
409 a_incremental.contains("REVISED with new prose"),
410 "incremental page reflects the edit"
411 );
412
413 assert_eq!(
415 fs::read(out.join("index.html")).unwrap(),
416 index_before,
417 "home page must not be rewritten by a body edit elsewhere"
418 );
419 assert_eq!(
420 fs::read(out.join("guide/b/index.html")).unwrap(),
421 b_before,
422 "sibling page must not be rewritten"
423 );
424
425 let ref_out = root.join("ref");
428 let (_outcome, refcap) = build_site_inner(
429 &BuildOptions {
430 project_root: root,
431 out_dir: &ref_out,
432 mode: BuildMode::Dev,
433 },
434 true,
435 )
436 .unwrap();
437 let refcap = refcap.unwrap();
438 let a_full = fs::read_to_string(ref_out.join("guide/a/index.html")).unwrap();
439 assert_eq!(
440 mask_built(&a_incremental, &init_stamp),
441 mask_built(&a_full, &refcap.built_stamp),
442 "incremental page is byte-identical to a full rebuild's page"
443 );
444 }
445
446 #[test]
447 fn title_change_falls_back_to_full() {
448 let tmp = tempfile::tempdir().unwrap();
449 let root = tmp.path();
450 corpus(root);
451 let out = root.join("out");
452 let (mut state, _) = DevState::initial(root, &out).unwrap();
453
454 fs::write(
456 root.join("docs/guide/a.md"),
457 "# Alpha Renamed\n\nAlpha body. Link to [[guide/b]].\n",
458 )
459 .unwrap();
460 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
461 }
462
463 #[test]
464 fn adding_a_link_falls_back_to_full() {
465 let tmp = tempfile::tempdir().unwrap();
466 let root = tmp.path();
467 corpus(root);
468 let out = root.join("out");
469 let (mut state, _) = DevState::initial(root, &out).unwrap();
470
471 fs::write(
473 root.join("docs/guide/a.md"),
474 "# Alpha\n\nAlpha body. Link to [[guide/b]] and now [[index]].\n",
475 )
476 .unwrap();
477 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
478 }
479
480 #[test]
481 fn adding_a_new_doc_falls_back_to_full() {
482 let tmp = tempfile::tempdir().unwrap();
483 let root = tmp.path();
484 corpus(root);
485 let out = root.join("out");
486 let (mut state, _) = DevState::initial(root, &out).unwrap();
487
488 fs::write(root.join("docs/guide/c.md"), "# Gamma\n\nNew page.\n").unwrap();
489 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
490 }
491
492 #[test]
493 fn no_op_change_is_incremental() {
494 let tmp = tempfile::tempdir().unwrap();
495 let root = tmp.path();
496 corpus(root);
497 let out = root.join("out");
498 let (mut state, _) = DevState::initial(root, &out).unwrap();
499
500 fs::write(
502 root.join("docs/guide/a.md"),
503 "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
504 )
505 .unwrap();
506 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Incremental);
507 }
508}