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    /// 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}