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 tempfile::TempDir;
10use tokio::io::{AsyncBufReadExt, BufReader};
11use tokio::process::Command;
12use tokio::sync::mpsc::UnboundedSender;
13
14use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender};
15
16fn default_parallel() -> bool {
17    true
18}
19
20/// Configuration for [`CargoBuilder`].
21#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
22pub struct CargoBuilderConfig {
23    /// Cargo target triples to build for (e.g. `"x86_64-unknown-linux-musl"`).
24    ///
25    /// Each entry is passed as `--target <triple>` in a separate `cargo build`
26    /// invocation. When the list is empty, cargo builds for the host target.
27    #[serde(default)]
28    pub targets: Vec<String>,
29
30    /// Optional path to the Cargo.toml manifest.
31    ///
32    /// Passed verbatim as `--manifest-path`. Defaults to the manifest in the
33    /// current working directory when absent.
34    pub manifest_path: Option<PathBuf>,
35
36    /// Restrict collected artifacts to these binary (or cdylib) target names.
37    ///
38    /// When empty every artifact produced by a **workspace member or local
39    /// path-dependency** is kept. Use this to avoid picking up extra binaries
40    /// from dev-tools or examples that live in the same workspace.
41    ///
42    /// ```toml
43    /// [[builders]]
44    /// type = "cargo"
45    /// bins = ["my_binary", "my_cdylib"]
46    /// ```
47    #[serde(default)]
48    pub bins: Vec<String>,
49
50    /// Run cross-compilation targets in parallel using isolated temporary
51    /// target directories.
52    ///
53    /// When `true` (the default), each target triple is given its own
54    /// `--target-dir` backed by a [`tempfile::TempDir`], so multiple
55    /// `cargo build` processes can compile simultaneously without contending
56    /// on cargo's file lock (`target/.cargo-lock`).  Compiled artifacts are
57    /// copied to the canonical `target/<triple>/release/` paths and the
58    /// temporary directories are then removed automatically.
59    ///
60    /// Set this to `false` when:
61    ///
62    /// - **Disk space is tight.** Each temporary build tree can occupy several
63    ///   gigabytes for dependency-heavy crates. Four targets running in
64    ///   parallel means roughly four times the peak disk usage of a single
65    ///   build.
66    /// - **Incremental compilation matters.** Temporary target directories
67    ///   always start cold, discarding Rust's incremental cache. Disabling
68    ///   parallelism lets all targets share the persistent `target/` directory
69    ///   and reuse previously compiled artefacts on subsequent runs.
70    /// - **The build host is resource-constrained.** Parallel `cargo build`
71    ///   processes each consume significant CPU and RAM. On CI machines with
72    ///   limited memory, running them sequentially avoids thrashing or
73    ///   out-of-memory failures.
74    /// - **Your cross-compilation toolchain is not concurrency-safe.** Some
75    ///   custom linkers or build-script tools assume exclusive access and may
76    ///   produce corrupt output when invoked concurrently.
77    #[serde(default = "default_parallel")]
78    pub parallel: bool,
79}
80
81impl Default for CargoBuilderConfig {
82    fn default() -> Self {
83        Self {
84            targets: Vec::new(),
85            manifest_path: None,
86            bins: Vec::new(),
87            parallel: default_parallel(),
88        }
89    }
90}
91
92/// Runs `cargo build --release` and returns the produced artifacts.
93pub struct CargoBuilder;
94
95impl Builder for CargoBuilder {
96    type ConfigType = CargoBuilderConfig;
97
98    async fn build(
99        &self,
100        config: Self::ConfigType,
101        abbaye_version: &str,
102        log: LogSender,
103    ) -> Result<Vec<ArtifactPath>> {
104        let crate_version = read_crate_version(config.manifest_path.as_deref()).await?;
105
106        if config.targets.is_empty() {
107            // Single host target: forward stderr lines as plain LogEvent::Line events.
108            let host = get_host_target().await?;
109            let line_tx = line_bridge(log, LogEvent::Line);
110            run_cargo_build(
111                &config,
112                None,
113                &host,
114                &crate_version,
115                abbaye_version,
116                line_tx,
117                None,
118            )
119            .await
120        } else {
121            // Multiple targets: each runs in its own task with its own
122            // temporary target directory so cargo's file lock does not
123            // serialise them.
124            let mut join_set = tokio::task::JoinSet::new();
125
126            for target in &config.targets {
127                let config = config.clone();
128                let crate_version = crate_version.clone();
129                let abbaye_version = abbaye_version.to_owned();
130                let target = target.clone();
131                let log = log.clone();
132
133                join_set.spawn(async move {
134                    // Announce this target as a child task.
135                    let _ = log.send(LogEvent::ChildStart {
136                        id: target.clone(),
137                        label: target.clone(),
138                    });
139
140                    // Bridge: run_cargo_build emits plain Strings; forward
141                    // them as ChildLine events on the parent LogSender.
142                    let target_id = target.clone();
143                    let line_tx = line_bridge(log.clone(), move |l| LogEvent::ChildLine {
144                        id: target_id.clone(),
145                        line: l,
146                    });
147
148                    let result = if config.parallel {
149                        // Give this invocation its own target directory so it
150                        // does not contend with sibling builds on cargo's lock.
151                        let tmpdir = TempDir::new().into_diagnostic()?;
152                        let r = run_cargo_build(
153                            &config,
154                            Some(target.as_str()),
155                            &target,
156                            &crate_version,
157                            &abbaye_version,
158                            line_tx,
159                            Some(tmpdir.path()),
160                        )
161                        .await;
162                        // Copy artifacts to stable paths inside target/ before
163                        // tmpdir is dropped, then let tmpdir clean up.
164                        match r {
165                            Ok(artifacts) => relocate_artifacts(artifacts, tmpdir.path()).await,
166                            Err(e) => Err(e),
167                        }
168                    } else {
169                        // Sequential mode: share the default target/ directory.
170                        // Cargo's file lock ensures the invocations do not
171                        // corrupt each other; they simply queue up.
172                        run_cargo_build(
173                            &config,
174                            Some(target.as_str()),
175                            &target,
176                            &crate_version,
177                            &abbaye_version,
178                            line_tx,
179                            None,
180                        )
181                        .await
182                    };
183
184                    let _ = log.send(LogEvent::ChildFinish {
185                        id: target.clone(),
186                        success: result.is_ok(),
187                        summary: match &result {
188                            Ok(artifacts) => format!("{} artifact(s)", artifacts.len()),
189                            Err(e) => e.to_string(),
190                        },
191                    });
192
193                    result
194                });
195            }
196
197            let mut all_artifacts = Vec::new();
198            while let Some(res) = join_set.join_next().await {
199                all_artifacts.extend(res.into_diagnostic()??);
200            }
201            Ok(all_artifacts)
202        }
203    }
204}
205
206/// Creates a plain-string sender whose lines are mapped through `f` and
207/// forwarded to `log`.  This lets `run_cargo_build` (which only knows about
208/// strings) feed into the structured [`LogSender`] without depending on
209/// [`LogEvent`] directly.
210fn line_bridge(
211    log: LogSender,
212    f: impl Fn(String) -> LogEvent + Send + 'static,
213) -> UnboundedSender<String> {
214    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
215    tokio::spawn(async move {
216        while let Some(line) = rx.recv().await {
217            let _ = log.send(f(line));
218        }
219    });
220    tx
221}
222
223/// Minimal representation of the JSON messages emitted by
224/// `cargo build --message-format=json`.
225#[derive(Deserialize)]
226struct CargoMessage {
227    reason: String,
228    /// Identifies the crate that produced this artifact.
229    /// Local packages (workspace members and path-deps) always contain
230    /// `path+file://`; external registry/git crates do not.
231    package_id: Option<String>,
232    target: Option<CargoMessageTarget>,
233    filenames: Option<Vec<String>>,
234}
235
236#[derive(Deserialize)]
237struct CargoMessageTarget {
238    name: String,
239}
240
241/// Spawn `cargo build --release --message-format=json [--target <triple>]
242/// [--manifest-path <path>]` and collect every artifact path from the
243/// `compiler-artifact` messages.
244///
245/// Stderr lines are forwarded to `line_tx` as plain strings; the caller is
246/// responsible for mapping them to the appropriate [`LogEvent`] variant.
247async fn run_cargo_build(
248    config: &CargoBuilderConfig,
249    target: Option<&str>,
250    triple: &str,
251    version: &str,
252    abbaye_version: &str,
253    line_tx: UnboundedSender<String>,
254    target_dir: Option<&Path>,
255) -> Result<Vec<ArtifactPath>> {
256    let mut cmd = Command::new("cargo");
257    cmd.args(["build", "--release", "--message-format=json"]);
258    cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
259
260    if let Some(t) = target {
261        cmd.args(["--target", t]);
262    }
263
264    if let Some(manifest) = &config.manifest_path {
265        cmd.arg("--manifest-path").arg(manifest);
266    }
267
268    if let Some(dir) = target_dir {
269        cmd.arg("--target-dir").arg(dir);
270    }
271
272    let mut child = cmd
273        .stdout(Stdio::piped())
274        .stderr(Stdio::piped())
275        .spawn()
276        .into_diagnostic()?;
277
278    // Forward stderr lines to the caller's line sender concurrently with
279    // JSON stdout parsing.
280    let stderr = child.stderr.take().expect("stderr was piped");
281    tokio::spawn(async move {
282        let mut stderr_lines = BufReader::new(stderr).lines();
283        while let Ok(Some(line)) = stderr_lines.next_line().await {
284            let _ = line_tx.send(line);
285        }
286    });
287
288    let stdout = child.stdout.take().expect("stdout was piped");
289    let mut lines = BufReader::new(stdout).lines();
290
291    let mut artifacts = Vec::new();
292
293    while let Some(line) = lines.next_line().await.into_diagnostic()? {
294        let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else {
295            continue;
296        };
297
298        if msg.reason != "compiler-artifact" {
299            continue;
300        }
301
302        // Skip artifacts from external (registry / git) dependencies.
303        // Both the old package_id format ("name ver (path+file://...)") and the
304        // newer spec format ("path+file://...#name@ver") contain "path+file://"
305        // for every local crate, so a substring check is version-agnostic.
306        if !msg
307            .package_id
308            .as_deref()
309            .is_some_and(|id| id.contains("path+file://"))
310        {
311            continue;
312        }
313
314        // If the caller named specific targets, restrict to those.
315        if !config.bins.is_empty() {
316            let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or("");
317            if !config.bins.iter().any(|b| b == target_name) {
318                continue;
319            }
320        }
321
322        for filename in msg.filenames.unwrap_or_default() {
323            let path = PathBuf::from(&filename);
324
325            // Skip rlib / rmeta files; we only want executables and cdylibs.
326            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
327            if matches!(ext, "rlib" | "rmeta" | "d") {
328                continue;
329            }
330
331            if !path.exists() {
332                continue;
333            }
334
335            // Name the artifact as `{stem}-{version}-{triple}{ext}` so that
336            // binaries for different targets can coexist in the same dist dir.
337            let stem = path
338                .file_stem()
339                .map(|s| s.to_string_lossy().into_owned())
340                .unwrap_or_default();
341            let dot_ext = path
342                .extension()
343                .map(|e| format!(".{}", e.to_string_lossy()))
344                .unwrap_or_default();
345            let name = format!("{stem}-{version}-{triple}{dot_ext}");
346
347            artifacts.push(ArtifactPath {
348                path,
349                name,
350                hash: None,
351            });
352        }
353    }
354
355    let status = child.wait().await.into_diagnostic()?;
356
357    if !status.success() {
358        return Err(miette!(
359            "cargo build --release failed with exit status: {status}"
360        ));
361    }
362
363    Ok(artifacts)
364}
365
366/// Copy each artifact from its path inside `tmp_root` to the corresponding
367/// path under `target/`, creating parent directories as needed, and return
368/// updated [`ArtifactPath`]s pointing at the new stable locations.
369///
370/// When `--target-dir <tmpdir>` is passed to `cargo build`, artifacts land at
371/// `<tmpdir>/<triple>/release/<name>`.  Stripping the `tmpdir` prefix and
372/// prepending `target/` gives the canonical path `target/<triple>/release/<name>`,
373/// which is where a normal `cargo build --target <triple>` would place them.
374async fn relocate_artifacts(
375    artifacts: Vec<ArtifactPath>,
376    tmp_root: &Path,
377) -> Result<Vec<ArtifactPath>> {
378    let mut relocated = Vec::with_capacity(artifacts.len());
379    for artifact in artifacts {
380        let relative = artifact.path.strip_prefix(tmp_root).into_diagnostic()?;
381        let stable = PathBuf::from("target").join(relative);
382        if let Some(parent) = stable.parent() {
383            tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
384        }
385        tokio::fs::copy(&artifact.path, &stable)
386            .await
387            .into_diagnostic()?;
388        relocated.push(ArtifactPath {
389            path: stable,
390            name: artifact.name,
391            hash: artifact.hash,
392        });
393    }
394    Ok(relocated)
395}
396
397/// Query `rustc -vV` and return the host target triple
398/// (e.g. `"x86_64-unknown-linux-gnu"`).
399async fn get_host_target() -> Result<String> {
400    let output = Command::new("rustc")
401        .args(["-vV"])
402        .output()
403        .await
404        .into_diagnostic()?;
405
406    if !output.status.success() {
407        return Err(miette!("rustc -vV failed"));
408    }
409
410    let stdout = String::from_utf8(output.stdout).into_diagnostic()?;
411    stdout
412        .lines()
413        .find(|l| l.starts_with("host:"))
414        .and_then(|l| l.split_whitespace().nth(1))
415        .map(str::to_owned)
416        .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output"))
417}
418
419/// Read `[package].version` from the Cargo.toml at `manifest_path`
420/// (defaults to `Cargo.toml` in the current directory).
421async fn read_crate_version(manifest_path: Option<&Path>) -> Result<String> {
422    let path = manifest_path.unwrap_or(Path::new("Cargo.toml"));
423    let content = tokio::fs::read_to_string(path).await.into_diagnostic()?;
424
425    #[derive(Deserialize)]
426    struct Manifest {
427        package: Option<Package>,
428    }
429    #[derive(Deserialize)]
430    struct Package {
431        version: Option<String>,
432    }
433
434    let manifest: Manifest = toml::from_str(&content).into_diagnostic()?;
435    manifest
436        .package
437        .ok_or_else(|| miette!("{} has no [package] section", path.display()))?
438        .version
439        .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))
440}
441
442/// Configuration for [`CargoDocBuilder`].
443#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
444pub struct CargoDocBuilderConfig {
445    /// Optional path to the Cargo.toml manifest.
446    ///
447    /// Passed verbatim as `--manifest-path`. Defaults to the manifest in the
448    /// current working directory when absent.
449    pub manifest_path: Option<PathBuf>,
450
451    /// Skip building documentation for dependencies (`--no-deps`).
452    #[serde(default)]
453    pub no_deps: bool,
454}
455
456/// Runs `cargo doc` and returns the whole doc directory as an artifact.
457pub struct CargoDocBuilder;
458
459impl Builder for CargoDocBuilder {
460    type ConfigType = CargoDocBuilderConfig;
461
462    async fn build(
463        &self,
464        config: Self::ConfigType,
465        abbaye_version: &str,
466        log: LogSender,
467    ) -> Result<Vec<ArtifactPath>> {
468        let mut cmd = Command::new("cargo");
469        cmd.arg("doc");
470        cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
471
472        if config.no_deps {
473            cmd.arg("--no-deps");
474        }
475
476        if let Some(manifest) = &config.manifest_path {
477            cmd.arg("--manifest-path").arg(manifest);
478        }
479
480        let mut child = cmd.stderr(Stdio::piped()).spawn().into_diagnostic()?;
481
482        let stderr = child.stderr.take().expect("stderr was piped");
483        tokio::spawn(async move {
484            let mut stderr_lines = BufReader::new(stderr).lines();
485            while let Ok(Some(line)) = stderr_lines.next_line().await {
486                let _ = log.send(LogEvent::Line(line));
487            }
488        });
489
490        let status = child.wait().await.into_diagnostic()?;
491
492        if !status.success() {
493            return Err(miette!("cargo doc failed with exit status: {status}"));
494        }
495
496        // Resolve the doc output directory. When a manifest path is given the
497        // workspace root is its parent directory; otherwise fall back to CWD.
498        let doc_dir = config
499            .manifest_path
500            .as_deref()
501            .and_then(|p| p.parent())
502            .unwrap_or_else(|| std::path::Path::new("."))
503            .join("target/doc");
504
505        if !doc_dir.exists() {
506            return Err(miette!("doc directory not found at {}", doc_dir.display()));
507        }
508
509        // Return the entire target/doc tree as a single artifact so that the
510        // shared rustdoc assets (CSS, JS, fonts, search indices) that live at
511        // the root of target/doc/ are preserved alongside the per-crate HTML.
512        Ok(vec![ArtifactPath {
513            path: doc_dir,
514            name: "doc".to_owned(),
515            hash: None,
516        }])
517    }
518}