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}