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 /// Crate name to install. When `None`, defaults to the last `::`-separated
45 /// segment of [`Self::id`] (the convention used by manifest-derived ids).
46 pub crate_name: Option<String>,
47 /// Cargo features to enable.
48 pub features: Vec<String>,
49 /// `--root` argument: cargo writes the binary to `<install_to>/bin/<crate>`.
50 pub install_to: PathBuf,
51 /// IDs of actions that must complete before this one runs.
52 pub deps: Vec<ActionId>,
53}
54
55impl CargoInstall {
56 fn resolved_crate_name(&self) -> String {
57 if let Some(name) = &self.crate_name {
58 name.clone()
59 } else {
60 self.id
61 .0
62 .rsplit("::")
63 .next()
64 .unwrap_or(&self.id.0)
65 .to_string()
66 }
67 }
68
69 /// Where cargo writes the binary: `<install_to>/bin/<crate><EXE_SUFFIX>`.
70 /// Single source of truth for `execute` (which reads it back to hash it)
71 /// and `output_present` (which only checks it exists).
72 fn installed_bin(&self) -> PathBuf {
73 let bin_name = format!(
74 "{}{}",
75 self.resolved_crate_name(),
76 std::env::consts::EXE_SUFFIX
77 );
78 expand_tilde(&self.install_to).join("bin").join(bin_name)
79 }
80}
81
82#[async_trait]
83impl Action for CargoInstall {
84 fn id(&self) -> ActionId {
85 self.id.clone()
86 }
87
88 fn deps(&self) -> Vec<ActionId> {
89 self.deps.clone()
90 }
91
92 async fn input_hash(&self, ctx: &Ctx) -> Result<Sha256, BuildError> {
93 let mut h = ShaHasher::new();
94 match &self.source {
95 CargoSource::Git { url, branch } => {
96 h.update(b"git:");
97 h.update(url.as_bytes());
98 h.update(b":");
99 h.update(branch.as_deref().unwrap_or("").as_bytes());
100 }
101 CargoSource::Path { path } => {
102 // The tree's *content*, not its name. Hashing the path
103 // string alone made every local edit invisible: the hash
104 // never moved, so `sync` reported `up to date` while the
105 // installed binary was stale (issue #73).
106 let expanded = expand_tilde(path);
107 h.update(b"path:");
108 if expanded.exists() {
109 h.update(tree_digest(&expanded)?.0);
110 } else {
111 // Pre-clone grace: a sibling `git-clone` earlier in the
112 // same node materializes this tree, but planning runs
113 // before any execution. Hash a deterministic marker
114 // rather than failing the whole plan — the action is
115 // stale regardless, runs after the clone, and the next
116 // sync digests the real content. Same treatment as
117 // `docker` and federation.
118 h.update(b"pre-clone");
119 }
120 }
121 }
122 platform_tag(&mut h);
123 for f in &self.features {
124 h.update(b":feat:");
125 h.update(f.as_bytes());
126 }
127 h.update(b":crate:");
128 h.update(self.resolved_crate_name().as_bytes());
129
130 // Mix in `cargo --version` so a toolchain bump invalidates the cache.
131 let mut cmd = Command::new("cargo");
132 cmd.arg("--version");
133 let v = run_with_cancel(cmd, &ctx.cancel).await?;
134 if !v.status.success() {
135 return Err(BuildError::UpstreamUnreachable(
136 "cargo --version failed".to_string(),
137 ));
138 }
139 h.update(b":cargo:");
140 h.update(&v.stdout);
141 Ok(Sha256(h.finalize().into()))
142 }
143
144 async fn execute(&self, ctx: &Ctx) -> Result<BuildOutput, BuildError> {
145 let install_root = expand_tilde(&self.install_to);
146 let install_to = install_root.to_str().ok_or_else(|| {
147 BuildError::Io(format!("non-utf8 install_to: {}", install_root.display()))
148 })?;
149
150 let mut cmd = Command::new("cargo");
151 cmd.arg("install");
152 match &self.source {
153 CargoSource::Git { url, branch } => {
154 cmd.args(["--git", url]);
155 if let Some(b) = branch {
156 cmd.args(["--branch", b]);
157 }
158 }
159 CargoSource::Path { path } => {
160 let p = path.to_str().ok_or_else(|| {
161 BuildError::Io(format!("non-utf8 source path: {}", path.display()))
162 })?;
163 cmd.args(["--path", p]);
164 }
165 }
166 // Use `--bin <NAME>` instead of the positional `<CRATE>` argument:
167 // the positional form filters by *package* name and rejects any
168 // mismatch, so packages that expose multiple binaries (or use a
169 // package name different from the binary name) can't be installed.
170 // `--bin` filters by binary target name and works in both cases.
171 let crate_name = self.resolved_crate_name();
172 cmd.args(["--bin", &crate_name]);
173 if !self.features.is_empty() {
174 cmd.args(["--features", &self.features.join(",")]);
175 }
176 cmd.args(["--locked", "--force", "--root", install_to]);
177
178 check_status(&run_with_cancel(cmd, &ctx.cancel).await?)?;
179
180 // output_hash = sha256(installed binary file content). Fail loudly if
181 // the binary cannot be read — silent fallback would mask cargo regressions.
182 // Use `install_root` (the tilde-expanded path) to match where cargo
183 // actually wrote the binary; reading from `self.install_to` would
184 // otherwise look up a literal `~` directory and always fail.
185 //
186 // Windows binaries have `.exe` suffix; `EXE_SUFFIX` is `""` on Unix.
187 // `tokio::fs::read` releases the async worker for the (potentially
188 // multi-MB) read instead of blocking it like `std::fs::read` did.
189 let bin = self.installed_bin();
190 let bytes = tokio::fs::read(&bin)
191 .await
192 .map_err(|e| BuildError::Io(format!("read installed binary {}: {e}", bin.display())))?;
193 let mut h = ShaHasher::new();
194 h.update(&bytes);
195 Ok(BuildOutput {
196 output_hash: Sha256(h.finalize().into()),
197 stdout: format!("installed -> {}", bin.display()),
198 })
199 }
200
201 /// The installed binary must still be on this machine. A cached
202 /// `Success` alone proves only that *some* machine built it.
203 async fn output_present(&self, _ctx: &Ctx) -> bool {
204 self.installed_bin().is_file()
205 }
206}