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    /// The kind(s) of the target, e.g. `["bin"]`, `["lib"]`, `["custom-build"]`.
240    #[serde(default)]
241    kind: Vec<String>,
242}
243
244/// Spawn `cargo build --release --message-format=json [--target <triple>]
245/// [--manifest-path <path>]` and collect every artifact path from the
246/// `compiler-artifact` messages.
247///
248/// Stderr lines are forwarded to `line_tx` as plain strings; the caller is
249/// responsible for mapping them to the appropriate [`LogEvent`] variant.
250async fn run_cargo_build(
251    config: &CargoBuilderConfig,
252    target: Option<&str>,
253    triple: &str,
254    version: &str,
255    abbaye_version: &str,
256    line_tx: UnboundedSender<String>,
257    target_dir: Option<&Path>,
258) -> Result<Vec<ArtifactPath>> {
259    let mut cmd = Command::new("cargo");
260    cmd.args(["build", "--release", "--message-format=json"]);
261    cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
262
263    if let Some(t) = target {
264        cmd.args(["--target", t]);
265    }
266
267    if let Some(manifest) = &config.manifest_path {
268        cmd.arg("--manifest-path").arg(manifest);
269    }
270
271    if let Some(dir) = target_dir {
272        cmd.arg("--target-dir").arg(dir);
273    }
274
275    let mut child = cmd
276        .stdout(Stdio::piped())
277        .stderr(Stdio::piped())
278        .spawn()
279        .into_diagnostic()?;
280
281    // Forward stderr lines to the caller's line sender concurrently with
282    // JSON stdout parsing.
283    let stderr = child.stderr.take().expect("stderr was piped");
284    tokio::spawn(async move {
285        let mut stderr_lines = BufReader::new(stderr).lines();
286        while let Ok(Some(line)) = stderr_lines.next_line().await {
287            let _ = line_tx.send(line);
288        }
289    });
290
291    let stdout = child.stdout.take().expect("stdout was piped");
292    let mut lines = BufReader::new(stdout).lines();
293
294    let mut artifacts = Vec::new();
295
296    while let Some(line) = lines.next_line().await.into_diagnostic()? {
297        let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else {
298            continue;
299        };
300
301        if msg.reason != "compiler-artifact" {
302            continue;
303        }
304
305        // Skip artifacts from external (registry / git) dependencies.
306        // Both the old package_id format ("name ver (path+file://...)") and the
307        // newer spec format ("path+file://...#name@ver") contain "path+file://"
308        // for every local crate, so a substring check is version-agnostic.
309        if !msg
310            .package_id
311            .as_deref()
312            .is_some_and(|id| id.contains("path+file://"))
313        {
314            continue;
315        }
316
317        // Skip build-script artifacts (kind == ["custom-build"]).
318        if msg
319            .target
320            .as_ref()
321            .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build"))
322        {
323            continue;
324        }
325
326        // If the caller named specific targets, restrict to those.
327        if !config.bins.is_empty() {
328            let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or("");
329            if !config.bins.iter().any(|b| b == target_name) {
330                continue;
331            }
332        }
333
334        for filename in msg.filenames.unwrap_or_default() {
335            let path = PathBuf::from(&filename);
336
337            // Skip rlib / rmeta files; we only want executables and cdylibs.
338            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
339            if matches!(ext, "rlib" | "rmeta" | "d") {
340                continue;
341            }
342
343            if !path.exists() {
344                continue;
345            }
346
347            // Name the artifact as `{stem}-{version}-{triple}{ext}` so that
348            // binaries for different targets can coexist in the same dist dir.
349            let stem = path
350                .file_stem()
351                .map(|s| s.to_string_lossy().into_owned())
352                .unwrap_or_default();
353            let dot_ext = path
354                .extension()
355                .map(|e| format!(".{}", e.to_string_lossy()))
356                .unwrap_or_default();
357            let name = format!("{stem}-{version}-{triple}{dot_ext}");
358
359            artifacts.push(ArtifactPath {
360                path,
361                name,
362                hash: None,
363            });
364        }
365    }
366
367    let status = child.wait().await.into_diagnostic()?;
368
369    if !status.success() {
370        return Err(miette!(
371            "cargo build --release failed with exit status: {status}"
372        ));
373    }
374
375    Ok(artifacts)
376}
377
378/// Copy each artifact from its path inside `tmp_root` to the corresponding
379/// path under `target/`, creating parent directories as needed, and return
380/// updated [`ArtifactPath`]s pointing at the new stable locations.
381///
382/// When `--target-dir <tmpdir>` is passed to `cargo build`, artifacts land at
383/// `<tmpdir>/<triple>/release/<name>`.  Stripping the `tmpdir` prefix and
384/// prepending `target/` gives the canonical path `target/<triple>/release/<name>`,
385/// which is where a normal `cargo build --target <triple>` would place them.
386async fn relocate_artifacts(
387    artifacts: Vec<ArtifactPath>,
388    tmp_root: &Path,
389) -> Result<Vec<ArtifactPath>> {
390    let mut relocated = Vec::with_capacity(artifacts.len());
391    for artifact in artifacts {
392        let relative = artifact.path.strip_prefix(tmp_root).into_diagnostic()?;
393        let stable = PathBuf::from("target").join(relative);
394        if let Some(parent) = stable.parent() {
395            tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
396        }
397        tokio::fs::copy(&artifact.path, &stable)
398            .await
399            .into_diagnostic()?;
400        relocated.push(ArtifactPath {
401            path: stable,
402            name: artifact.name,
403            hash: artifact.hash,
404        });
405    }
406    Ok(relocated)
407}
408
409/// Query `rustc -vV` and return the host target triple
410/// (e.g. `"x86_64-unknown-linux-gnu"`).
411async fn get_host_target() -> Result<String> {
412    let output = Command::new("rustc")
413        .args(["-vV"])
414        .output()
415        .await
416        .into_diagnostic()?;
417
418    if !output.status.success() {
419        return Err(miette!("rustc -vV failed"));
420    }
421
422    let stdout = String::from_utf8(output.stdout).into_diagnostic()?;
423    stdout
424        .lines()
425        .find(|l| l.starts_with("host:"))
426        .and_then(|l| l.split_whitespace().nth(1))
427        .map(str::to_owned)
428        .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output"))
429}
430
431/// Read `[package].version` from the Cargo.toml at `manifest_path`
432/// (defaults to `Cargo.toml` in the current directory).
433async fn read_crate_version(manifest_path: Option<&Path>) -> Result<String> {
434    let path = manifest_path.unwrap_or(Path::new("Cargo.toml"));
435    let content = tokio::fs::read_to_string(path).await.into_diagnostic()?;
436
437    #[derive(Deserialize)]
438    struct Manifest {
439        package: Option<Package>,
440    }
441    #[derive(Deserialize)]
442    struct Package {
443        version: Option<String>,
444    }
445
446    let manifest: Manifest = toml::from_str(&content).into_diagnostic()?;
447    manifest
448        .package
449        .ok_or_else(|| miette!("{} has no [package] section", path.display()))?
450        .version
451        .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))
452}
453
454/// Configuration for [`CargoDocBuilder`].
455#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
456pub struct CargoDocBuilderConfig {
457    /// Optional path to the Cargo.toml manifest.
458    ///
459    /// Passed verbatim as `--manifest-path`. Defaults to the manifest in the
460    /// current working directory when absent.
461    pub manifest_path: Option<PathBuf>,
462
463    /// Skip building documentation for dependencies (`--no-deps`).
464    #[serde(default)]
465    pub no_deps: bool,
466}
467
468/// Runs `cargo doc` and returns the whole doc directory as an artifact.
469pub struct CargoDocBuilder;
470
471impl Builder for CargoDocBuilder {
472    type ConfigType = CargoDocBuilderConfig;
473
474    async fn build(
475        &self,
476        config: Self::ConfigType,
477        abbaye_version: &str,
478        log: LogSender,
479    ) -> Result<Vec<ArtifactPath>> {
480        let mut cmd = Command::new("cargo");
481        cmd.arg("doc");
482        cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
483
484        if config.no_deps {
485            cmd.arg("--no-deps");
486        }
487
488        if let Some(manifest) = &config.manifest_path {
489            cmd.arg("--manifest-path").arg(manifest);
490        }
491
492        let mut child = cmd.stderr(Stdio::piped()).spawn().into_diagnostic()?;
493
494        let stderr = child.stderr.take().expect("stderr was piped");
495        tokio::spawn(async move {
496            let mut stderr_lines = BufReader::new(stderr).lines();
497            while let Ok(Some(line)) = stderr_lines.next_line().await {
498                let _ = log.send(LogEvent::Line(line));
499            }
500        });
501
502        let status = child.wait().await.into_diagnostic()?;
503
504        if !status.success() {
505            return Err(miette!("cargo doc failed with exit status: {status}"));
506        }
507
508        // Resolve the doc output directory. When a manifest path is given the
509        // workspace root is its parent directory; otherwise fall back to CWD.
510        let doc_dir = config
511            .manifest_path
512            .as_deref()
513            .and_then(|p| p.parent())
514            .unwrap_or_else(|| std::path::Path::new("."))
515            .join("target/doc");
516
517        if !doc_dir.exists() {
518            return Err(miette!("doc directory not found at {}", doc_dir.display()));
519        }
520
521        // Return the entire target/doc tree as a single artifact so that the
522        // shared rustdoc assets (CSS, JS, fonts, search indices) that live at
523        // the root of target/doc/ are preserved alongside the per-crate HTML.
524        Ok(vec![ArtifactPath {
525            path: doc_dir,
526            name: "doc".to_owned(),
527            hash: None,
528        }])
529    }
530}