Skip to main content

repolith_actions/
cargo_install.rs

1//! `CargoInstall` action — wraps `cargo install --git|--path --locked --force --root <dir>`.
2//!
3//! The standard install pattern for any Rust binary crate. Like
4//! [`crate::git_clone::GitClone`], the spawned `cargo` subprocess is raced
5//! against `ctx.cancel` via `tokio::select!` so the orchestrator's
6//! `FailFast` mode can short-circuit a long-running build.
7
8use crate::paths::expand_tilde;
9use crate::util::{check_status, run_with_cancel};
10use async_trait::async_trait;
11use repolith_core::action::Action;
12use repolith_core::types::{ActionId, BuildError, BuildOutput, Ctx, Sha256};
13use sha2::{Digest, Sha256 as ShaHasher};
14use std::path::PathBuf;
15use tokio::process::Command;
16
17/// Where `cargo install` should pull the crate from.
18#[derive(Clone, Debug)]
19pub enum CargoSource {
20    /// `cargo install --git <url>` (optionally `--branch <branch>`).
21    Git {
22        /// Remote git URL.
23        url: String,
24        /// Optional branch override; `None` lets cargo pick the default branch.
25        branch: Option<String>,
26    },
27    /// `cargo install --path <path>` — local source tree.
28    Path {
29        /// Filesystem path to the crate root (must contain `Cargo.toml`).
30        path: PathBuf,
31    },
32}
33
34/// Action that installs a binary crate via `cargo install`.
35///
36/// `output_hash` is the SHA-256 of the resulting binary file's content,
37/// so a successful re-install with identical inputs yields a stable hash.
38pub struct CargoInstall {
39    /// Action identifier for the plan.
40    pub id: ActionId,
41    /// Where the crate source lives.
42    pub source: CargoSource,
43    /// Crate name to install. When `None`, defaults to the last `::`-separated
44    /// segment of [`Self::id`] (the convention used by manifest-derived ids).
45    pub crate_name: Option<String>,
46    /// Cargo features to enable.
47    pub features: Vec<String>,
48    /// `--root` argument: cargo writes the binary to `<install_to>/bin/<crate>`.
49    pub install_to: PathBuf,
50    /// IDs of actions that must complete before this one runs.
51    pub deps: Vec<ActionId>,
52}
53
54impl CargoInstall {
55    fn resolved_crate_name(&self) -> String {
56        if let Some(name) = &self.crate_name {
57            name.clone()
58        } else {
59            self.id
60                .0
61                .rsplit("::")
62                .next()
63                .unwrap_or(&self.id.0)
64                .to_string()
65        }
66    }
67}
68
69#[async_trait]
70impl Action for CargoInstall {
71    fn id(&self) -> ActionId {
72        self.id.clone()
73    }
74
75    fn deps(&self) -> Vec<ActionId> {
76        self.deps.clone()
77    }
78
79    async fn input_hash(&self, ctx: &Ctx) -> Result<Sha256, BuildError> {
80        let mut h = ShaHasher::new();
81        match &self.source {
82            CargoSource::Git { url, branch } => {
83                h.update(b"git:");
84                h.update(url.as_bytes());
85                h.update(b":");
86                h.update(branch.as_deref().unwrap_or("").as_bytes());
87            }
88            CargoSource::Path { path } => {
89                h.update(b"path:");
90                h.update(path.to_string_lossy().as_bytes());
91            }
92        }
93        for f in &self.features {
94            h.update(b":feat:");
95            h.update(f.as_bytes());
96        }
97        h.update(b":crate:");
98        h.update(self.resolved_crate_name().as_bytes());
99
100        // Mix in `cargo --version` so a toolchain bump invalidates the cache.
101        let mut cmd = Command::new("cargo");
102        cmd.arg("--version");
103        let v = run_with_cancel(cmd, &ctx.cancel).await?;
104        if !v.status.success() {
105            return Err(BuildError::UpstreamUnreachable(
106                "cargo --version failed".to_string(),
107            ));
108        }
109        h.update(b":cargo:");
110        h.update(&v.stdout);
111        Ok(Sha256(h.finalize().into()))
112    }
113
114    async fn execute(&self, ctx: &Ctx) -> Result<BuildOutput, BuildError> {
115        let install_root = expand_tilde(&self.install_to);
116        let install_to = install_root.to_str().ok_or_else(|| {
117            BuildError::Io(format!("non-utf8 install_to: {}", install_root.display()))
118        })?;
119
120        let mut cmd = Command::new("cargo");
121        cmd.arg("install");
122        match &self.source {
123            CargoSource::Git { url, branch } => {
124                cmd.args(["--git", url]);
125                if let Some(b) = branch {
126                    cmd.args(["--branch", b]);
127                }
128            }
129            CargoSource::Path { path } => {
130                let p = path.to_str().ok_or_else(|| {
131                    BuildError::Io(format!("non-utf8 source path: {}", path.display()))
132                })?;
133                cmd.args(["--path", p]);
134            }
135        }
136        // Use `--bin <NAME>` instead of the positional `<CRATE>` argument:
137        // the positional form filters by *package* name and rejects any
138        // mismatch, so packages that expose multiple binaries (or use a
139        // package name different from the binary name) can't be installed.
140        // `--bin` filters by binary target name and works in both cases.
141        let crate_name = self.resolved_crate_name();
142        cmd.args(["--bin", &crate_name]);
143        if !self.features.is_empty() {
144            cmd.args(["--features", &self.features.join(",")]);
145        }
146        cmd.args(["--locked", "--force", "--root", install_to]);
147
148        check_status(&run_with_cancel(cmd, &ctx.cancel).await?)?;
149
150        // output_hash = sha256(installed binary file content). Fail loudly if
151        // the binary cannot be read — silent fallback would mask cargo regressions.
152        // Use `install_root` (the tilde-expanded path) to match where cargo
153        // actually wrote the binary; reading from `self.install_to` would
154        // otherwise look up a literal `~` directory and always fail.
155        //
156        // Windows binaries have `.exe` suffix; `EXE_SUFFIX` is `""` on Unix.
157        // `tokio::fs::read` releases the async worker for the (potentially
158        // multi-MB) read instead of blocking it like `std::fs::read` did.
159        let bin_name = format!("{crate_name}{}", std::env::consts::EXE_SUFFIX);
160        let bin = install_root.join("bin").join(&bin_name);
161        let bytes = tokio::fs::read(&bin)
162            .await
163            .map_err(|e| BuildError::Io(format!("read installed binary {}: {e}", bin.display())))?;
164        let mut h = ShaHasher::new();
165        h.update(&bytes);
166        Ok(BuildOutput {
167            output_hash: Sha256(h.finalize().into()),
168            stdout: format!("installed -> {}", bin.display()),
169        })
170    }
171}