Skip to main content

idt/installers/
cargo.rs

1use std::path::Path;
2use std::path::PathBuf;
3use std::process::Command;
4
5use rootcause::bail;
6use rootcause::prelude::ResultExt;
7use ytil_sys::rustup::RequestedRustToolchain;
8
9use crate::installers::Installer;
10use crate::installers::run_health_check;
11
12/// Install a declared Cargo or Git tool from Cargo's configured install root.
13///
14/// Cargo's `--root` always appends `bin`, so using a private root would impose a `bin_dir` naming constraint.
15/// Instead, install only the declared tool into Cargo's configured standard root and link that exact binary into
16/// `bin_dir`. Do not enumerate or update other binaries already present in Cargo's home.
17pub struct Cargo<'a> {
18    source_bin_dir: &'a Path,
19    bin_dir: &'a Path,
20    bin_name: &'static str,
21    source: Source,
22    health_check: HealthCheck,
23}
24
25enum Source {
26    Registry {
27        crate_name: &'static str,
28        features: Option<&'static str>,
29        all_features: bool,
30        locked: bool,
31    },
32    Git {
33        repository: &'static str,
34        branch: Option<&'static str>,
35        locked: bool,
36        requested_toolchain: Option<RequestedRustToolchain>,
37        package_name: Option<&'static str>,
38    },
39}
40
41#[derive(Clone, Copy)]
42enum HealthCheck {
43    /// Run the installed binary directly with its health-check arguments.
44    Binary(&'static [&'static str]),
45    /// Run a Cargo subcommand because the installed binary expects Cargo's dispatch prefix.
46    CargoSubcommand(&'static str),
47}
48
49impl<'a> Cargo<'a> {
50    pub const fn registry(
51        source_bin_dir: &'a Path,
52        bin_dir: &'a Path,
53        bin_name: &'static str,
54        crate_name: &'static str,
55    ) -> Self {
56        Self {
57            source_bin_dir,
58            bin_dir,
59            bin_name,
60            source: Source::Registry {
61                crate_name,
62                features: None,
63                all_features: false,
64                locked: false,
65            },
66            health_check: HealthCheck::Binary(&["--version"]),
67        }
68    }
69
70    pub const fn registry_with_features(
71        source_bin_dir: &'a Path,
72        bin_dir: &'a Path,
73        bin_name: &'static str,
74        crate_name: &'static str,
75        features: &'static str,
76    ) -> Self {
77        Self {
78            source_bin_dir,
79            bin_dir,
80            bin_name,
81            source: Source::Registry {
82                crate_name,
83                features: Some(features),
84                all_features: false,
85                locked: false,
86            },
87            health_check: HealthCheck::Binary(&["--version"]),
88        }
89    }
90
91    pub const fn locked_registry(
92        source_bin_dir: &'a Path,
93        bin_dir: &'a Path,
94        bin_name: &'static str,
95        crate_name: &'static str,
96    ) -> Self {
97        Self {
98            source_bin_dir,
99            bin_dir,
100            bin_name,
101            source: Source::Registry {
102                crate_name,
103                features: None,
104                all_features: false,
105                locked: true,
106            },
107            health_check: HealthCheck::Binary(&["--version"]),
108        }
109    }
110
111    pub const fn git(
112        source_bin_dir: &'a Path,
113        bin_dir: &'a Path,
114        bin_name: &'static str,
115        repository: &'static str,
116    ) -> Self {
117        Self {
118            source_bin_dir,
119            bin_dir,
120            bin_name,
121            source: Source::Git {
122                repository,
123                branch: None,
124                locked: false,
125                requested_toolchain: None,
126                package_name: None,
127            },
128            health_check: HealthCheck::Binary(&["--version"]),
129        }
130    }
131
132    pub const fn registry_with_all_features(
133        source_bin_dir: &'a Path,
134        bin_dir: &'a Path,
135        bin_name: &'static str,
136        crate_name: &'static str,
137    ) -> Self {
138        Self {
139            source_bin_dir,
140            bin_dir,
141            bin_name,
142            source: Source::Registry {
143                crate_name,
144                features: None,
145                all_features: true,
146                locked: false,
147            },
148            health_check: HealthCheck::Binary(&["--version"]),
149        }
150    }
151
152    pub const fn registry_with_cargo_subcommand(
153        source_bin_dir: &'a Path,
154        bin_dir: &'a Path,
155        bin_name: &'static str,
156        crate_name: &'static str,
157        cargo_subcommand: &'static str,
158    ) -> Self {
159        let mut installer = Self::registry(source_bin_dir, bin_dir, bin_name, crate_name);
160        installer.health_check = HealthCheck::CargoSubcommand(cargo_subcommand);
161        installer
162    }
163
164    pub const fn registry_with_health_check_args(
165        source_bin_dir: &'a Path,
166        bin_dir: &'a Path,
167        bin_name: &'static str,
168        crate_name: &'static str,
169        health_check_args: &'static [&'static str],
170    ) -> Self {
171        let mut installer = Self::registry(source_bin_dir, bin_dir, bin_name, crate_name);
172        installer.health_check = HealthCheck::Binary(health_check_args);
173        installer
174    }
175
176    pub const fn nightly_git(
177        source_bin_dir: &'a Path,
178        bin_dir: &'a Path,
179        bin_name: &'static str,
180        repository: &'static str,
181        branch: &'static str,
182    ) -> Self {
183        Self {
184            source_bin_dir,
185            bin_dir,
186            bin_name,
187            source: Source::Git {
188                repository,
189                branch: Some(branch),
190                locked: true,
191                requested_toolchain: Some(RequestedRustToolchain::Nightly(None)),
192                package_name: Some(bin_name),
193            },
194            health_check: HealthCheck::Binary(&["--version"]),
195        }
196    }
197}
198
199impl Installer for Cargo<'_> {
200    fn bin_name(&self) -> &'static str {
201        self.bin_name
202    }
203
204    fn should_verify_checksum(&self) -> bool {
205        false
206    }
207
208    fn install(&self) -> rootcause::Result<()> {
209        let toolchain = match &self.source {
210            Source::Git {
211                requested_toolchain: Some(requested_toolchain),
212                ..
213            } => Some(ytil_sys::rustup::find_latest_installed_rust_toolchain(
214                requested_toolchain,
215            )?),
216            Source::Registry { .. }
217            | Source::Git {
218                requested_toolchain: None,
219                ..
220            } => None,
221        };
222        let mut command = ytil_cmd::silent_cmd("cargo");
223        if let Some(toolchain) = toolchain {
224            command.arg(format!("+{toolchain}"));
225        }
226        command.args(["install", "--force"]);
227
228        match &self.source {
229            Source::Registry {
230                crate_name,
231                features,
232                all_features,
233                locked,
234            } => {
235                command.arg(*crate_name);
236                if let Some(features) = features {
237                    command.args(["--features", *features]);
238                }
239                if *all_features {
240                    command.arg("--all-features");
241                }
242                if *locked {
243                    command.arg("--locked");
244                }
245            }
246            Source::Git {
247                repository,
248                branch,
249                locked,
250                package_name,
251                ..
252            } => {
253                command.args(["--git", *repository]);
254                if let Some(branch) = branch {
255                    command.args(["--branch", *branch]);
256                }
257                if *locked {
258                    command.arg("--locked");
259                }
260                if let Some(package_name) = package_name {
261                    command.arg(*package_name);
262                }
263            }
264        }
265
266        command
267            .status()
268            .context("failed to spawn cargo install")?
269            .exit_ok()
270            .context("cargo install failed")
271            .attach_with(|| format!("tool={}", self.bin_name()))
272            .attach_with(|| format!("command={command:?}"))?;
273
274        let cargo_binary = self
275            .source_bin_dir
276            .join(self.bin_name())
277            .canonicalize()
278            .context("could not resolve Cargo-installed binary")?;
279        ytil_sys::file::ln_sf(&cargo_binary, &self.bin_dir.join(self.bin_name()))?;
280        ytil_sys::file::chmod_x(cargo_binary)?;
281
282        Ok(())
283    }
284
285    fn health_check(&self) -> Option<rootcause::Result<String>> {
286        let command = self.health_check_command();
287        Some(run_health_check(command))
288    }
289}
290
291impl Cargo<'_> {
292    fn health_check_command(&self) -> Command {
293        match self.health_check {
294            HealthCheck::Binary(args) => {
295                let mut command = Command::new(self.bin_dir.join(self.bin_name()));
296                command.args(args);
297                command
298            }
299            HealthCheck::CargoSubcommand(subcommand) => {
300                let mut command = Command::new("cargo");
301                command.args([subcommand, "--version"]);
302                command
303            }
304        }
305    }
306}
307
308/// Resolve Cargo's binary directory once before installers begin running.
309pub fn bin_dir() -> rootcause::Result<PathBuf> {
310    Ok(cargo_install_root()?.join("bin"))
311}
312
313/// Resolve Cargo's install-root precedence without scanning installed packages or invoking Cargo.
314fn cargo_install_root() -> rootcause::Result<PathBuf> {
315    if let Some(install_root) = std::env::var_os("CARGO_INSTALL_ROOT") {
316        return Ok(PathBuf::from(install_root));
317    }
318
319    let cargo_home = cargo_home()?;
320    if let Some(install_root) = cargo_config_install_root(&cargo_home)? {
321        return Ok(install_root);
322    }
323
324    Ok(cargo_home)
325}
326
327fn cargo_home() -> rootcause::Result<PathBuf> {
328    if let Some(cargo_home) = std::env::var_os("CARGO_HOME") {
329        return Ok(PathBuf::from(cargo_home));
330    }
331
332    ytil_sys::dir::build_home_path(&[".cargo"])
333}
334
335/// Read `[install].root` from Cargo's standard home configuration file.
336fn cargo_config_install_root(cargo_home: &Path) -> rootcause::Result<Option<PathBuf>> {
337    let config_path = cargo_home.join("config.toml");
338    if !config_path.is_file() {
339        return Ok(None);
340    }
341    let config = std::fs::read_to_string(&config_path)
342        .context("could not read Cargo configuration")
343        .attach_with(|| format!("config={}", config_path.display()))?;
344    let config: toml::Value = toml::from_str(&config)
345        .context("could not parse Cargo configuration")
346        .attach_with(|| format!("config={}", config_path.display()))?;
347    let Some(install_root) = config
348        .get("install")
349        .and_then(toml::Value::as_table)
350        .and_then(|install| install.get("root"))
351    else {
352        return Ok(None);
353    };
354    let Some(install_root) = install_root.as_str() else {
355        bail!("install.root in {} must be a path string", config_path.display());
356    };
357
358    resolve_config_path(&config_path, install_root).map(Some)
359}
360
361fn resolve_config_path(config_path: &Path, install_root: &str) -> rootcause::Result<PathBuf> {
362    let install_root = PathBuf::from(install_root);
363    if install_root.is_absolute() {
364        return Ok(install_root);
365    }
366
367    let Some(config_dir) = config_path.parent() else {
368        bail!("Cargo configuration path has no parent: {}", config_path.display());
369    };
370    Ok(config_dir.join(install_root))
371}
372
373#[cfg(test)]
374mod tests {
375    use std::ffi::OsStr;
376
377    use super::*;
378
379    #[test]
380    fn test_cargo_health_check_command_when_cargo_subcommand_is_selected_uses_cargo_dispatch() {
381        let source_bin_dir = Path::new("/source/bin");
382        let bin_dir = Path::new("/target/bin");
383        let installer = Cargo::registry_with_cargo_subcommand(
384            source_bin_dir,
385            bin_dir,
386            "cargo-llvm-cov",
387            "cargo-llvm-cov",
388            "llvm-cov",
389        );
390
391        let command = installer.health_check_command();
392
393        assert_eq!(command.get_program(), OsStr::new("cargo"));
394        assert_eq!(
395            command.get_args().collect::<Vec<_>>(),
396            vec![OsStr::new("llvm-cov"), OsStr::new("--version")]
397        );
398    }
399
400    #[test]
401    fn test_cargo_health_check_command_when_custom_args_are_selected_uses_direct_binary() {
402        let source_bin_dir = Path::new("/source/bin");
403        let bin_dir = Path::new("/target/bin");
404        let installer = Cargo::registry_with_health_check_args(source_bin_dir, bin_dir, "pv", "pv", &["--help"]);
405
406        let command = installer.health_check_command();
407
408        assert_eq!(command.get_program(), bin_dir.join("pv"));
409        assert_eq!(command.get_args().collect::<Vec<_>>(), vec![OsStr::new("--help")]);
410    }
411}