1use std::path::{Path, PathBuf};
26
27use anyhow::{Context, Result};
28use clap::Args;
29
30use crate::tasks::util::{cmd, fs, http, workspace};
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_rev: 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_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
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, 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
249fn 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
275fn 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
297fn 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
311fn 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
335fn 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
344fn 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}