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