1use std::path::{Path, PathBuf};
26
27use anyhow::{Context, Result};
28use clap::Args;
29
30use crate::tasks::util::{cmd, fs, http};
31
32#[derive(Debug, Args)]
34pub struct InstallDocToolsArgs {
35 #[arg(long)]
37 pub mdbook_version: String,
38
39 #[arg(long)]
41 pub mdbook_epub_rev: String,
42
43 #[arg(long)]
45 pub mdbook_mermaid_version: String,
46
47 #[arg(long)]
49 pub mdbook_i18n_helpers_version: String,
50
51 #[arg(long)]
55 pub zola_version: Option<String>,
56}
57
58pub 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
86fn 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
113fn 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
159fn 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
198fn 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 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 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
250fn 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
276fn 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
298fn 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
312fn 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
336fn 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
345fn 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}