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) => {
117 crate::copy_assets(&self.project_root.join("docs"), &self.out_dir)?;
124 Ok(rebuilt)
125 }
126 None => self.full(),
127 }
128 }
129
130 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 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 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 if partials_new != self.cap.partials {
163 return Ok(None);
164 }
165 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 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 if changed.is_empty() {
189 return Ok(Some(Rebuilt {
190 kind: RebuildKind::Incremental,
191 page_count: self.cap.docs.len(),
192 }));
193 }
194
195 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 None,
207 );
208 rerendered.push((i, rd));
209 }
210
211 let mut outbound_new = self.cap.outbound.clone();
215 for (i, rd) in &rerendered {
216 outbound_new.insert(
217 self.cap.prepared[*i].slug.clone(),
218 rd.resolved_links.clone(),
219 );
220 }
221 let doc_meta: Vec<(String, String, Option<String>)> = self
222 .cap
223 .docs
224 .iter()
225 .map(|d| (d.slug.clone(), d.title.clone(), d.description.clone()))
226 .collect();
227 let graph_new = build_link_graph(&doc_meta, &outbound_new);
228 if graph_new.edges != self.cap.graph.edges
229 || graph_new.backlinks != self.cap.graph.backlinks
230 {
231 return Ok(None);
232 }
233
234 let island_new = self.island_set_after(&rerendered);
239 if island_new != self.cap.island_components {
240 return Ok(None);
241 }
242
243 for (i, rd) in rerendered {
246 self.cap.search[i] = SearchEntry {
247 slug: self.cap.docs[i].slug.clone(),
248 title: self.cap.docs[i].title.clone(),
249 text: rd.search_text,
250 };
251 self.cap.docs[i] = rd.doc;
252 }
253 self.cap.outbound = outbound_new;
254 self.cap.prepared = prepared_new;
255 self.cap.partials = partials_new;
256
257 let (section_rows, recent_rows) = compute_home_rows(&self.cap.docs);
260 let home_sections: Vec<HomeSection> = section_rows
261 .iter()
262 .map(|(label, slug, count)| HomeSection {
263 label,
264 slug,
265 count: *count,
266 })
267 .collect();
268 let home_recent: Vec<HomeRecent> = recent_rows
269 .iter()
270 .map(|(title, slug, section)| HomeRecent {
271 title,
272 slug,
273 section,
274 })
275 .collect();
276 let shared = PageShared {
277 tree: &self.cap.tree,
278 graph: &self.cap.graph,
279 commit: &self.cap.commit_hash,
280 built: &self.cap.built_stamp,
281 base: &self.cap.config.base,
282 site_title: self.cap.config.title.as_deref().unwrap_or(""),
283 search_enabled: self.cap.config.features.search,
284 has_diff: self.cap.has_diff,
285 has_components_css: self.cap.has_components_css,
286 island_components: &self.cap.island_components,
287 graph_payload: &self.cap.graph_payload,
288 home_sections: &home_sections,
289 home_recent: &home_recent,
290 pages_count: self.cap.docs.len(),
291 total_links: self.cap.graph.edges.len(),
292 };
293
294 for &i in &changed {
295 let doc = &self.cap.docs[i];
296 let html = render_one_page(&self.renderer, &shared, doc)?;
297 let dir = self.out_dir.join(&doc.slug);
298 std::fs::create_dir_all(&dir)?;
299 std::fs::write(dir.join("index.html"), &html)?;
300 if doc.slug == HOME_SLUG {
302 std::fs::write(self.out_dir.join("index.html"), &html)?;
303 }
304 }
305
306 if self.cap.config.features.search {
309 std::fs::write(
310 self.out_dir.join("search-index.json"),
311 docgen_core::search::index_json(&self.cap.search),
312 )?;
313 }
314
315 Ok(Some(Rebuilt {
316 kind: RebuildKind::Incremental,
317 page_count: self.cap.docs.len(),
318 }))
319 }
320
321 fn island_set_after(
325 &self,
326 rerendered: &[(usize, docgen_core::pipeline::RenderedDoc)],
327 ) -> BTreeSet<String> {
328 let islands: BTreeSet<&str> = self
329 .cap
330 .registry
331 .islands()
332 .iter()
333 .map(|c| c.name.as_str())
334 .collect();
335 let mut used: BTreeSet<String> = BTreeSet::new();
336 for (i, doc) in self.cap.docs.iter().enumerate() {
337 let components = rerendered
339 .iter()
340 .find(|(j, _)| *j == i)
341 .map(|(_, rd)| &rd.doc.components_used)
342 .unwrap_or(&doc.components_used);
343 for c in components {
344 if islands.contains(c.as_str()) {
345 used.insert(c.clone());
346 }
347 }
348 }
349 used
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356 use std::fs;
357
358 fn corpus(dir: &Path) {
360 let docs = dir.join("docs");
361 fs::create_dir_all(docs.join("guide")).unwrap();
362 fs::write(
363 docs.join("index.md"),
364 "# Home\n\nWelcome. See [[guide/a]].\n",
365 )
366 .unwrap();
367 fs::write(
368 docs.join("guide/a.md"),
369 "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
370 )
371 .unwrap();
372 fs::write(
373 docs.join("guide/b.md"),
374 "# Beta\n\nBeta body. Link to [[guide/a]].\n",
375 )
376 .unwrap();
377 }
378
379 fn mask_built(html: &str, stamp: &str) -> String {
382 if stamp.is_empty() {
383 return html.to_string();
384 }
385 html.replace(stamp, "BUILT")
386 }
387
388 #[test]
389 fn incremental_body_edit_matches_full_rebuild_and_leaves_others_untouched() {
390 let tmp = tempfile::tempdir().unwrap();
391 let root = tmp.path();
392 corpus(root);
393 let out = root.join("out");
394
395 let (mut state, first) = DevState::initial(root, &out).unwrap();
396 assert_eq!(first.kind, RebuildKind::Full);
397 let init_stamp = state.cap.built_stamp.clone();
398
399 let index_before = fs::read(out.join("index.html")).unwrap();
401 let b_before = fs::read(out.join("guide/b/index.html")).unwrap();
402
403 fs::write(
405 root.join("docs/guide/a.md"),
406 "# Alpha\n\nAlpha body REVISED with new prose. Link to [[guide/b]].\n",
407 )
408 .unwrap();
409
410 let r = state.rebuild().unwrap();
411 assert_eq!(
412 r.kind,
413 RebuildKind::Incremental,
414 "body-only edit must be incremental"
415 );
416
417 let a_incremental = fs::read_to_string(out.join("guide/a/index.html")).unwrap();
418 assert!(
419 a_incremental.contains("REVISED with new prose"),
420 "incremental page reflects the edit"
421 );
422
423 assert_eq!(
425 fs::read(out.join("index.html")).unwrap(),
426 index_before,
427 "home page must not be rewritten by a body edit elsewhere"
428 );
429 assert_eq!(
430 fs::read(out.join("guide/b/index.html")).unwrap(),
431 b_before,
432 "sibling page must not be rewritten"
433 );
434
435 let ref_out = root.join("ref");
438 let (_outcome, refcap) = build_site_inner(
439 &BuildOptions {
440 project_root: root,
441 out_dir: &ref_out,
442 mode: BuildMode::Dev,
443 },
444 true,
445 )
446 .unwrap();
447 let refcap = refcap.unwrap();
448 let a_full = fs::read_to_string(ref_out.join("guide/a/index.html")).unwrap();
449 assert_eq!(
450 mask_built(&a_incremental, &init_stamp),
451 mask_built(&a_full, &refcap.built_stamp),
452 "incremental page is byte-identical to a full rebuild's page"
453 );
454 }
455
456 #[test]
457 fn title_change_falls_back_to_full() {
458 let tmp = tempfile::tempdir().unwrap();
459 let root = tmp.path();
460 corpus(root);
461 let out = root.join("out");
462 let (mut state, _) = DevState::initial(root, &out).unwrap();
463
464 fs::write(
466 root.join("docs/guide/a.md"),
467 "# Alpha Renamed\n\nAlpha body. Link to [[guide/b]].\n",
468 )
469 .unwrap();
470 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
471 }
472
473 #[test]
474 fn adding_a_link_falls_back_to_full() {
475 let tmp = tempfile::tempdir().unwrap();
476 let root = tmp.path();
477 corpus(root);
478 let out = root.join("out");
479 let (mut state, _) = DevState::initial(root, &out).unwrap();
480
481 fs::write(
483 root.join("docs/guide/a.md"),
484 "# Alpha\n\nAlpha body. Link to [[guide/b]] and now [[index]].\n",
485 )
486 .unwrap();
487 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
488 }
489
490 #[test]
491 fn adding_a_new_doc_falls_back_to_full() {
492 let tmp = tempfile::tempdir().unwrap();
493 let root = tmp.path();
494 corpus(root);
495 let out = root.join("out");
496 let (mut state, _) = DevState::initial(root, &out).unwrap();
497
498 fs::write(root.join("docs/guide/c.md"), "# Gamma\n\nNew page.\n").unwrap();
499 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
500 }
501
502 #[test]
503 fn no_op_change_is_incremental() {
504 let tmp = tempfile::tempdir().unwrap();
505 let root = tmp.path();
506 corpus(root);
507 let out = root.join("out");
508 let (mut state, _) = DevState::initial(root, &out).unwrap();
509
510 fs::write(
512 root.join("docs/guide/a.md"),
513 "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
514 )
515 .unwrap();
516 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Incremental);
517 }
518}