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 version pinning.
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/.version` |
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};
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    /// mdbook-i18n-helpers release version to install (e.g. `"0.3.0"`).
48    #[arg(long)]
49    pub mdbook_i18n_helpers_version: 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_version)?;
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/// Installs mdbook-i18n-helpers via `cargo install` if the cached version is stale.
199///
200/// The binaries are placed at `~/.cache/mdbook-i18n-helpers/bin/` and
201/// symlinked into `~/.local/bin/`.  A `.version` file records the installed
202/// version.
203fn install_mdbook_i18n_helpers(cache: &Path, local_bin: &Path, version: &str) -> Result<()> {
204    let i18n_dir = cache.join("mdbook-i18n-helpers");
205    let i18n_bin_dir = i18n_dir.join("bin");
206    let version_file = i18n_dir.join(".version");
207
208    let binaries = ["mdbook-gettext", "mdbook-xgettext", "mdbook-i18n-normalize"];
209
210    // Check if all binaries exist and version matches
211    let all_exist = binaries.iter().all(|b| i18n_bin_dir.join(b).exists());
212    let version_matches = is_current(&i18n_bin_dir.join("mdbook-gettext"), &version_file, version);
213
214    if all_exist && version_matches {
215        println!("mdbook-i18n-helpers {version} already cached, skipping install.");
216    } else {
217        println!("Installing mdbook-i18n-helpers {version}…");
218        std::fs::remove_dir_all(&i18n_dir).ok();
219        std::fs::create_dir_all(&i18n_bin_dir)
220            .context("failed to create ~/.cache/mdbook-i18n-helpers/bin")?;
221
222        cmd::run(
223            "cargo",
224            &[
225                "install",
226                "mdbook-i18n-helpers",
227                "--version",
228                version,
229                "--root",
230                i18n_dir.to_str().context("non-UTF-8 path")?,
231            ],
232            Path::new("."),
233            &[],
234        )?;
235
236        std::fs::write(&version_file, version)
237            .context("failed to write mdbook-i18n-helpers .version")?;
238    }
239
240    // Symlink all binaries
241    for bin in &binaries {
242        let target = i18n_bin_dir.join(bin);
243        let link = local_bin.join(*bin);
244        symlink_bin(&target, &link)?;
245    }
246
247    Ok(())
248}
249
250/// Downloads and extracts the Zola binary if not already cached.
251///
252/// The binary is placed at `~/.cache/zola/zola` and symlinked into
253/// `~/.local/bin/`.
254fn install_zola(cache: &Path, local_bin: &Path, version: &str) -> Result<()> {
255    let zola_dir = cache.join("zola");
256    let zola_bin = zola_dir.join("zola");
257
258    if zola_bin.exists() {
259        println!("Zola {version} already cached, skipping download.");
260    } else {
261        println!("Installing Zola {version}…");
262        std::fs::create_dir_all(&zola_dir).context("failed to create ~/.cache/zola")?;
263
264        let url = format!(
265            "https://github.com/getzola/zola/releases/download/v{version}/zola-v{version}-x86_64-unknown-linux-gnu.tar.gz"
266        );
267
268        let tarball = std::env::temp_dir().join("zola.tar.gz");
269        http::download(&url, &tarball)?;
270        fs::extract_tarball(&tarball, &zola_dir)?;
271    }
272
273    symlink_bin(&zola_bin, &local_bin.join("zola"))
274}
275
276/// Appends `path` to the file referenced by `$GITHUB_PATH`.
277///
278/// This is the GitHub Actions mechanism for adding directories to `PATH` for
279/// subsequent steps.  When `$GITHUB_PATH` is not set (e.g. local development),
280/// this function is a no-op.
281fn append_to_github_path(path: &Path) -> Result<()> {
282    let github_path = match std::env::var("GITHUB_PATH") {
283        Ok(p) if !p.is_empty() => p,
284        _ => return Ok(()),
285    };
286
287    let entry = format!("{}\n", path.display());
288    std::fs::OpenOptions::new()
289        .append(true)
290        .open(&github_path)
291        .and_then(|mut f| {
292            use std::io::Write;
293            f.write_all(entry.as_bytes())
294        })
295        .with_context(|| format!("failed to append to GITHUB_PATH file at {github_path}"))
296}
297
298/// Returns `true` if `bin` exists and `marker` contains `expected`.
299///
300/// Used to decide whether a cached tool is up-to-date.
301fn is_current(bin: &Path, marker: &Path, expected: &str) -> bool {
302    if !bin.exists() {
303        return false;
304    }
305
306    match std::fs::read_to_string(marker) {
307        Ok(content) => content.trim() == expected,
308        Err(_) => false,
309    }
310}
311
312/// Creates a symlink at `link` pointing to `target`, replacing any existing
313/// file or symlink.
314fn symlink_bin(target: &Path, link: &Path) -> Result<()> {
315    if link.exists() || link.symlink_metadata().is_ok() {
316        std::fs::remove_file(link)
317            .with_context(|| format!("failed to remove {}", link.display()))?;
318    }
319
320    #[cfg(unix)]
321    std::os::unix::fs::symlink(target, link).with_context(|| {
322        format!(
323            "failed to symlink {} -> {}",
324            link.display(),
325            target.display()
326        )
327    })?;
328
329    #[cfg(not(unix))]
330    std::fs::copy(target, link)
331        .with_context(|| format!("failed to copy {} to {}", target.display(), link.display()))?;
332
333    Ok(())
334}
335
336/// Returns the platform-specific architecture string used in mdBook release URLs.
337fn mdbook_arch() -> &'static str {
338    if cfg!(target_os = "macos") {
339        "aarch64-apple-darwin"
340    } else {
341        "x86_64-unknown-linux-gnu"
342    }
343}
344
345/// Returns the user's home directory.
346fn home_dir() -> Result<PathBuf> {
347    std::env::var("HOME")
348        .map(PathBuf::from)
349        .context("$HOME is not set")
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use std::fs;
356
357    #[test]
358    fn is_current_returns_false_when_bin_missing() {
359        let tmp = tempfile::tempdir().unwrap();
360        assert!(!is_current(
361            &tmp.path().join("nonexistent"),
362            &tmp.path().join(".ver"),
363            "1.0"
364        ));
365    }
366
367    #[test]
368    fn is_current_returns_false_when_marker_missing() {
369        let tmp = tempfile::tempdir().unwrap();
370        let bin = tmp.path().join("tool");
371        fs::write(&bin, "").unwrap();
372        assert!(!is_current(&bin, &tmp.path().join(".ver"), "1.0"));
373    }
374
375    #[test]
376    fn is_current_returns_false_when_version_mismatch() {
377        let tmp = tempfile::tempdir().unwrap();
378        let bin = tmp.path().join("tool");
379        let marker = tmp.path().join(".ver");
380        fs::write(&bin, "").unwrap();
381        fs::write(&marker, "0.9").unwrap();
382        assert!(!is_current(&bin, &marker, "1.0"));
383    }
384
385    #[test]
386    fn is_current_returns_true_when_version_matches() {
387        let tmp = tempfile::tempdir().unwrap();
388        let bin = tmp.path().join("tool");
389        let marker = tmp.path().join(".ver");
390        fs::write(&bin, "").unwrap();
391        fs::write(&marker, "1.0").unwrap();
392        assert!(is_current(&bin, &marker, "1.0"));
393    }
394
395    #[test]
396    fn is_current_trims_trailing_newline_in_marker() {
397        let tmp = tempfile::tempdir().unwrap();
398        let bin = tmp.path().join("tool");
399        let marker = tmp.path().join(".ver");
400        fs::write(&bin, "").unwrap();
401        fs::write(&marker, "1.0\n").unwrap();
402        assert!(is_current(&bin, &marker, "1.0"));
403    }
404
405    #[test]
406    fn append_to_github_path_is_noop_when_env_unset() {
407        unsafe { std::env::remove_var("GITHUB_PATH") };
408        let result = append_to_github_path(Path::new("/some/bin"));
409        assert!(result.is_ok());
410    }
411
412    #[test]
413    fn append_to_github_path_writes_to_file() {
414        let tmp = tempfile::tempdir().unwrap();
415        let path_file = tmp.path().join("GITHUB_PATH");
416        fs::write(&path_file, "").unwrap();
417
418        unsafe { std::env::set_var("GITHUB_PATH", path_file.to_str().unwrap()) };
419        append_to_github_path(Path::new("/usr/local/bin")).unwrap();
420        unsafe { std::env::remove_var("GITHUB_PATH") };
421
422        let content = fs::read_to_string(&path_file).unwrap();
423        assert!(content.contains("/usr/local/bin"));
424    }
425
426    #[cfg(unix)]
427    #[test]
428    fn symlink_bin_creates_symlink() {
429        let tmp = tempfile::tempdir().unwrap();
430        let target = tmp.path().join("target");
431        let link = tmp.path().join("link");
432        fs::write(&target, "binary").unwrap();
433        symlink_bin(&target, &link).unwrap();
434        assert!(link.exists());
435    }
436
437    #[cfg(unix)]
438    #[test]
439    fn symlink_bin_replaces_existing_symlink() {
440        let tmp = tempfile::tempdir().unwrap();
441        let target1 = tmp.path().join("target1");
442        let target2 = tmp.path().join("target2");
443        let link = tmp.path().join("link");
444        fs::write(&target1, "v1").unwrap();
445        fs::write(&target2, "v2").unwrap();
446        symlink_bin(&target1, &link).unwrap();
447        symlink_bin(&target2, &link).unwrap();
448        assert_eq!(fs::read_to_string(&link).unwrap(), "v2");
449    }
450}