Skip to main content

waterui_cli/toolchain/
cargo_helpers.rs

1//! Cargo-installed helper binaries the CLI's workflows invoke.
2//!
3//! These are binaries cargo puts on `PATH` (an entry under
4//! `$CARGO_HOME/bin`), not rustup components: `cargo-nextest` for
5//! `water bench`, `espflash`/`ldproxy` for the ESP32 backend. The check probes
6//! `PATH` and the repair is the `cargo install`/`cargo binstall` command that
7//! produces the binary.
8
9use crate::{
10    toolchain::{Host, Installation, Toolchain, ToolchainError},
11    utils::CommandError,
12};
13
14/// A required set of cargo-installed helper binaries on `PATH`.
15#[derive(Debug, Clone)]
16pub struct CargoHelpers {
17    required: Vec<String>,
18}
19
20impl CargoHelpers {
21    /// Check that each of `required` binaries resolves on `PATH`.
22    #[must_use]
23    pub fn new(required: impl IntoIterator<Item = impl Into<String>>) -> Self {
24        Self {
25            required: required.into_iter().map(Into::into).collect(),
26        }
27    }
28}
29
30/// Installation plan installing missing helper crates with cargo.
31#[derive(Debug, Clone)]
32pub struct CargoHelpersInstallation {
33    crates: Vec<String>,
34}
35
36impl CargoHelpersInstallation {
37    /// Plan `cargo install` (or `cargo binstall` when available) for `crates`.
38    pub(crate) const fn new(crates: Vec<String>) -> Self {
39        Self { crates }
40    }
41
42    /// The missing helpers and their install command, for the doctor item's
43    /// message.
44    #[must_use]
45    pub fn describe(&self) -> String {
46        format!(
47            "cargo helpers not on PATH: {} (installed with `cargo install {}`)",
48            self.crates.join(", "),
49            self.crates.join(" ")
50        )
51    }
52}
53
54/// Errors from `cargo install`/`cargo binstall` helper installation.
55#[derive(Debug, thiserror::Error)]
56pub enum FailToInstallCargoHelpers {
57    /// cargo was not found.
58    #[error(
59        "cargo is required to install cargo helpers but is not on PATH; fix the `rust` doctor item first."
60    )]
61    CargoNotFound,
62    /// A helper crate could not be installed.
63    #[error("Failed to install `{krate}` with cargo: {source}")]
64    Install {
65        /// Crate that failed to install.
66        krate: String,
67        /// Underlying command error.
68        source: CommandError,
69    },
70}
71
72impl Toolchain for CargoHelpers {
73    type Installation = CargoHelpersInstallation;
74
75    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
76        let mut missing = Vec::new();
77        for binary in &self.required {
78            if host.which(binary).await.is_err() {
79                missing.push(binary.clone());
80            }
81        }
82        if missing.is_empty() {
83            return Ok(());
84        }
85        if host.which("cargo").await.is_err() {
86            return Err(ToolchainError::unfixable(
87                format!("cargo helpers not on PATH: {}", missing.join(", ")),
88                format!(
89                    "Install Rust via rustup (fix the `rust` doctor item first), then run `cargo install {}`.",
90                    missing.join(" ")
91                ),
92            ));
93        }
94        Err(ToolchainError::fixable(CargoHelpersInstallation::new(
95            missing,
96        )))
97    }
98}
99
100impl Installation for CargoHelpersInstallation {
101    type Error = FailToInstallCargoHelpers;
102
103    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
104        if host.which("cargo").await.is_err() {
105            return Err(FailToInstallCargoHelpers::CargoNotFound);
106        }
107        // `cargo binstall` fetches a prebuilt binary instead of compiling
108        // from source; use it when the host carries it.
109        let binstall = host.which("cargo-binstall").await.is_ok();
110        for krate in &self.crates {
111            if binstall {
112                host.run("cargo", ["binstall", "--no-confirm", krate.as_str()])
113                    .await
114            } else {
115                host.run("cargo", ["install", "--locked", krate.as_str()])
116                    .await
117            }
118            .map_err(|source| FailToInstallCargoHelpers::Install {
119                krate: krate.clone(),
120                source,
121            })?;
122        }
123        Ok(())
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::CargoHelpers;
130    use crate::toolchain::testing::TestMachine;
131    use crate::toolchain::{Toolchain, ToolchainError};
132
133    #[test]
134    fn helpers_ok_when_on_path() {
135        let machine = TestMachine::new();
136        machine.install("cargo-nextest");
137        let host = machine.host(Vec::<(String, String)>::new());
138        smol::block_on(CargoHelpers::new(["cargo-nextest"]).check(&host))
139            .expect("helper on PATH must be ok");
140    }
141
142    #[test]
143    fn helpers_fixable_when_cargo_present() {
144        let machine = TestMachine::new();
145        machine.install("cargo");
146        let host = machine.host(Vec::<(String, String)>::new());
147        let result = smol::block_on(CargoHelpers::new(["cargo-nextest"]).check(&host));
148        assert!(
149            matches!(result, Err(ToolchainError::Fixable(_))),
150            "missing helper with cargo present must be fixable: {result:?}"
151        );
152    }
153
154    #[test]
155    fn helpers_unfixable_without_cargo() {
156        let machine = TestMachine::new();
157        let host = machine.host(Vec::<(String, String)>::new());
158        let result = smol::block_on(CargoHelpers::new(["cargo-nextest"]).check(&host));
159        match &result {
160            Err(ToolchainError::Unfixable(error)) => {
161                assert!(
162                    error.suggestion().contains("cargo install"),
163                    "the manual repair must name cargo install: {}",
164                    error.suggestion()
165                );
166            }
167            other => panic!("missing helper without cargo must be manual: {other:?}"),
168        }
169    }
170}