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