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