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 diagrams: docgen_core::pipeline::Diagrams,
46 pub plantuml_server: Option<String>,
49 pub bases_corpus: Option<docgen_bases::Corpus>,
54 pub base_inputs: Vec<docgen_core::discover::BaseFileInput>,
57 pub base_pages: Vec<Doc>,
60 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum RebuildKind {
85 Full,
86 Incremental,
87}
88
89#[derive(Debug, Clone)]
91pub struct Rebuilt {
92 pub kind: RebuildKind,
93 pub page_count: usize,
94}
95
96pub struct DevState {
100 project_root: PathBuf,
101 out_dir: PathBuf,
102 renderer: Renderer,
103 cap: CapturedBuild,
104}
105
106impl DevState {
107 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 pub fn rebuild(&mut self) -> Result<Rebuilt> {
137 match self.try_incremental()? {
138 Some(rebuilt) => {
139 crate::copy_assets(&self.project_root.join("docs"), &self.out_dir)?;
146 Ok(rebuilt)
147 }
148 None => self.full(),
149 }
150 }
151
152 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 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 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 if partials_new != self.cap.partials {
185 return Ok(None);
186 }
187 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 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 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 if new.title != old.title || new.description != old.description {
221 return Ok(None);
222 }
223 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 if self.cap.has_base_consumers && self.cap.config.features.bases && !changed.is_empty() {
240 return Ok(None);
241 }
242
243 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 let slugs: SlugSet = self.cap.prepared.iter().map(|p| p.slug.clone()).collect();
258 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 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 let island_new = self.island_set_after(&rerendered);
319 if island_new != self.cap.island_components {
320 return Ok(None);
321 }
322
323 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 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 if doc.slug == HOME_SLUG {
382 std::fs::write(self.out_dir.join("index.html"), &html)?;
383 }
384 }
385
386 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 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 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 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 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 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 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 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 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 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 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 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 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 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}