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}