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 prepared: Vec<PreparedDoc>,
50 pub docs: Vec<Doc>,
51 pub outbound: BTreeMap<String, Vec<String>>,
52 pub graph: LinkGraph,
53 pub tree: Vec<TreeNode>,
54 pub graph_payload: Option<(String, usize, usize)>,
55 pub island_components: BTreeSet<String>,
56 pub has_components_css: bool,
57 pub commit_hash: String,
58 pub built_stamp: String,
59 pub has_diff: bool,
60 pub search: Vec<SearchEntry>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum RebuildKind {
69 Full,
70 Incremental,
71}
72
73#[derive(Debug, Clone)]
75pub struct Rebuilt {
76 pub kind: RebuildKind,
77 pub page_count: usize,
78}
79
80pub struct DevState {
84 project_root: PathBuf,
85 out_dir: PathBuf,
86 renderer: Renderer,
87 cap: CapturedBuild,
88}
89
90impl DevState {
91 pub fn initial(project_root: &Path, out_dir: &Path) -> Result<(Self, Rebuilt)> {
93 let (outcome, cap) = build_site_inner(
94 &BuildOptions {
95 project_root,
96 out_dir,
97 mode: BuildMode::Dev,
98 },
99 true,
100 )?;
101 let cap = cap.expect("capture requested → CapturedBuild present");
102 let renderer = Renderer::new(DEFAULT_PAGE_TEMPLATE)?;
103 Ok((
104 Self {
105 project_root: project_root.to_path_buf(),
106 out_dir: out_dir.to_path_buf(),
107 renderer,
108 cap,
109 },
110 Rebuilt {
111 kind: RebuildKind::Full,
112 page_count: outcome.page_count,
113 },
114 ))
115 }
116
117 pub fn rebuild(&mut self) -> Result<Rebuilt> {
121 match self.try_incremental()? {
122 Some(rebuilt) => {
123 crate::copy_assets(&self.project_root.join("docs"), &self.out_dir)?;
130 Ok(rebuilt)
131 }
132 None => self.full(),
133 }
134 }
135
136 fn full(&mut self) -> Result<Rebuilt> {
138 let (outcome, cap) = build_site_inner(
139 &BuildOptions {
140 project_root: &self.project_root,
141 out_dir: &self.out_dir,
142 mode: BuildMode::Dev,
143 },
144 true,
145 )?;
146 self.cap = cap.expect("capture requested → CapturedBuild present");
147 Ok(Rebuilt {
148 kind: RebuildKind::Full,
149 page_count: outcome.page_count,
150 })
151 }
152
153 fn try_incremental(&mut self) -> Result<Option<Rebuilt>> {
157 let docs_dir = self.project_root.join("docs");
158 let raws = match docgen_core::discover::discover_docs(&docs_dir) {
159 Ok(r) => r,
160 Err(_) => return Ok(None),
162 };
163 let (pages, partials_new) = partition_partials(raws);
164 let prepared_new: Vec<PreparedDoc> = pages.into_iter().map(prepare).collect();
165
166 if partials_new != self.cap.partials {
169 return Ok(None);
170 }
171 let diagrams_new = match docgen_core::discover::discover_diagrams(&docs_dir) {
175 Ok(d) => d,
176 Err(_) => return Ok(None),
177 };
178 if diagrams_new != self.cap.diagrams {
179 return Ok(None);
180 }
181 if prepared_new.len() != self.cap.prepared.len() {
184 return Ok(None);
185 }
186 let mut changed: Vec<usize> = Vec::new();
187 for (i, (new, old)) in prepared_new.iter().zip(&self.cap.prepared).enumerate() {
188 if new.slug != old.slug {
189 return Ok(None);
190 }
191 if new.title != old.title || new.description != old.description {
194 return Ok(None);
195 }
196 if new.body_md != old.body_md {
197 changed.push(i);
198 }
199 }
200
201 if changed.is_empty() {
205 return Ok(Some(Rebuilt {
206 kind: RebuildKind::Incremental,
207 page_count: self.cap.docs.len(),
208 }));
209 }
210
211 let slugs: SlugSet = self.cap.prepared.iter().map(|p| p.slug.clone()).collect();
216 let rerendered: Vec<(usize, docgen_core::pipeline::RenderedDoc)> = {
219 let plantuml_renderer = self.cap.plantuml_server.as_ref().map(|server| {
220 docgen_plantuml::HttpRenderer::new(
221 server.clone(),
222 self.project_root.join(".docgen"),
223 )
224 });
225 let plantuml_support =
226 plantuml_renderer
227 .as_ref()
228 .map(|r| docgen_core::plantuml::PlantumlSupport {
229 diagrams: &self.cap.diagrams,
230 renderer: Some(r as &dyn docgen_core::PlantumlRenderer),
231 });
232 let mut acc = Vec::with_capacity(changed.len());
233 for &i in &changed {
234 let rd = render_doc(
235 &prepared_new[i],
236 &self.cap.config,
237 &self.cap.registry,
238 &slugs,
239 &partials_new,
240 None,
241 plantuml_support.as_ref(),
242 );
243 acc.push((i, rd));
244 }
245 acc
246 };
247
248 let mut outbound_new = self.cap.outbound.clone();
252 for (i, rd) in &rerendered {
253 outbound_new.insert(
254 self.cap.prepared[*i].slug.clone(),
255 rd.resolved_links.clone(),
256 );
257 }
258 let doc_meta: Vec<(String, String, Option<String>)> = self
259 .cap
260 .docs
261 .iter()
262 .map(|d| (d.slug.clone(), d.title.clone(), d.description.clone()))
263 .collect();
264 let graph_new = build_link_graph(&doc_meta, &outbound_new);
265 if graph_new.edges != self.cap.graph.edges
266 || graph_new.backlinks != self.cap.graph.backlinks
267 {
268 return Ok(None);
269 }
270
271 let island_new = self.island_set_after(&rerendered);
276 if island_new != self.cap.island_components {
277 return Ok(None);
278 }
279
280 for (i, rd) in rerendered {
283 self.cap.search[i] = SearchEntry {
284 slug: self.cap.docs[i].slug.clone(),
285 title: self.cap.docs[i].title.clone(),
286 text: rd.search_text,
287 };
288 self.cap.docs[i] = rd.doc;
289 }
290 self.cap.outbound = outbound_new;
291 self.cap.prepared = prepared_new;
292 self.cap.partials = partials_new;
293
294 let (section_rows, recent_rows) = compute_home_rows(&self.cap.docs);
297 let home_sections: Vec<HomeSection> = section_rows
298 .iter()
299 .map(|(label, slug, count)| HomeSection {
300 label,
301 slug,
302 count: *count,
303 })
304 .collect();
305 let home_recent: Vec<HomeRecent> = recent_rows
306 .iter()
307 .map(|(title, slug, section)| HomeRecent {
308 title,
309 slug,
310 section,
311 })
312 .collect();
313 let shared = PageShared {
314 tree: &self.cap.tree,
315 graph: &self.cap.graph,
316 commit: &self.cap.commit_hash,
317 built: &self.cap.built_stamp,
318 base: &self.cap.config.base,
319 site_title: self.cap.config.title.as_deref().unwrap_or(""),
320 search_enabled: self.cap.config.features.search,
321 has_diff: self.cap.has_diff,
322 has_components_css: self.cap.has_components_css,
323 island_components: &self.cap.island_components,
324 graph_payload: &self.cap.graph_payload,
325 home_sections: &home_sections,
326 home_recent: &home_recent,
327 pages_count: self.cap.docs.len(),
328 total_links: self.cap.graph.edges.len(),
329 };
330
331 for &i in &changed {
332 let doc = &self.cap.docs[i];
333 let html = render_one_page(&self.renderer, &shared, doc)?;
334 let dir = self.out_dir.join(&doc.slug);
335 std::fs::create_dir_all(&dir)?;
336 std::fs::write(dir.join("index.html"), &html)?;
337 if doc.slug == HOME_SLUG {
339 std::fs::write(self.out_dir.join("index.html"), &html)?;
340 }
341 }
342
343 if self.cap.config.features.search {
346 std::fs::write(
347 self.out_dir.join("search-index.json"),
348 docgen_core::search::index_json(&self.cap.search),
349 )?;
350 }
351
352 Ok(Some(Rebuilt {
353 kind: RebuildKind::Incremental,
354 page_count: self.cap.docs.len(),
355 }))
356 }
357
358 fn island_set_after(
362 &self,
363 rerendered: &[(usize, docgen_core::pipeline::RenderedDoc)],
364 ) -> BTreeSet<String> {
365 let islands: BTreeSet<&str> = self
366 .cap
367 .registry
368 .islands()
369 .iter()
370 .map(|c| c.name.as_str())
371 .collect();
372 let mut used: BTreeSet<String> = BTreeSet::new();
373 for (i, doc) in self.cap.docs.iter().enumerate() {
374 let components = rerendered
376 .iter()
377 .find(|(j, _)| *j == i)
378 .map(|(_, rd)| &rd.doc.components_used)
379 .unwrap_or(&doc.components_used);
380 for c in components {
381 if islands.contains(c.as_str()) {
382 used.insert(c.clone());
383 }
384 }
385 }
386 used
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393 use std::fs;
394
395 fn corpus(dir: &Path) {
397 let docs = dir.join("docs");
398 fs::create_dir_all(docs.join("guide")).unwrap();
399 fs::write(
400 docs.join("index.md"),
401 "# Home\n\nWelcome. See [[guide/a]].\n",
402 )
403 .unwrap();
404 fs::write(
405 docs.join("guide/a.md"),
406 "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
407 )
408 .unwrap();
409 fs::write(
410 docs.join("guide/b.md"),
411 "# Beta\n\nBeta body. Link to [[guide/a]].\n",
412 )
413 .unwrap();
414 }
415
416 fn mask_built(html: &str, stamp: &str) -> String {
419 if stamp.is_empty() {
420 return html.to_string();
421 }
422 html.replace(stamp, "BUILT")
423 }
424
425 #[test]
426 fn incremental_body_edit_matches_full_rebuild_and_leaves_others_untouched() {
427 let tmp = tempfile::tempdir().unwrap();
428 let root = tmp.path();
429 corpus(root);
430 let out = root.join("out");
431
432 let (mut state, first) = DevState::initial(root, &out).unwrap();
433 assert_eq!(first.kind, RebuildKind::Full);
434 let init_stamp = state.cap.built_stamp.clone();
435
436 let index_before = fs::read(out.join("index.html")).unwrap();
438 let b_before = fs::read(out.join("guide/b/index.html")).unwrap();
439
440 fs::write(
442 root.join("docs/guide/a.md"),
443 "# Alpha\n\nAlpha body REVISED with new prose. Link to [[guide/b]].\n",
444 )
445 .unwrap();
446
447 let r = state.rebuild().unwrap();
448 assert_eq!(
449 r.kind,
450 RebuildKind::Incremental,
451 "body-only edit must be incremental"
452 );
453
454 let a_incremental = fs::read_to_string(out.join("guide/a/index.html")).unwrap();
455 assert!(
456 a_incremental.contains("REVISED with new prose"),
457 "incremental page reflects the edit"
458 );
459
460 assert_eq!(
462 fs::read(out.join("index.html")).unwrap(),
463 index_before,
464 "home page must not be rewritten by a body edit elsewhere"
465 );
466 assert_eq!(
467 fs::read(out.join("guide/b/index.html")).unwrap(),
468 b_before,
469 "sibling page must not be rewritten"
470 );
471
472 let ref_out = root.join("ref");
475 let (_outcome, refcap) = build_site_inner(
476 &BuildOptions {
477 project_root: root,
478 out_dir: &ref_out,
479 mode: BuildMode::Dev,
480 },
481 true,
482 )
483 .unwrap();
484 let refcap = refcap.unwrap();
485 let a_full = fs::read_to_string(ref_out.join("guide/a/index.html")).unwrap();
486 assert_eq!(
487 mask_built(&a_incremental, &init_stamp),
488 mask_built(&a_full, &refcap.built_stamp),
489 "incremental page is byte-identical to a full rebuild's page"
490 );
491 }
492
493 #[test]
494 fn title_change_falls_back_to_full() {
495 let tmp = tempfile::tempdir().unwrap();
496 let root = tmp.path();
497 corpus(root);
498 let out = root.join("out");
499 let (mut state, _) = DevState::initial(root, &out).unwrap();
500
501 fs::write(
503 root.join("docs/guide/a.md"),
504 "# Alpha Renamed\n\nAlpha body. Link to [[guide/b]].\n",
505 )
506 .unwrap();
507 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
508 }
509
510 #[test]
511 fn adding_a_link_falls_back_to_full() {
512 let tmp = tempfile::tempdir().unwrap();
513 let root = tmp.path();
514 corpus(root);
515 let out = root.join("out");
516 let (mut state, _) = DevState::initial(root, &out).unwrap();
517
518 fs::write(
520 root.join("docs/guide/a.md"),
521 "# Alpha\n\nAlpha body. Link to [[guide/b]] and now [[index]].\n",
522 )
523 .unwrap();
524 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
525 }
526
527 #[test]
528 fn adding_a_new_doc_falls_back_to_full() {
529 let tmp = tempfile::tempdir().unwrap();
530 let root = tmp.path();
531 corpus(root);
532 let out = root.join("out");
533 let (mut state, _) = DevState::initial(root, &out).unwrap();
534
535 fs::write(root.join("docs/guide/c.md"), "# Gamma\n\nNew page.\n").unwrap();
536 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Full);
537 }
538
539 #[test]
540 fn no_op_change_is_incremental() {
541 let tmp = tempfile::tempdir().unwrap();
542 let root = tmp.path();
543 corpus(root);
544 let out = root.join("out");
545 let (mut state, _) = DevState::initial(root, &out).unwrap();
546
547 fs::write(
549 root.join("docs/guide/a.md"),
550 "# Alpha\n\nAlpha body. Link to [[guide/b]].\n",
551 )
552 .unwrap();
553 assert_eq!(state.rebuild().unwrap().kind, RebuildKind::Incremental);
554 }
555}