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::source_hash::{platform_tag, tree_digest};
10use crate::util::{check_status, run_with_cancel};
11use async_trait::async_trait;
12use repolith_core::action::Action;
13use repolith_core::types::{ActionId, BuildError, BuildOutput, Ctx, Sha256};
14use sha2::{Digest, Sha256 as ShaHasher};
15use std::path::PathBuf;
16use tokio::process::Command;
17
18/// Where `cargo install` should pull the crate from.
19#[derive(Clone, Debug)]
20pub enum CargoSource {
21    /// `cargo install --git <url>` (optionally `--branch <branch>`).
22    Git {
23        /// Remote git URL.
24        url: String,
25        /// Optional branch override; `None` lets cargo pick the default branch.
26        branch: Option<String>,
27    },
28    /// `cargo install --path <path>` — local source tree.
29    Path {
30        /// Filesystem path to the crate root (must contain `Cargo.toml`).
31        path: PathBuf,
32    },
33}
34
35/// Action that installs a binary crate via `cargo install`.
36///
37/// `output_hash` is the SHA-256 of the resulting binary file's content,
38/// so a successful re-install with identical inputs yields a stable hash.
39pub struct CargoInstall {
40    /// Action identifier for the plan.
41    pub id: ActionId,
42    /// Where the crate source lives.
43    pub source: CargoSource,
44    /// Binary target to install (`--bin`). When `None`, defaults to the last
45    /// `::`-separated segment of [`Self::id`] (the convention used by
46    /// manifest-derived ids).
47    pub crate_name: Option<String>,
48    /// Package to select from the source, when it holds more than one
49    /// (cargo's positional `[CRATE]` argument). `None` leaves the choice to
50    /// cargo — see the argv comment in [`Action::execute`] for why this has
51    /// no implicit default.
52    pub package: Option<String>,
53    /// Cargo profile (`--profile`). `None` leaves cargo's default, release.
54    pub profile: Option<String>,
55    /// Cargo features to enable.
56    pub features: Vec<String>,
57    /// `--root` argument: cargo writes the binary to `<install_to>/bin/<crate>`.
58    pub install_to: PathBuf,
59    /// IDs of actions that must complete before this one runs.
60    pub deps: Vec<ActionId>,
61}
62
63impl CargoInstall {
64    fn resolved_crate_name(&self) -> String {
65        if let Some(name) = &self.crate_name {
66            name.clone()
67        } else {
68            self.id
69                .0
70                .rsplit("::")
71                .next()
72                .unwrap_or(&self.id.0)
73                .to_string()
74        }
75    }
76
77    /// Where cargo writes the binary: `<install_to>/bin/<crate><EXE_SUFFIX>`.
78    /// Single source of truth for `execute` (which reads it back to hash it)
79    /// and `output_present` (which only checks it exists).
80    fn installed_bin(&self) -> PathBuf {
81        let bin_name = format!(
82            "{}{}",
83            self.resolved_crate_name(),
84            std::env::consts::EXE_SUFFIX
85        );
86        expand_tilde(&self.install_to).join("bin").join(bin_name)
87    }
88}
89
90#[async_trait]
91impl Action for CargoInstall {
92    fn id(&self) -> ActionId {
93        self.id.clone()
94    }
95
96    fn deps(&self) -> Vec<ActionId> {
97        self.deps.clone()
98    }
99
100    async fn input_hash(&self, ctx: &Ctx) -> Result<Sha256, BuildError> {
101        let mut h = ShaHasher::new();
102        match &self.source {
103            CargoSource::Git { url, branch } => {
104                h.update(b"git:");
105                h.update(url.as_bytes());
106                h.update(b":");
107                h.update(branch.as_deref().unwrap_or("").as_bytes());
108            }
109            CargoSource::Path { path } => {
110                // The tree's *content*, not its name. Hashing the path
111                // string alone made every local edit invisible: the hash
112                // never moved, so `sync` reported `up to date` while the
113                // installed binary was stale (issue #73).
114                let expanded = expand_tilde(path);
115                h.update(b"path:");
116                if expanded.exists() {
117                    h.update(tree_digest(&expanded)?.0);
118                } else {
119                    // Pre-clone grace: a sibling `git-clone` earlier in the
120                    // same node materializes this tree, but planning runs
121                    // before any execution. Hash a deterministic marker
122                    // rather than failing the whole plan — the action is
123                    // stale regardless, runs after the clone, and the next
124                    // sync digests the real content. Same treatment as
125                    // `docker` and federation.
126                    h.update(b"pre-clone");
127                }
128            }
129        }
130        platform_tag(&mut h);
131        for f in &self.features {
132            h.update(b":feat:");
133            h.update(f.as_bytes());
134        }
135        h.update(b":crate:");
136        h.update(self.resolved_crate_name().as_bytes());
137        // Two nodes over the same source that differ only by which package
138        // they select must not share a hash, or the second silently inherits
139        // the first's cached Success.
140        h.update(b":pkg:");
141        h.update(self.package.as_deref().unwrap_or("").as_bytes());
142        // The profile changes the artifact but not where it lands, so
143        // `output_present` cannot tell release from debug. Without this the
144        // cache would report `up to date` after a profile switch, leaving
145        // the other profile's binary installed.
146        h.update(b":profile:");
147        h.update(self.profile.as_deref().unwrap_or("").as_bytes());
148
149        // Mix in `cargo --version` so a toolchain bump invalidates the cache.
150        let mut cmd = Command::new("cargo");
151        cmd.arg("--version");
152        let v = run_with_cancel(cmd, &ctx.cancel).await?;
153        if !v.status.success() {
154            return Err(BuildError::UpstreamUnreachable(
155                "cargo --version failed".to_string(),
156            ));
157        }
158        h.update(b":cargo:");
159        h.update(&v.stdout);
160        Ok(Sha256(h.finalize().into()))
161    }
162
163    async fn execute(&self, ctx: &Ctx) -> Result<BuildOutput, BuildError> {
164        let install_root = expand_tilde(&self.install_to);
165        let install_to = install_root.to_str().ok_or_else(|| {
166            BuildError::Io(format!("non-utf8 install_to: {}", install_root.display()))
167        })?;
168
169        let mut cmd = Command::new("cargo");
170        cmd.arg("install");
171        match &self.source {
172            CargoSource::Git { url, branch } => {
173                cmd.args(["--git", url]);
174                if let Some(b) = branch {
175                    cmd.args(["--branch", b]);
176                }
177            }
178            CargoSource::Path { path } => {
179                let p = path.to_str().ok_or_else(|| {
180                    BuildError::Io(format!("non-utf8 source path: {}", path.display()))
181                })?;
182                cmd.args(["--path", p]);
183            }
184        }
185        // Two orthogonal selectors, and both are needed:
186        //
187        // - the positional `[CRATE]` picks the *package* out of the source
188        //   (cargo has no `--package` for `install`);
189        // - `--bin` picks the binary *target* within it.
190        //
191        // Only `--bin` used to be passed, because the positional alone
192        // rejects a package whose name differs from the binary's. That fix
193        // stands — but it left multi-package git repos unreachable, since
194        // cargo resolves the package first and refuses to guess between
195        // several (issue #77). Passing the positional only when `package` is
196        // set keeps the old behavior byte-for-byte for every existing
197        // manifest.
198        if let Some(pkg) = &self.package {
199            cmd.arg(pkg);
200        }
201        let crate_name = self.resolved_crate_name();
202        cmd.args(["--bin", &crate_name]);
203        if let Some(profile) = &self.profile {
204            cmd.args(["--profile", profile]);
205        }
206        if !self.features.is_empty() {
207            cmd.args(["--features", &self.features.join(",")]);
208        }
209        cmd.args(["--locked", "--force", "--root", install_to]);
210
211        check_status(&run_with_cancel(cmd, &ctx.cancel).await?)?;
212
213        // output_hash = sha256(installed binary file content). Fail loudly if
214        // the binary cannot be read — silent fallback would mask cargo regressions.
215        // Use `install_root` (the tilde-expanded path) to match where cargo
216        // actually wrote the binary; reading from `self.install_to` would
217        // otherwise look up a literal `~` directory and always fail.
218        //
219        // Windows binaries have `.exe` suffix; `EXE_SUFFIX` is `""` on Unix.
220        // `tokio::fs::read` releases the async worker for the (potentially
221        // multi-MB) read instead of blocking it like `std::fs::read` did.
222        let bin = self.installed_bin();
223        let bytes = tokio::fs::read(&bin)
224            .await
225            .map_err(|e| BuildError::Io(format!("read installed binary {}: {e}", bin.display())))?;
226        let mut h = ShaHasher::new();
227        h.update(&bytes);
228        Ok(BuildOutput {
229            output_hash: Sha256(h.finalize().into()),
230            stdout: format!("installed -> {}", bin.display()),
231        })
232    }
233
234    /// The installed binary must still be on this machine. A cached
235    /// `Success` alone proves only that *some* machine built it.
236    async fn output_present(&self, _ctx: &Ctx) -> bool {
237        self.installed_bin().is_file()
238    }
239}