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}