xtask_lib/tasks/
docs.rs

1//! `cargo xtask docs` — build the full documentation portal.
2//!
3//! 1. Installs mdbook-mermaid JavaScript assets into `docs/`.
4//! 2. Builds the mdBook user guide (`docs/book/html/`).
5//! 3. Builds translated mdBook books for each locale found in `docs/po/`.
6//! 4. Generates Rust API documentation (`target/doc/`).
7//! 5. Optionally injects the git version string into the generated HTML.
8//! 6. Writes `locales.json` with available locales.
9//! 7. Creates symlinks so Zola can find the mdBook and cargo-doc outputs.
10//! 8. Builds the Zola documentation portal (`docs-portal/public/`).
11//!
12//! ## Output
13//!
14//! The final portal is written to `docs-portal/public/` and is ready to be
15//! deployed to Cloudflare Pages or GitHub Pages.
16
17use std::path::Path;
18
19use anyhow::{Context, Result};
20use clap::Args;
21use serde::Deserialize;
22use walkdir::WalkDir;
23
24use super::util::{cmd, workspace};
25
26/// Represents a locale entry in the locales.json file.
27#[derive(Debug, serde::Serialize)]
28struct LocaleEntry {
29    code: String,
30    label: String,
31}
32
33/// Arguments for `cargo xtask docs`.
34#[derive(Debug, Args)]
35pub struct DocsArgs {
36    /// Base URL for the Zola build (e.g. `https://cadmus-dt6.pages.dev/`).
37    ///
38    /// Defaults to `http://localhost` for local development.
39    #[arg(long, default_value = "http://localhost")]
40    pub base_url: String,
41
42    /// Skip the Zola portal build (useful when only the mdBook output is
43    /// needed, e.g. for embedding the EPUB in the binary).
44    #[arg(long)]
45    pub mdbook_only: bool,
46}
47
48#[derive(Debug, Deserialize)]
49struct CargoMetadata {
50    packages: Vec<CargoPackage>,
51    target_directory: String,
52}
53
54#[derive(Debug, Deserialize)]
55struct CargoPackage {
56    name: String,
57    version: String,
58}
59
60/// Builds the full documentation portal.
61///
62/// # Errors
63///
64/// Returns an error if any build tool (`mdbook`, `cargo doc`, `zola`) is not
65/// on `PATH` or exits with a non-zero status.
66pub fn run(args: DocsArgs) -> Result<()> {
67    let root = workspace::root()?;
68
69    install_mermaid_assets(&root)?;
70    build_mdbook(&root)?;
71    build_translated_books(&root)?;
72
73    if args.mdbook_only {
74        return Ok(());
75    }
76
77    build_cargo_doc(&root)?;
78    inject_git_version(&root)?;
79    write_locales_json(&root)?;
80    create_portal_symlinks(&root)?;
81    build_zola(&root, &args.base_url)?;
82
83    println!("\nDocumentation built successfully!");
84    println!("Output: docs-portal/public/");
85
86    Ok(())
87}
88
89/// Installs the Mermaid JavaScript assets required by mdbook-mermaid.
90///
91/// This only needs to run once (or after updating the mdbook-mermaid version).
92fn install_mermaid_assets(root: &Path) -> Result<()> {
93    println!("Installing mdbook-mermaid assets…");
94    cmd::run("mdbook-mermaid", &["install", "docs"], root, &[])
95}
96
97/// Builds the mdBook user guide.
98fn build_mdbook(root: &Path) -> Result<()> {
99    println!("Building mdBook documentation…");
100    cmd::run("mdbook", &["build"], &root.join("docs"), &[])
101}
102
103/// Generates Rust API documentation for all workspace crates.
104fn build_cargo_doc(root: &Path) -> Result<()> {
105    println!("Building Rust API documentation…");
106    cmd::run(
107        "cargo",
108        &["doc", "--no-deps", "--document-private-items"],
109        root,
110        &[],
111    )
112}
113
114/// Injects the git version string into the generated Rust documentation HTML.
115///
116/// `cargo doc` embeds the crate version from `Cargo.toml`.  This function
117/// replaces that static version with the output of `git describe` so that
118/// documentation built from a dirty working tree or a non-tagged commit shows
119/// the exact revision.
120fn inject_git_version(root: &Path) -> Result<()> {
121    let workspace_version = read_workspace_version(root)?;
122    let git_version = read_git_version(root)?;
123
124    if workspace_version == git_version {
125        return Ok(());
126    }
127
128    println!("Injecting git version '{git_version}' into Rust docs…");
129
130    let doc_dir = root.join("target/doc");
131    if !doc_dir.exists() {
132        return Ok(());
133    }
134
135    replace_version_in_html(&doc_dir, &workspace_version, &git_version)
136}
137
138/// Reads the workspace version from the `cadmus` crate's `Cargo.toml`.
139fn read_workspace_version(root: &Path) -> Result<String> {
140    let metadata = cargo_metadata(root)?;
141    metadata
142        .packages
143        .into_iter()
144        .find(|p| p.name == "cadmus")
145        .map(|p| p.version)
146        .context("cadmus package not found in cargo metadata")
147}
148
149/// Returns the git version string (`git describe --tags --always --dirty`).
150fn read_git_version(root: &Path) -> Result<String> {
151    cmd::output(
152        "git",
153        &["describe", "--tags", "--always", "--dirty"],
154        root,
155        &[],
156    )
157}
158
159/// Walks `doc_dir` recursively and replaces `workspace_version` with
160/// `git_version` in every HTML file that contains the version span.
161fn replace_version_in_html(
162    doc_dir: &Path,
163    workspace_version: &str,
164    git_version: &str,
165) -> Result<()> {
166    let old = format!(r#"<span class="version">{workspace_version}</span>"#);
167    let new = format!(r#"<span class="version">{git_version}</span>"#);
168
169    for entry in WalkDir::new(doc_dir) {
170        let entry = entry.with_context(|| format!("failed to walk {}", doc_dir.display()))?;
171        let path = entry.path();
172
173        if !path.extension().is_some_and(|ext| ext == "html") {
174            continue;
175        }
176
177        let content = std::fs::read_to_string(path)
178            .with_context(|| format!("failed to read {}", path.display()))?;
179
180        if content.contains(&old) {
181            let updated = content.replace(&old, &new);
182            std::fs::write(path, updated)
183                .with_context(|| format!("failed to write {}", path.display()))?;
184        }
185    }
186
187    Ok(())
188}
189
190/// Builds the Zola documentation portal.
191fn build_zola(root: &Path, base_url: &str) -> Result<()> {
192    println!("Building Zola documentation portal…");
193    cmd::run(
194        "zola",
195        &["build", "--base-url", base_url],
196        &root.join("docs-portal"),
197        &[],
198    )
199}
200
201/// Runs `cargo metadata` and returns the parsed result.
202fn cargo_metadata(root: &Path) -> Result<CargoMetadata> {
203    let json = cmd::output(
204        "cargo",
205        &["metadata", "--format-version=1", "--no-deps"],
206        root,
207        &[],
208    )?;
209    serde_json::from_str(&json).context("failed to parse cargo metadata JSON")
210}
211
212fn symlink_force(target: &Path, link: &Path) -> Result<()> {
213    if link.exists() || link.symlink_metadata().is_ok() {
214        std::fs::remove_file(link)
215            .with_context(|| format!("failed to remove {}", link.display()))?;
216    }
217
218    #[cfg(unix)]
219    std::os::unix::fs::symlink(target, link)
220        .with_context(|| format!("failed to create symlink {}", link.display()))?;
221
222    #[cfg(not(unix))]
223    {
224        if target.is_dir() {
225            std::os::windows::fs::symlink_dir(target, link)
226                .with_context(|| format!("failed to create dir symlink {}", link.display()))?;
227        } else {
228            std::fs::copy(target, link)
229                .with_context(|| format!("failed to copy {}", target.display()))?;
230        }
231    }
232
233    Ok(())
234}
235
236/// Builds translated mdBook books for each locale found in `docs/po/`.
237fn build_translated_books(root: &Path) -> Result<()> {
238    let po_dir = root.join("docs/po");
239    if !po_dir.exists() {
240        println!("No PO directory found, skipping translated books.");
241        return Ok(());
242    }
243
244    println!("Building translated books…");
245    for entry in WalkDir::new(&po_dir)
246        .min_depth(1)
247        .max_depth(1)
248        .into_iter()
249        .filter_map(|e| e.ok())
250    {
251        let path = entry.path();
252        if path.extension().is_some_and(|ext| ext == "po") {
253            let lang = path
254                .file_stem()
255                .and_then(|s| s.to_str())
256                .context("Invalid locale filename")?;
257
258            println!("Building {lang} translation…");
259            cmd::run(
260                "mdbook",
261                &["build", "-d", &format!("book/{lang}")],
262                &root.join("docs"),
263                &[("MDBOOK_BOOK__LANGUAGE", lang)],
264            )?;
265        }
266    }
267
268    Ok(())
269}
270
271/// Writes `docs/book/html/locales.json` with available locales and their display names.
272fn write_locales_json(root: &Path) -> Result<()> {
273    let po_dir = root.join("docs/po");
274    if !po_dir.exists() {
275        return Ok(());
276    }
277
278    let mut locales = Vec::new();
279    for entry in WalkDir::new(&po_dir)
280        .min_depth(1)
281        .max_depth(1)
282        .into_iter()
283        .filter_map(|e| e.ok())
284    {
285        let path = entry.path();
286        if path.extension().is_some_and(|ext| ext == "po") {
287            let lang = path
288                .file_stem()
289                .and_then(|s| s.to_str())
290                .context("Invalid locale filename")?;
291
292            let label = extract_lang_name(path).unwrap_or_else(|| lang.to_string());
293            locales.push(LocaleEntry {
294                code: lang.to_string(),
295                label,
296            });
297        }
298    }
299
300    // Sort locales by code for deterministic output
301    locales.sort_by(|a, b| a.code.cmp(&b.code));
302
303    let output_path = root.join("docs/book/html/locales.json");
304    std::fs::create_dir_all(output_path.parent().context("no parent dir")?)?;
305    let json = serde_json::to_string_pretty(&locales)?;
306    std::fs::write(&output_path, json).context("failed to write locales.json")?;
307
308    println!(
309        "Wrote {} locales to {}",
310        locales.len(),
311        output_path.display()
312    );
313    Ok(())
314}
315
316/// Extracts the display name from the PO file header.
317fn extract_lang_name(po_path: &Path) -> Option<String> {
318    let content = std::fs::read_to_string(po_path).ok()?;
319    for line in content.lines() {
320        let line = line.trim_start();
321        if let Some(rest) = line.strip_prefix("Language-Name:") {
322            let name = rest.trim();
323            if name.is_empty() {
324                return None;
325            } else {
326                return Some(name.to_string());
327            }
328        }
329    }
330    None
331}
332
333/// Creates symlinks in `docs-portal/static/` so Zola can serve the mdBook
334/// and cargo-doc outputs as static assets.
335fn create_portal_symlinks(root: &Path) -> Result<()> {
336    let metadata = cargo_metadata(root)?;
337
338    let api_link = root.join("docs-portal/static/api");
339    let guide_link = root.join("docs-portal/static/guide");
340
341    symlink_force(
342        Path::new(&format!("{}/doc", metadata.target_directory)),
343        &api_link,
344    )?;
345    symlink_force(&root.join("docs/book/html"), &guide_link)?;
346
347    // Create locale symlinks
348    let po_dir = root.join("docs/po");
349    if po_dir.exists() {
350        for entry in WalkDir::new(&po_dir)
351            .min_depth(1)
352            .max_depth(1)
353            .into_iter()
354            .filter_map(|e| e.ok())
355        {
356            let path = entry.path();
357            if path.extension().is_some_and(|ext| ext == "po") {
358                let lang = path
359                    .file_stem()
360                    .and_then(|s| s.to_str())
361                    .context("Invalid locale filename")?;
362
363                let target = root.join("docs/book").join(lang).join("html");
364                let link = root.join("docs-portal/static/guide").join(lang);
365                symlink_force(&target, &link)?;
366            }
367        }
368    }
369
370    Ok(())
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use std::fs;
377    use tempfile::TempDir;
378
379    #[test]
380    fn extract_lang_name_with_valid_header() {
381        let temp_dir = TempDir::new().unwrap();
382        let po_file = temp_dir.path().join("test.po");
383        fs::write(
384            &po_file,
385            r#"msgid ""
386msgstr ""
387Language-Name: Español
388
389msgid "hello"
390msgstr "hola"
391"#,
392        )
393        .unwrap();
394
395        let result = extract_lang_name(&po_file);
396        assert_eq!(result, Some("Español".to_string()));
397    }
398
399    #[test]
400    fn extract_lang_name_with_leading_whitespace() {
401        let temp_dir = TempDir::new().unwrap();
402        let po_file = temp_dir.path().join("test.po");
403        fs::write(
404            &po_file,
405            r#"msgid ""
406msgstr ""
407Language-Name:   Français
408
409msgid "hello"
410msgstr "bonjour"
411"#,
412        )
413        .unwrap();
414
415        let result = extract_lang_name(&po_file);
416        assert_eq!(result, Some("Français".to_string()));
417    }
418
419    #[test]
420    fn extract_lang_name_missing_header() {
421        let temp_dir = TempDir::new().unwrap();
422        let po_file = temp_dir.path().join("test.po");
423        fs::write(
424            &po_file,
425            r#"msgid "hello"
426msgstr "hola"
427"#,
428        )
429        .unwrap();
430
431        let result = extract_lang_name(&po_file);
432        assert_eq!(result, None);
433    }
434
435    #[test]
436    fn extract_lang_name_empty_language_name() {
437        let temp_dir = TempDir::new().unwrap();
438        let po_file = temp_dir.path().join("test.po");
439        fs::write(
440            &po_file,
441            r#"msgid ""
442msgstr ""
443Language-Name:
444
445msgid "hello"
446msgstr "hola"
447"#,
448        )
449        .unwrap();
450
451        let result = extract_lang_name(&po_file);
452        assert_eq!(result, None);
453    }
454}