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