1use std::path::{Path, PathBuf};
24
25#[derive(Debug)]
27pub struct Built {
28 pub root: PathBuf,
30 pub book: PathBuf,
32 pub api: PathBuf,
34 pub crates: Vec<String>,
36}
37
38#[derive(Debug)]
40pub enum Failure {
41 NotACheckout,
43 MissingTool {
45 tool: &'static str,
47 install: &'static str,
49 },
50 Stage {
54 name: &'static str,
56 code: Option<i32>,
58 },
59 Io(String),
61}
62
63impl std::fmt::Display for Failure {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 match self {
66 Self::NotACheckout => write!(
67 f,
68 "no book.toml in this directory or any parent.\n\
69 `choir docs` builds this repository's own documentation, \
70 so it has to be run inside a checkout of it."
71 ),
72 Self::MissingTool { tool, install } => {
73 write!(f, "{tool} is not on PATH.\n {install}")
74 }
75 Self::Stage { name, code } => match code {
76 Some(code) => write!(f, "{name} exited {code}"),
77 None => write!(f, "{name} was killed by a signal"),
78 },
79 Self::Io(message) => write!(f, "{message}"),
80 }
81 }
82}
83
84#[must_use]
90pub fn find_root(from: &Path) -> Option<PathBuf> {
91 let mut dir = from.to_path_buf();
92 loop {
93 if dir.join("book.toml").is_file() {
94 return Some(dir);
95 }
96 if !dir.pop() {
97 return None;
98 }
99 }
100}
101
102#[must_use]
113pub fn target_dir_from_metadata(json: &str) -> Option<&str> {
114 const KEY: &str = "\"target_directory\":";
115 let rest = &json[json.find(KEY)? + KEY.len()..];
116 let rest = rest.trim_start();
117 let rest = rest.strip_prefix('"')?;
118 let end = rest.find('"')?;
119 Some(&rest[..end])
120}
121
122#[must_use]
133pub fn api_index(crates: &[String]) -> String {
134 let mut items = String::new();
135 for name in crates {
136 items.push_str(&format!(
137 " <li><a href=\"{name}/index.html\"><code>{name}</code></a></li>\n"
138 ));
139 }
140 format!(
141 r#"<!doctype html>
142<html lang="en">
143<meta charset="utf-8">
144<meta name="viewport" content="width=device-width,initial-scale=1">
145<title>choir: API documentation</title>
146<link rel="stylesheet" href="choir-tokens.css">
147<style>
148 :root {{ font-size: 17px; }}
149 body {{ margin: 0; background: var(--ground); color: var(--ink);
150 font-family: var(--font-sans); letter-spacing: var(--tr-body);
151 line-height: var(--lh-copy); }}
152 main {{ max-width: var(--measure); margin: 0 auto;
153 padding: var(--sp-16) var(--sp-5); }}
154 h1 {{ font-size: var(--fs-900); line-height: var(--lh-tight);
155 letter-spacing: var(--tr-display); color: var(--strong);
156 margin: 0 0 var(--sp-3); }}
157 p.lede {{ font-size: var(--fs-600); color: var(--muted);
158 margin: 0 0 var(--sp-10); }}
159 a {{ color: var(--accent-ink); text-decoration-color: var(--accent-line);
160 text-underline-offset: .18em; }}
161 a:hover {{ color: var(--accent-hover); }}
162 ul {{ list-style: none; padding: 0; margin: 0; display: grid;
163 gap: var(--sp-2);
164 grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); }}
165 li a {{ display: block; background: var(--card); border: var(--border);
166 border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4);
167 text-decoration: none; color: var(--ink); }}
168 li a code {{ font-family: var(--font-mono); font-size: var(--fs-300); }}
169 li a:hover {{ border-color: var(--accent-line); background: var(--accent-tint);
170 color: var(--accent-ink); }}
171 footer {{ margin-top: var(--sp-12); padding-top: var(--sp-5);
172 border-top: var(--bw-hair) solid var(--line);
173 color: var(--faint); font-size: var(--fs-300); }}
174</style>
175<main>
176<h1>choir</h1>
177<p class="lede">API documentation, one page per crate. The prose that
178explains how they fit is in <a href="../index.html">the book</a>.</p>
179<ul>
180{items}</ul>
181<footer>Written by <code>choir docs</code>. Rebuild with
182<code>choir docs --open</code>.</footer>
183</main>
184</html>
185"#
186 )
187}
188
189pub const NODE_LINK_MARKER: &str = "<!--node-link-->";
196
197#[must_use]
223pub fn repoint_escaping_links(html: &str, depth: usize, base: &str) -> String {
224 let mut out = String::with_capacity(html.len());
225 let mut rest = html;
226 while let Some(at) = rest.find("href=\"") {
227 out.push_str(&rest[..at + 6]);
228 rest = &rest[at + 6..];
229 let Some(end) = rest.find('"') else { break };
230 let href = &rest[..end];
231 match repoint_one(href, depth, base) {
232 Some(moved) => out.push_str(&moved),
233 None => out.push_str(href),
234 }
235 rest = &rest[end..];
236 }
237 out.push_str(rest);
238 out
239}
240
241fn repoint_one(href: &str, depth: usize, base: &str) -> Option<String> {
243 let (path, tail) = match href.find(['#', '?']) {
244 Some(at) => (&href[..at], &href[at..]),
245 None => (href, ""),
246 };
247 let climbs = path.split('/').take_while(|seg| *seg == "..").count();
248 if climbs == 0 || climbs <= depth || climbs > depth + 1 {
252 return None;
253 }
254 let target = path.split('/').skip(climbs).collect::<Vec<_>>().join("/");
255 let target = target.strip_suffix(".html")?;
256 Some(format!("{}/{target}.md{tail}", base.trim_end_matches('/')))
257}
258
259#[must_use]
266pub fn stamp_node_link(html: &str, node: &str) -> String {
267 let node = node.trim_end_matches('/');
268 html.replace(
269 NODE_LINK_MARKER,
270 &format!("<a class=\"api-link\" href=\"{node}\">The node itself →</a>"),
271 )
272}
273
274fn rewrite_pages(book: &Path, base: Option<&str>, node: Option<&str>) -> std::io::Result<()> {
283 fn walk(
284 dir: &Path,
285 depth: usize,
286 base: Option<&str>,
287 node: Option<&str>,
288 ) -> std::io::Result<()> {
289 for entry in std::fs::read_dir(dir)? {
290 let entry = entry?;
291 let path = entry.path();
292 if entry.file_type()?.is_dir() {
293 if depth == 0 && entry.file_name() == "api" {
294 continue;
295 }
296 walk(&path, depth + 1, base, node)?;
297 continue;
298 }
299 if path.extension().is_none_or(|ext| ext != "html") {
300 continue;
301 }
302 let before = std::fs::read_to_string(&path)?;
303 let mut after = match base {
304 Some(base) => repoint_escaping_links(&before, depth, base),
305 None => before.clone(),
306 };
307 if let Some(node) = node {
308 after = stamp_node_link(&after, node);
309 }
310 if after != before {
311 std::fs::write(&path, after)?;
312 }
313 }
314 Ok(())
315 }
316 walk(book, 0, base, node)
317}
318
319fn tool_exists(tool: &str) -> bool {
320 std::process::Command::new(tool)
321 .arg("--version")
322 .stdout(std::process::Stdio::null())
323 .stderr(std::process::Stdio::null())
324 .status()
325 .is_ok_and(|s| s.success())
326}
327
328fn stage(name: &'static str, mut command: std::process::Command) -> Result<(), Failure> {
330 let status = command
331 .status()
332 .map_err(|e| Failure::Io(format!("cannot run {name}: {e}")))?;
333 if status.success() {
334 return Ok(());
335 }
336 Err(Failure::Stage {
337 name,
338 code: status.code(),
339 })
340}
341
342fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
347 std::fs::create_dir_all(dst)?;
348 for entry in std::fs::read_dir(src)? {
349 let entry = entry?;
350 let target = dst.join(entry.file_name());
351 if entry.file_type()?.is_dir() {
352 copy_tree(&entry.path(), &target)?;
353 } else {
354 std::fs::copy(entry.path(), &target)?;
355 }
356 }
357 Ok(())
358}
359
360pub fn build(root: &Path) -> Result<Built, Failure> {
368 if !tool_exists("cargo") {
369 return Err(Failure::MissingTool {
370 tool: "cargo",
371 install: "install Rust from https://rustup.rs/",
372 });
373 }
374 if !tool_exists("mdbook") {
375 return Err(Failure::MissingTool {
376 tool: "mdbook",
377 install: "cargo install mdbook --locked",
378 });
379 }
380
381 let mut doc = std::process::Command::new("cargo");
385 doc.current_dir(root)
386 .env("RUSTDOCFLAGS", "-Dwarnings")
387 .args(["doc", "--workspace", "--no-deps"]);
388 stage("cargo doc", doc)?;
389
390 let mut book = std::process::Command::new("mdbook");
391 book.current_dir(root).arg("build");
392 stage("mdbook build", book)?;
393
394 let base = std::env::var("CHOIR_DOCS_REPO_BASE").ok();
400 let node = std::env::var("CHOIR_DOCS_NODE_URL").ok();
401 rewrite_pages(
402 &root.join("book"),
403 base.as_deref().filter(|s| !s.is_empty()),
404 node.as_deref().filter(|s| !s.is_empty()),
405 )
406 .map_err(|e| Failure::Io(format!("cannot rewrite the book's links: {e}")))?;
407
408 let metadata = std::process::Command::new("cargo")
409 .current_dir(root)
410 .args(["metadata", "--format-version", "1", "--no-deps"])
411 .output()
412 .map_err(|e| Failure::Io(format!("cannot run cargo metadata: {e}")))?;
413 let metadata = String::from_utf8_lossy(&metadata.stdout);
414 let target = target_dir_from_metadata(&metadata)
415 .map(PathBuf::from)
416 .or_else(|| std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from))
417 .unwrap_or_else(|| root.join("target"));
418
419 let from = target.join("doc");
420 if !from.is_dir() {
421 return Err(Failure::Io(format!(
422 "cargo doc reported success but {} does not exist\n\
423 cargo says its target directory is: {}",
424 from.display(),
425 target.display()
426 )));
427 }
428
429 let book_dir = root.join("book");
430 let api = book_dir.join("api");
431 if let Err(e) = std::fs::remove_dir_all(&api) {
432 if e.kind() != std::io::ErrorKind::NotFound {
433 return Err(Failure::Io(format!("cannot clear {}: {e}", api.display())));
434 }
435 }
436 copy_tree(&from, &api).map_err(|e| {
437 Failure::Io(format!(
438 "cannot copy {} to {}: {e}",
439 from.display(),
440 api.display()
441 ))
442 })?;
443
444 let mut crates = Vec::new();
448 if let Ok(entries) = std::fs::read_dir(&api) {
449 for entry in entries.flatten() {
450 if entry.path().join("index.html").is_file() {
451 crates.push(entry.file_name().to_string_lossy().into_owned());
452 }
453 }
454 }
455 crates.sort();
456
457 let index = api.join("index.html");
460 if !index.is_file() {
461 let tokens = root.join("theme/choir-tokens.css");
462 std::fs::copy(&tokens, api.join("choir-tokens.css"))
463 .map_err(|e| Failure::Io(format!("cannot copy {}: {e}", tokens.display())))?;
464 std::fs::write(&index, api_index(&crates))
465 .map_err(|e| Failure::Io(format!("cannot write {}: {e}", index.display())))?;
466 }
467
468 Ok(Built {
469 root: root.to_path_buf(),
470 book: book_dir,
471 api,
472 crates,
473 })
474}
475
476pub fn open(path: &Path) -> bool {
482 for opener in ["open", "xdg-open"] {
483 let opened = std::process::Command::new(opener)
484 .arg(path)
485 .stdout(std::process::Stdio::null())
486 .stderr(std::process::Stdio::null())
487 .status()
488 .is_ok_and(|s| s.success());
489 if opened {
490 return true;
491 }
492 }
493 false
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn target_directory_is_read_out_of_cargos_own_shape() {
502 let json = r#"{"packages":[],"workspace_members":[],"resolve":null,
505 "target_directory":"/w/shared-target","version":1,"workspace_root":"/w"}"#;
506 assert_eq!(
507 target_dir_from_metadata(json),
508 Some("/w/shared-target"),
509 "the field cargo actually emits must be readable"
510 );
511 }
512
513 #[test]
514 fn unreadable_metadata_is_none_rather_than_a_guess() {
515 assert_eq!(target_dir_from_metadata("{}"), None);
516 assert_eq!(target_dir_from_metadata(""), None);
517 assert_eq!(target_dir_from_metadata(r#"{"target_directory":"/w"#), None);
521 }
522
523 #[test]
524 fn the_root_is_the_directory_holding_the_book() {
525 let dir = std::env::temp_dir().join("choir-docs-find-root");
526 let deep = dir.join("crates/choir-cli/src");
527 std::fs::create_dir_all(&deep).expect("scratch tree");
528 std::fs::write(dir.join("book.toml"), "").expect("book.toml");
529 std::fs::write(dir.join("crates/choir-cli/Cargo.toml"), "").expect("manifest");
532
533 assert_eq!(find_root(&deep).as_deref(), Some(dir.as_path()));
534 std::fs::remove_dir_all(&dir).ok();
535 }
536
537 #[test]
538 fn a_tree_with_no_book_has_no_root() {
539 let dir = std::env::temp_dir().join("choir-docs-no-book");
540 std::fs::create_dir_all(&dir).expect("scratch tree");
541 assert!(find_root(&dir).is_none());
544 std::fs::remove_dir_all(&dir).ok();
545 }
546
547 #[test]
548 fn the_index_links_every_crate_and_nothing_else() {
549 let html = api_index(&["choir_node".to_string(), "choir_view".to_string()]);
550 assert!(html.contains("href=\"choir_node/index.html\""));
551 assert!(html.contains("href=\"choir_view/index.html\""));
552 assert!(
553 html.contains("href=\"../index.html\""),
554 "no way back to the book"
555 );
556 assert!(
557 html.contains("choir-tokens.css"),
558 "the index must use the book's palette"
559 );
560 assert!(!html.contains("<script"), "the index must run nothing");
563 assert!(!html.contains("http://"), "no external request");
564 }
565
566 #[test]
567 fn an_empty_workspace_still_renders_a_page() {
568 let html = api_index(&[]);
569 assert!(html.contains("<ul>"), "the list is still well-formed");
570 assert!(html.contains("</html>"));
571 }
572
573 const BASE: &str = "https://forge.invalid/o/choir/blob/abc123";
574
575 #[test]
579 fn a_link_that_leaves_the_book_is_repointed_at_the_repository() {
580 assert_eq!(
581 repoint_one("../DECISIONS.html", 0, BASE).as_deref(),
582 Some("https://forge.invalid/o/choir/blob/abc123/DECISIONS.md")
583 );
584 assert_eq!(
585 repoint_one("../../templates/README.html", 1, BASE).as_deref(),
586 Some("https://forge.invalid/o/choir/blob/abc123/templates/README.md")
587 );
588 assert_eq!(
589 repoint_one("../ERRORS.html#e_no_such_ref", 0, BASE).as_deref(),
590 Some("https://forge.invalid/o/choir/blob/abc123/ERRORS.md#e_no_such_ref")
591 );
592 }
593
594 #[test]
599 fn a_link_that_stays_inside_the_book_is_left_alone() {
600 assert_eq!(repoint_one("architecture.html", 0, BASE), None);
601 assert_eq!(repoint_one("../architecture.html", 1, BASE), None);
602 assert_eq!(repoint_one("operating/limits.html", 0, BASE), None);
603 assert_eq!(repoint_one("#start-here", 0, BASE), None);
604 assert_eq!(repoint_one("https://example.invalid/x", 0, BASE), None);
605 }
606
607 #[test]
610 fn a_link_that_leaves_the_checkout_too_is_left_alone() {
611 assert_eq!(repoint_one("../../../elsewhere.html", 0, BASE), None);
612 }
613
614 #[test]
617 fn only_a_page_mdbook_renamed_is_renamed_back() {
618 assert_eq!(repoint_one("../theme/custom.css", 0, BASE), None);
619 }
620
621 #[test]
622 fn the_rewrite_touches_hrefs_and_nothing_else() {
623 let html = "<p>see ../DECISIONS.html</p><a href=\"../DECISIONS.html\">x</a>";
624 let out = repoint_escaping_links(html, 0, BASE);
625 assert!(
626 out.contains("<p>see ../DECISIONS.html</p>"),
627 "prose was rewritten: {out}"
628 );
629 assert!(
630 out.contains("href=\"https://forge.invalid/o/choir/blob/abc123/DECISIONS.md\""),
631 "the link was not rewritten: {out}"
632 );
633 }
634
635 #[test]
636 fn the_node_link_replaces_its_marker_and_nothing_when_unmarked() {
637 let stamped = stamp_node_link(&format!("<p>{NODE_LINK_MARKER}</p>"), "https://n.invalid/");
638 assert!(
639 stamped.contains("href=\"https://n.invalid\""),
640 "the trailing slash survived: {stamped}"
641 );
642 assert!(!stamped.contains(NODE_LINK_MARKER));
643 assert_eq!(stamp_node_link("<p>x</p>", "https://n.invalid"), "<p>x</p>");
646 }
647}