Skip to main content

abbaye/builders/
cargo.rs

1use std::{
2    path::{Path, PathBuf},
3    process::Stdio,
4};
5
6use miette::{IntoDiagnostic, Result, miette};
7use serde::Deserialize;
8use tokio::io::{AsyncBufReadExt, BufReader};
9use tokio::process::Command;
10
11use super::hash_file;
12use crate::builders::{ArtifactPath, Builder};
13
14/// Configuration for [`CargoBuilder`].
15#[derive(Debug, Default, Clone, Deserialize)]
16pub struct CargoBuilderConfig {
17    /// Cargo target triples to build for (e.g. `"x86_64-unknown-linux-musl"`).
18    ///
19    /// Each entry is passed as `--target <triple>` in a separate `cargo build`
20    /// invocation. When the list is empty, cargo builds for the host target.
21    #[serde(default)]
22    pub targets: Vec<String>,
23
24    /// Optional path to the Cargo.toml manifest.
25    ///
26    /// Passed verbatim as `--manifest-path`. Defaults to the manifest in the
27    /// current working directory when absent.
28    pub manifest_path: Option<PathBuf>,
29
30    /// Restrict collected artifacts to these binary (or cdylib) target names.
31    ///
32    /// When empty every artifact produced by a **workspace member or local
33    /// path-dependency** is kept. Use this to avoid picking up extra binaries
34    /// from dev-tools or examples that live in the same workspace.
35    ///
36    /// ```toml
37    /// [[builders]]
38    /// type = "cargo"
39    /// bins = ["my_binary", "my_cdylib"]
40    /// ```
41    #[serde(default)]
42    pub bins: Vec<String>,
43}
44
45/// Runs `cargo build --release` and returns the produced artifacts.
46pub struct CargoBuilder;
47
48impl Builder for CargoBuilder {
49    type ConfigType = CargoBuilderConfig;
50
51    async fn build(&self, config: Self::ConfigType) -> Result<Vec<ArtifactPath>> {
52        let version = read_crate_version(config.manifest_path.as_deref()).await?;
53
54        if config.targets.is_empty() {
55            let host = get_host_target().await?;
56            run_cargo_build(&config, None, &host, &version).await
57        } else {
58            let mut all_artifacts = Vec::new();
59            for target in &config.targets {
60                let artifacts =
61                    run_cargo_build(&config, Some(target.as_str()), target, &version).await?;
62                all_artifacts.extend(artifacts);
63            }
64            Ok(all_artifacts)
65        }
66    }
67}
68
69// ── Internals ────────────────────────────────────────────────────────────────
70
71/// Minimal representation of the JSON messages emitted by
72/// `cargo build --message-format=json`.
73#[derive(Deserialize)]
74struct CargoMessage {
75    reason: String,
76    /// Identifies the crate that produced this artifact.
77    /// Local packages (workspace members and path-deps) always contain
78    /// `path+file://`; external registry/git crates do not.
79    package_id: Option<String>,
80    target: Option<CargoMessageTarget>,
81    filenames: Option<Vec<String>>,
82}
83
84#[derive(Deserialize)]
85struct CargoMessageTarget {
86    name: String,
87}
88
89/// Spawn `cargo build --release --message-format=json [--target <triple>]
90/// [--manifest-path <path>]` and collect every artifact path from the
91/// `compiler-artifact` messages.
92async fn run_cargo_build(
93    config: &CargoBuilderConfig,
94    target: Option<&str>,
95    triple: &str,
96    version: &str,
97) -> Result<Vec<ArtifactPath>> {
98    let mut cmd = Command::new("cargo");
99    cmd.args(["build", "--release", "--message-format=json"]);
100
101    if let Some(t) = target {
102        cmd.args(["--target", t]);
103    }
104
105    if let Some(manifest) = &config.manifest_path {
106        cmd.arg("--manifest-path").arg(manifest);
107    }
108
109    let mut child = cmd
110        .stdout(Stdio::piped())
111        .stderr(Stdio::inherit()) // let cargo's human-readable errors reach the terminal
112        .spawn()
113        .into_diagnostic()?;
114
115    let stdout = child.stdout.take().expect("stdout was piped");
116    let mut lines = BufReader::new(stdout).lines();
117
118    let mut artifacts = Vec::new();
119
120    while let Some(line) = lines.next_line().await.into_diagnostic()? {
121        let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else {
122            continue;
123        };
124
125        if msg.reason != "compiler-artifact" {
126            continue;
127        }
128
129        // Skip artifacts from external (registry / git) dependencies.
130        // Both the old package_id format ("name ver (path+file://...)") and the
131        // newer spec format ("path+file://...#name@ver") contain "path+file://"
132        // for every local crate, so a substring check is version-agnostic.
133        if !msg
134            .package_id
135            .as_deref()
136            .is_some_and(|id| id.contains("path+file://"))
137        {
138            continue;
139        }
140
141        // If the caller named specific targets, restrict to those.
142        if !config.bins.is_empty() {
143            let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or("");
144            if !config.bins.iter().any(|b| b == target_name) {
145                continue;
146            }
147        }
148
149        for filename in msg.filenames.unwrap_or_default() {
150            let path = PathBuf::from(&filename);
151
152            // Skip rlib / rmeta files; we only want executables and cdylibs.
153            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
154            if matches!(ext, "rlib" | "rmeta" | "d") {
155                continue;
156            }
157
158            if !path.exists() {
159                continue;
160            }
161
162            // Name the artifact as `{stem}-{version}-{triple}{ext}` so that
163            // binaries for different targets can coexist in the same dist dir.
164            let stem = path
165                .file_stem()
166                .map(|s| s.to_string_lossy().into_owned())
167                .unwrap_or_default();
168            let dot_ext = path
169                .extension()
170                .map(|e| format!(".{}", e.to_string_lossy()))
171                .unwrap_or_default();
172            let name = format!("{stem}-{version}-{triple}{dot_ext}");
173
174            let hash = Some(hash_file(&path).await?);
175
176            artifacts.push(ArtifactPath { path, name, hash });
177        }
178    }
179
180    let status = child.wait().await.into_diagnostic()?;
181
182    if !status.success() {
183        return Err(miette!(
184            "cargo build --release failed with exit status: {status}"
185        ));
186    }
187
188    Ok(artifacts)
189}
190
191/// Query `rustc -vV` and return the host target triple
192/// (e.g. `"x86_64-unknown-linux-gnu"`).
193async fn get_host_target() -> Result<String> {
194    let output = Command::new("rustc")
195        .args(["-vV"])
196        .output()
197        .await
198        .into_diagnostic()?;
199
200    if !output.status.success() {
201        return Err(miette!("rustc -vV failed"));
202    }
203
204    let stdout = String::from_utf8(output.stdout).into_diagnostic()?;
205    stdout
206        .lines()
207        .find(|l| l.starts_with("host:"))
208        .and_then(|l| l.split_whitespace().nth(1))
209        .map(str::to_owned)
210        .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output"))
211}
212
213/// Read `[package].version` from the Cargo.toml at `manifest_path`
214/// (defaults to `Cargo.toml` in the current directory).
215async fn read_crate_version(manifest_path: Option<&Path>) -> Result<String> {
216    let path = manifest_path.unwrap_or(Path::new("Cargo.toml"));
217    let content = tokio::fs::read_to_string(path).await.into_diagnostic()?;
218
219    #[derive(Deserialize)]
220    struct Manifest {
221        package: Option<Package>,
222    }
223    #[derive(Deserialize)]
224    struct Package {
225        version: Option<String>,
226    }
227
228    let manifest: Manifest = toml::from_str(&content).into_diagnostic()?;
229    manifest
230        .package
231        .ok_or_else(|| miette!("{} has no [package] section", path.display()))?
232        .version
233        .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))
234}
235
236// ── CargoDocBuilder ──────────────────────────────────────────────────────────
237
238/// Configuration for [`CargoDocBuilder`].
239#[derive(Debug, Default, Clone, Deserialize)]
240pub struct CargoDocBuilderConfig {
241    /// Optional path to the Cargo.toml manifest.
242    ///
243    /// Passed verbatim as `--manifest-path`. Defaults to the manifest in the
244    /// current working directory when absent.
245    pub manifest_path: Option<PathBuf>,
246
247    /// Skip building documentation for dependencies (`--no-deps`).
248    #[serde(default)]
249    pub no_deps: bool,
250}
251
252/// Runs `cargo doc` and returns the `index.html` entry point for each
253/// documented crate as an artifact.
254pub struct CargoDocBuilder;
255
256impl Builder for CargoDocBuilder {
257    type ConfigType = CargoDocBuilderConfig;
258
259    async fn build(&self, config: Self::ConfigType) -> Result<Vec<ArtifactPath>> {
260        let mut cmd = Command::new("cargo");
261        cmd.arg("doc");
262
263        if config.no_deps {
264            cmd.arg("--no-deps");
265        }
266
267        if let Some(manifest) = &config.manifest_path {
268            cmd.arg("--manifest-path").arg(manifest);
269        }
270
271        let status = cmd
272            .stderr(Stdio::inherit())
273            .status()
274            .await
275            .into_diagnostic()?;
276
277        if !status.success() {
278            return Err(miette!("cargo doc failed with exit status: {status}"));
279        }
280
281        // Resolve the doc output directory. When a manifest path is given the
282        // workspace root is its parent directory; otherwise fall back to CWD.
283        let doc_dir = config
284            .manifest_path
285            .as_deref()
286            .and_then(|p| p.parent())
287            .unwrap_or_else(|| std::path::Path::new("."))
288            .join("target/doc");
289
290        if !doc_dir.exists() {
291            return Err(miette!("doc directory not found at {}", doc_dir.display()));
292        }
293
294        // Return the entire target/doc tree as a single artifact so that the
295        // shared rustdoc assets (CSS, JS, fonts, search indices) that live at
296        // the root of target/doc/ are preserved alongside the per-crate HTML.
297        Ok(vec![ArtifactPath {
298            path: doc_dir,
299            name: "doc".to_owned(),
300            hash: None,
301        }])
302    }
303}