Skip to main content

xtask_lib/tasks/ci/
install_doc_tools.rs

1//! `cargo xtask ci install-doc-tools` — install mdBook, mdbook-epub,
2//! mdbook-mermaid, mdbook-i18n-helpers, and optionally Zola into `~/.cache/` with pinned revisions.
3//!
4//! This task is the Rust replacement for the bash install script that previously
5//! lived in `.github/actions/install-doc-tools/action.yml`.  It is designed to
6//! run after `actions/cache` has restored any previously cached binaries, so it
7//! only downloads and builds what is missing or stale.
8//!
9//! ## Cache layout
10//!
11//! | Tool | Cache directory | Staleness marker |
12//! |------|-----------------|------------------|
13//! | mdBook | `~/.cache/mdbook/` | binary presence |
14//! | mdbook-epub | `~/.cache/mdbook-epub/` | `~/.cache/mdbook-epub/.rev` |
15//! | mdbook-mermaid | `~/.cache/mdbook-mermaid/` | `~/.cache/mdbook-mermaid/.version` |
16//! | mdbook-i18n-helpers | `~/.cache/mdbook-i18n-helpers/` | `~/.cache/mdbook-i18n-helpers/.rev` |
17//! | Zola | `~/.cache/zola/` | binary presence |
18//!
19//! ## PATH update
20//!
21//! After installation, `~/.local/bin` is appended to the file pointed to by
22//! `$GITHUB_PATH`.  This makes all four tools available to subsequent GitHub
23//! Actions steps without any additional shell configuration.
24
25use std::path::{Path, PathBuf};
26
27use anyhow::{Context, Result};
28use clap::Args;
29
30use crate::tasks::util::{cmd, fs, http, workspace};
31
32/// Arguments for `cargo xtask ci install-doc-tools`.
33#[derive(Debug, Args)]
34pub struct InstallDocToolsArgs {
35    /// mdBook release version to install (e.g. `"0.5.2"`).
36    #[arg(long)]
37    pub mdbook_version: String,
38
39    /// Full git SHA of the `Michael-F-Bryan/mdbook-epub` commit to build.
40    #[arg(long)]
41    pub mdbook_epub_rev: String,
42
43    /// mdbook-mermaid release version to install (e.g. `"0.17.0"`).
44    #[arg(long)]
45    pub mdbook_mermaid_version: String,
46
47    /// Full git SHA of the `thirdparty/mdbook-i18n-helpers` commit to build.
48    #[arg(long)]
49    pub mdbook_i18n_helpers_rev: String,
50
51    /// Zola release version to install (e.g. `"0.22.1"`).
52    ///
53    /// When omitted, Zola installation is skipped.
54    #[arg(long)]
55    pub zola_version: Option<String>,
56}
57
58/// Installs doc tools and appends `~/.local/bin` to `$GITHUB_PATH`.
59///
60/// # Errors
61///
62/// Returns an error if any download, build, or installation step fails.
63pub fn run(args: InstallDocToolsArgs) -> Result<()> {
64    let home = home_dir()?;
65    let cache = home.join(".cache");
66    let local_bin = home.join(".local/bin");
67
68    std::fs::create_dir_all(&local_bin).context("failed to create ~/.local/bin")?;
69
70    install_mdbook(&cache, &local_bin, &args.mdbook_version)?;
71    install_mdbook_epub(&cache, &local_bin, &args.mdbook_epub_rev)?;
72    install_mdbook_mermaid(&cache, &local_bin, &args.mdbook_mermaid_version)?;
73    install_mdbook_i18n_helpers(&cache, &local_bin, &args.mdbook_i18n_helpers_rev)?;
74
75    if let Some(ref version) = args.zola_version {
76        install_zola(&cache, &local_bin, version)?;
77    }
78
79    append_to_github_path(&local_bin)?;
80
81    println!("\nDoc tools installed successfully.");
82
83    Ok(())
84}
85
86/// Downloads and extracts the mdBook binary if not already cached.
87///
88/// The binary is placed at `~/.cache/mdbook/mdbook` and symlinked into
89/// `~/.local/bin/`.
90fn install_mdbook(cache: &Path, local_bin: &Path, version: &str) -> Result<()> {
91    let mdbook_dir = cache.join("mdbook");
92    let mdbook_bin = mdbook_dir.join("mdbook");
93
94    if mdbook_bin.exists() {
95        println!("mdBook {version} already cached, skipping download.");
96    } else {
97        println!("Installing mdBook {version}…");
98        std::fs::create_dir_all(&mdbook_dir).context("failed to create ~/.cache/mdbook")?;
99
100        let arch = mdbook_arch();
101        let url = format!(
102            "https://github.com/rust-lang/mdBook/releases/download/v{version}/mdbook-v{version}-{arch}.tar.gz"
103        );
104
105        let tarball = std::env::temp_dir().join("mdbook.tar.gz");
106        http::download(&url, &tarball)?;
107        fs::extract_tarball(&tarball, &mdbook_dir)?;
108    }
109
110    symlink_bin(&mdbook_bin, &local_bin.join("mdbook"))
111}
112
113/// Builds and installs mdbook-epub from source if the cached revision is stale.
114///
115/// The binary is placed at `~/.cache/mdbook-epub/bin/mdbook-epub` and
116/// symlinked into `~/.local/bin/`.  A `.rev` file records the installed SHA so
117/// subsequent runs can detect staleness without rebuilding.
118fn install_mdbook_epub(cache: &Path, local_bin: &Path, rev: &str) -> Result<()> {
119    let epub_dir = cache.join("mdbook-epub");
120    let epub_bin = epub_dir.join("bin/mdbook-epub");
121    let rev_file = epub_dir.join(".rev");
122
123    if is_current(&epub_bin, &rev_file, rev) {
124        println!("mdbook-epub {rev} already cached, skipping build.");
125    } else {
126        println!("Building mdbook-epub @ {rev}…");
127        std::fs::remove_dir_all(&epub_dir).ok();
128
129        let tmp = std::env::temp_dir().join("mdbook-epub-src");
130        std::fs::create_dir_all(&tmp).context("failed to create mdbook-epub temp dir")?;
131
132        let tarball = tmp.join("mdbook-epub.tar.gz");
133        let url = format!("https://github.com/Michael-F-Bryan/mdbook-epub/archive/{rev}.tar.gz");
134        http::download(&url, &tarball)?;
135        fs::extract_tarball(&tarball, &tmp)?;
136
137        let src_dir = tmp.join(format!("mdbook-epub-{rev}"));
138        cmd::run(
139            "cargo",
140            &[
141                "install",
142                "--path",
143                src_dir.to_str().context("non-UTF-8 path")?,
144                "--locked",
145                "--root",
146                epub_dir.to_str().context("non-UTF-8 path")?,
147            ],
148            &src_dir,
149            &[],
150        )?;
151
152        std::fs::remove_dir_all(&tmp).ok();
153        std::fs::write(&rev_file, rev).context("failed to write mdbook-epub .rev")?;
154    }
155
156    symlink_bin(&epub_bin, &local_bin.join("mdbook-epub"))
157}
158
159/// Installs mdbook-mermaid via `cargo install` if the cached version is stale.
160///
161/// The binary is placed at `~/.cache/mdbook-mermaid/bin/mdbook-mermaid` and
162/// symlinked into `~/.local/bin/`.  A `.version` file records the installed
163/// version.
164fn install_mdbook_mermaid(cache: &Path, local_bin: &Path, version: &str) -> Result<()> {
165    let mermaid_dir = cache.join("mdbook-mermaid");
166    let mermaid_bin = mermaid_dir.join("bin/mdbook-mermaid");
167    let version_file = mermaid_dir.join(".version");
168
169    if is_current(&mermaid_bin, &version_file, version) {
170        println!("mdbook-mermaid {version} already cached, skipping install.");
171    } else {
172        println!("Installing mdbook-mermaid {version}…");
173        std::fs::remove_dir_all(&mermaid_dir).ok();
174        std::fs::create_dir_all(mermaid_dir.join("bin"))
175            .context("failed to create ~/.cache/mdbook-mermaid/bin")?;
176
177        cmd::run(
178            "cargo",
179            &[
180                "install",
181                "mdbook-mermaid",
182                "--version",
183                version,
184                "--root",
185                mermaid_dir.to_str().context("non-UTF-8 path")?,
186            ],
187            Path::new("."),
188            &[],
189        )?;
190
191        std::fs::write(&version_file, version)
192            .context("failed to write mdbook-mermaid .version")?;
193    }
194
195    symlink_bin(&mermaid_bin, &local_bin.join("mdbook-mermaid"))
196}
197
198/// Builds mdbook-i18n-helpers from the checked-out submodule if the cached revision is stale.
199///
200/// The binaries are placed at `~/.cache/mdbook-i18n-helpers/bin/` and
201/// symlinked into `~/.local/bin/`.  A `.rev` file records the installed
202/// revision.
203fn install_mdbook_i18n_helpers(cache: &Path, local_bin: &Path, rev: &str) -> Result<()> {
204    let i18n_dir = cache.join("mdbook-i18n-helpers");
205    let i18n_bin_dir = i18n_dir.join("bin");
206    let rev_file = i18n_dir.join(".rev");
207    let workspace_root = workspace::root()?;
208    let src_dir = workspace_root.join("thirdparty/mdbook-i18n-helpers/i18n-helpers");
209
210    let binaries = ["mdbook-gettext", "mdbook-xgettext", "mdbook-i18n-normalize"];
211
212    let all_exist = binaries.iter().all(|b| i18n_bin_dir.join(b).exists());
213    let rev_matches = is_current(&i18n_bin_dir.join("mdbook-gettext"), &rev_file, rev);
214
215    if all_exist && rev_matches {
216        println!("mdbook-i18n-helpers {rev} already cached, skipping build.");
217    } else {
218        println!("Building mdbook-i18n-helpers @ {rev}…");
219        std::fs::remove_dir_all(&i18n_dir).ok();
220        std::fs::create_dir_all(&i18n_bin_dir)
221            .context("failed to create ~/.cache/mdbook-i18n-helpers/bin")?;
222
223        cmd::run(
224            "cargo",
225            &[
226                "install",
227                "--path",
228                src_dir.to_str().context("non-UTF-8 path")?,
229                "--locked",
230                "--root",
231                i18n_dir.to_str().context("non-UTF-8 path")?,
232            ],
233            &src_dir,
234            &[],
235        )?;
236
237        std::fs::write(&rev_file, rev).context("failed to write mdbook-i18n-helpers .rev")?;
238    }
239
240    for bin in &binaries {
241        let target = i18n_bin_dir.join(bin);
242        let link = local_bin.join(*bin);
243        symlink_bin(&target, &link)?;
244    }
245
246    Ok(())
247}
248
249/// Downloads and extracts the Zola binary if not already cached.
250///
251/// The binary is placed at `~/.cache/zola/zola` and symlinked into
252/// `~/.local/bin/`.
253fn install_zola(cache: &Path, local_bin: &Path, version: &str) -> Result<()> {
254    let zola_dir = cache.join("zola");
255    let zola_bin = zola_dir.join("zola");
256
257    if zola_bin.exists() {
258        println!("Zola {version} already cached, skipping download.");
259    } else {
260        println!("Installing Zola {version}…");
261        std::fs::create_dir_all(&zola_dir).context("failed to create ~/.cache/zola")?;
262
263        let url = format!(
264            "https://github.com/getzola/zola/releases/download/v{version}/zola-v{version}-x86_64-unknown-linux-gnu.tar.gz"
265        );
266
267        let tarball = std::env::temp_dir().join("zola.tar.gz");
268        http::download(&url, &tarball)?;
269        fs::extract_tarball(&tarball, &zola_dir)?;
270    }
271
272    symlink_bin(&zola_bin, &local_bin.join("zola"))
273}
274
275/// Appends `path` to the file referenced by `$GITHUB_PATH`.
276///
277/// This is the GitHub Actions mechanism for adding directories to `PATH` for
278/// subsequent steps.  When `$GITHUB_PATH` is not set (e.g. local development),
279/// this function is a no-op.
280fn append_to_github_path(path: &Path) -> Result<()> {
281    let github_path = match std::env::var("GITHUB_PATH") {
282        Ok(p) if !p.is_empty() => p,
283        _ => return Ok(()),
284    };
285
286    let entry = format!("{}\n", path.display());
287    std::fs::OpenOptions::new()
288        .append(true)
289        .open(&github_path)
290        .and_then(|mut f| {
291            use std::io::Write;
292            f.write_all(entry.as_bytes())
293        })
294        .with_context(|| format!("failed to append to GITHUB_PATH file at {github_path}"))
295}
296
297/// Returns `true` if `bin` exists and `marker` contains `expected`.
298///
299/// Used to decide whether a cached tool is up-to-date.
300fn is_current(bin: &Path, marker: &Path, expected: &str) -> bool {
301    if !bin.exists() {
302        return false;
303    }
304
305    match std::fs::read_to_string(marker) {
306        Ok(content) => content.trim() == expected,
307        Err(_) => false,
308    }
309}
310
311/// Creates a symlink at `link` pointing to `target`, replacing any existing
312/// file or symlink.
313fn symlink_bin(target: &Path, link: &Path) -> Result<()> {
314    if link.exists() || link.symlink_metadata().is_ok() {
315        std::fs::remove_file(link)
316            .with_context(|| format!("failed to remove {}", link.display()))?;
317    }
318
319    #[cfg(unix)]
320    std::os::unix::fs::symlink(target, link).with_context(|| {
321        format!(
322            "failed to symlink {} -> {}",
323            link.display(),
324            target.display()
325        )
326    })?;
327
328    #[cfg(not(unix))]
329    std::fs::copy(target, link)
330        .with_context(|| format!("failed to copy {} to {}", target.display(), link.display()))?;
331
332    Ok(())
333}
334
335/// Returns the platform-specific architecture string used in mdBook release URLs.
336fn mdbook_arch() -> &'static str {
337    if cfg!(target_os = "macos") {
338        "aarch64-apple-darwin"
339    } else {
340        "x86_64-unknown-linux-gnu"
341    }
342}
343
344/// Returns the user's home directory.
345fn home_dir() -> Result<PathBuf> {
346    std::env::var("HOME")
347        .map(PathBuf::from)
348        .context("$HOME is not set")
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use std::fs;
355
356    #[test]
357    fn is_current_returns_false_when_bin_missing() {
358        let tmp = tempfile::tempdir().unwrap();
359        assert!(!is_current(
360            &tmp.path().join("nonexistent"),
361            &tmp.path().join(".ver"),
362            "1.0"
363        ));
364    }
365
366    #[test]
367    fn is_current_returns_false_when_marker_missing() {
368        let tmp = tempfile::tempdir().unwrap();
369        let bin = tmp.path().join("tool");
370        fs::write(&bin, "").unwrap();
371        assert!(!is_current(&bin, &tmp.path().join(".ver"), "1.0"));
372    }
373
374    #[test]
375    fn is_current_returns_false_when_version_mismatch() {
376        let tmp = tempfile::tempdir().unwrap();
377        let bin = tmp.path().join("tool");
378        let marker = tmp.path().join(".ver");
379        fs::write(&bin, "").unwrap();
380        fs::write(&marker, "0.9").unwrap();
381        assert!(!is_current(&bin, &marker, "1.0"));
382    }
383
384    #[test]
385    fn is_current_returns_true_when_version_matches() {
386        let tmp = tempfile::tempdir().unwrap();
387        let bin = tmp.path().join("tool");
388        let marker = tmp.path().join(".ver");
389        fs::write(&bin, "").unwrap();
390        fs::write(&marker, "1.0").unwrap();
391        assert!(is_current(&bin, &marker, "1.0"));
392    }
393
394    #[test]
395    fn is_current_trims_trailing_newline_in_marker() {
396        let tmp = tempfile::tempdir().unwrap();
397        let bin = tmp.path().join("tool");
398        let marker = tmp.path().join(".ver");
399        fs::write(&bin, "").unwrap();
400        fs::write(&marker, "1.0\n").unwrap();
401        assert!(is_current(&bin, &marker, "1.0"));
402    }
403
404    #[test]
405    fn append_to_github_path_is_noop_when_env_unset() {
406        unsafe { std::env::remove_var("GITHUB_PATH") };
407        let result = append_to_github_path(Path::new("/some/bin"));
408        assert!(result.is_ok());
409    }
410
411    #[test]
412    fn append_to_github_path_writes_to_file() {
413        let tmp = tempfile::tempdir().unwrap();
414        let path_file = tmp.path().join("GITHUB_PATH");
415        fs::write(&path_file, "").unwrap();
416
417        unsafe { std::env::set_var("GITHUB_PATH", path_file.to_str().unwrap()) };
418        append_to_github_path(Path::new("/usr/local/bin")).unwrap();
419        unsafe { std::env::remove_var("GITHUB_PATH") };
420
421        let content = fs::read_to_string(&path_file).unwrap();
422        assert!(content.contains("/usr/local/bin"));
423    }
424
425    #[cfg(unix)]
426    #[test]
427    fn symlink_bin_creates_symlink() {
428        let tmp = tempfile::tempdir().unwrap();
429        let target = tmp.path().join("target");
430        let link = tmp.path().join("link");
431        fs::write(&target, "binary").unwrap();
432        symlink_bin(&target, &link).unwrap();
433        assert!(link.exists());
434    }
435
436    #[cfg(unix)]
437    #[test]
438    fn symlink_bin_replaces_existing_symlink() {
439        let tmp = tempfile::tempdir().unwrap();
440        let target1 = tmp.path().join("target1");
441        let target2 = tmp.path().join("target2");
442        let link = tmp.path().join("link");
443        fs::write(&target1, "v1").unwrap();
444        fs::write(&target2, "v2").unwrap();
445        symlink_bin(&target1, &link).unwrap();
446        symlink_bin(&target2, &link).unwrap();
447        assert_eq!(fs::read_to_string(&link).unwrap(), "v2");
448    }
449}