Skip to main content

abbaye/builders/
cargo.rs

1use std::process::Stdio;
2
3use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender};
4use miette::{IntoDiagnostic, Result, miette};
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use tempfile::TempDir;
8use tokio::io::{AsyncBufReadExt, BufReader};
9use tokio::process::Command;
10use tokio::sync::mpsc::UnboundedSender;
11
12fn default_parallel() -> bool {
13    true
14}
15
16/// Configuration for [`CargoBuilder`].
17#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
18pub struct CargoBuilderConfig {
19    /// Cargo target triples to build for (e.g. `"x86_64-unknown-linux-musl"`).
20    ///
21    /// Each entry is passed as `--target <triple>` in a separate `cargo build`
22    /// invocation. When the list is empty, cargo builds for the host target.
23    #[serde(default)]
24    pub targets: Vec<String>,
25
26    /// Optional path to the Cargo.toml manifest.
27    ///
28    /// Passed verbatim as `--manifest-path`. Defaults to the manifest in the
29    /// current working directory when absent.
30    pub manifest_path: Option<std::path::PathBuf>,
31
32    /// Restrict collected artifacts to these binary (or cdylib) target names.
33    ///
34    /// When empty every artifact produced by a **workspace member or local
35    /// path-dependency** is kept. Use this to avoid picking up extra binaries
36    /// from dev-tools or examples that live in the same workspace.
37    ///
38    /// ```toml
39    /// [[builders]]
40    /// type = "cargo"
41    /// bins = ["my_binary", "my_cdylib"]
42    /// ```
43    #[serde(default)]
44    pub bins: Vec<String>,
45
46    /// Use `cross` instead of `cargo` for the build invocation.
47    ///
48    /// When `true`, the builder runs `cross build --release` instead of
49    /// `cargo build --release`.  This is useful when the host does not have
50    /// native cross-compilation toolchains installed — `cross` handles
51    /// toolchain provisioning via Docker or Podman automatically.
52    ///
53    /// Only affects the `cargo build` command name; all other flags
54    /// (`--target`, `--manifest-path`, `--target-dir`,
55    /// `--message-format=json`) and artifact discovery work identically.
56    #[serde(default)]
57    pub use_cross: bool,
58
59    /// Run cross-compilation targets in parallel using isolated temporary
60    /// target directories.
61    ///
62    /// When `true` (the default), each target triple is given its own
63    /// `--target-dir` backed by a [`tempfile::TempDir`], so multiple
64    /// `cargo build` processes can compile simultaneously without contending
65    /// on cargo's file lock (`target/.cargo-lock`).  Compiled artifacts are
66    /// copied to the canonical `target/<triple>/release/` paths and the
67    /// temporary directories are then removed automatically.
68    ///
69    /// Set this to `false` when:
70    ///
71    /// - **Disk space is tight.** Each temporary build tree can occupy several
72    ///   gigabytes for dependency-heavy crates. Four targets running in
73    ///   parallel means roughly four times the peak disk usage of a single
74    ///   build.
75    /// - **Incremental compilation matters.** Temporary target directories
76    ///   always start cold, discarding Rust's incremental cache. Disabling
77    ///   parallelism lets all targets share the persistent `target/` directory
78    ///   and reuse previously compiled artefacts on subsequent runs.
79    /// - **The build host is resource-constrained.** Parallel `cargo build`
80    ///   processes each consume significant CPU and RAM. On CI machines with
81    ///   limited memory, running them sequentially avoids thrashing or
82    ///   out-of-memory failures.
83    /// - **Your cross-compilation toolchain is not concurrency-safe.** Some
84    ///   custom linkers or build-script tools assume exclusive access and may
85    ///   produce corrupt output when invoked concurrently.
86    #[serde(default = "default_parallel")]
87    pub parallel: bool,
88}
89
90impl Default for CargoBuilderConfig {
91    fn default() -> Self {
92        Self {
93            targets: Vec::new(),
94            manifest_path: None,
95            bins: Vec::new(),
96            parallel: default_parallel(),
97            use_cross: false,
98        }
99    }
100}
101
102/// Runs `cargo build --release` (or `cross build --release`) and returns the
103/// produced artifacts.
104pub struct CargoBuilder;
105
106impl Builder for CargoBuilder {
107    type ConfigType = CargoBuilderConfig;
108
109    async fn build(
110        &self,
111        config: Self::ConfigType,
112        abbaye_version: &str,
113        log: LogSender,
114    ) -> Result<Vec<ArtifactPath>> {
115        let crate_version = read_crate_version(config.manifest_path.as_deref()).await?;
116
117        if config.targets.is_empty() {
118            // Single host target: forward stderr lines as plain LogEvent::Line events.
119            let host = get_host_target().await?;
120            let line_tx = line_bridge(log, LogEvent::Line);
121            run_cargo_build(
122                &config,
123                None,
124                &host,
125                &crate_version,
126                abbaye_version,
127                line_tx,
128                None,
129            )
130            .await
131        } else {
132            // Multiple targets: each runs in its own task with its own
133            // temporary target directory so cargo's file lock does not
134            // serialise them.
135            let mut join_set = tokio::task::JoinSet::new();
136
137            for target in &config.targets {
138                let config = config.clone();
139                let crate_version = crate_version.clone();
140                let abbaye_version = abbaye_version.to_owned();
141                let target = target.clone();
142                let log = log.clone();
143
144                join_set.spawn(async move {
145                    // Announce this target as a child task.
146                    let _ = log.send(LogEvent::ChildStart {
147                        id: target.clone(),
148                        label: target.clone(),
149                    });
150
151                    // Bridge: run_cargo_build emits plain Strings; forward
152                    // them as ChildLine events on the parent LogSender.
153                    let target_id = target.clone();
154                    let line_tx = line_bridge(log.clone(), move |l| LogEvent::ChildLine {
155                        id: target_id.clone(),
156                        line: l,
157                    });
158
159                    let result = if config.parallel && !config.use_cross {
160                        // Give this invocation its own target directory so it
161                        // does not contend with sibling builds on cargo's lock.
162                        let tmpdir = TempDir::new().into_diagnostic()?;
163                        let r = run_cargo_build(
164                            &config,
165                            Some(target.as_str()),
166                            &target,
167                            &crate_version,
168                            &abbaye_version,
169                            line_tx,
170                            Some(tmpdir.path()),
171                        )
172                        .await;
173                        // Copy artifacts to stable paths inside target/ before
174                        // tmpdir is dropped, then let tmpdir clean up.
175                        match r {
176                            Ok(artifacts) => relocate_artifacts(artifacts, tmpdir.path()).await,
177                            Err(e) => Err(e),
178                        }
179                    } else {
180                        // Sequential mode (or sequential cross-compilation): share the default target/ directory.
181                        // Cargo's file lock ensures the invocations do not
182                        // corrupt each other; they simply queue up.
183                        run_cargo_build(
184                            &config,
185                            Some(target.as_str()),
186                            &target,
187                            &crate_version,
188                            &abbaye_version,
189                            line_tx,
190                            None,
191                        )
192                        .await
193                    };
194
195                    let _ = log.send(LogEvent::ChildFinish {
196                        id: target.clone(),
197                        success: result.is_ok(),
198                        summary: match &result {
199                            Ok(artifacts) => format!("{} artifact(s)", artifacts.len()),
200                            Err(e) => e.to_string(),
201                        },
202                    });
203
204                    result
205                });
206            }
207
208            let mut all_artifacts = Vec::new();
209            while let Some(res) = join_set.join_next().await {
210                all_artifacts.extend(res.into_diagnostic()??);
211            }
212            Ok(all_artifacts)
213        }
214    }
215}
216
217/// Creates a plain-string sender whose lines are mapped through `f` and
218/// forwarded to `log`.  This lets `run_cargo_build` (which only knows about
219/// strings) feed into the structured [`LogSender`] without depending on
220/// [`LogEvent`] directly.
221fn line_bridge(
222    log: LogSender,
223    f: impl Fn(String) -> LogEvent + Send + 'static,
224) -> UnboundedSender<String> {
225    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
226    tokio::spawn(async move {
227        while let Some(line) = rx.recv().await {
228            let _ = log.send(f(line));
229        }
230    });
231    tx
232}
233
234/// Minimal representation of the JSON messages emitted by
235/// `cargo build --message-format=json`.
236#[derive(Deserialize)]
237struct CargoMessage {
238    reason: String,
239    /// Identifies the crate that produced this artifact.
240    /// Local packages (workspace members and path-deps) always contain
241    /// `path+file://`; external registry/git crates do not.
242    package_id: Option<String>,
243    target: Option<CargoMessageTarget>,
244    filenames: Option<Vec<String>>,
245}
246
247#[derive(Deserialize)]
248struct CargoMessageTarget {
249    name: String,
250    /// The kind(s) of the target, e.g. `["bin"]`, `["lib"]`, `["custom-build"]`.
251    #[serde(default)]
252    kind: Vec<String>,
253}
254
255/// Spawn `cargo build --release --message-format=json [--target <triple>]
256/// [--manifest-path <path>]` and collect every artifact path from the
257/// `compiler-artifact` messages.
258///
259/// Stderr lines are forwarded to `line_tx` as plain strings; the caller is
260/// responsible for mapping them to the appropriate [`LogEvent`] variant.
261async fn run_cargo_build(
262    config: &CargoBuilderConfig,
263    target: Option<&str>,
264    triple: &str,
265    version: &str,
266    abbaye_version: &str,
267    line_tx: UnboundedSender<String>,
268    target_dir: Option<&std::path::Path>,
269) -> Result<Vec<ArtifactPath>> {
270    let tool = if config.use_cross { "cross" } else { "cargo" };
271    let mut cmd = Command::new(tool);
272    cmd.args(["build", "--release", "--message-format=json"]);
273    cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
274
275    if let Some(t) = target {
276        cmd.args(["--target", t]);
277    }
278
279    if let Some(manifest) = &config.manifest_path {
280        cmd.arg("--manifest-path").arg(manifest);
281    }
282
283    if let Some(dir) = target_dir {
284        cmd.arg("--target-dir").arg(dir);
285    }
286
287    let mut child = cmd
288        .stdout(Stdio::piped())
289        .stderr(Stdio::piped())
290        .spawn()
291        .into_diagnostic()?;
292
293    // Forward stderr lines to the caller's line sender concurrently with
294    // JSON stdout parsing.
295    let stderr = child.stderr.take().expect("stderr was piped");
296    tokio::spawn(async move {
297        let mut stderr_lines = BufReader::new(stderr).lines();
298        while let Ok(Some(line)) = stderr_lines.next_line().await {
299            let _ = line_tx.send(line);
300        }
301    });
302
303    let stdout = child.stdout.take().expect("stdout was piped");
304    let mut lines = BufReader::new(stdout).lines();
305
306    let mut artifacts = Vec::new();
307
308    while let Some(line) = lines.next_line().await.into_diagnostic()? {
309        let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else {
310            continue;
311        };
312
313        if msg.reason != "compiler-artifact" {
314            continue;
315        }
316
317        // Skip artifacts from external (registry / git) dependencies.
318        // Both the old package_id format ("name ver (path+file://...)") and the
319        // newer spec format ("path+file://...#name@ver") contain "path+file://"
320        // for every local crate, so a substring check is version-agnostic.
321        if !msg
322            .package_id
323            .as_deref()
324            .is_some_and(|id| id.contains("path+file://"))
325        {
326            continue;
327        }
328
329        // Skip build-script artifacts (kind == ["custom-build"]).
330        if msg
331            .target
332            .as_ref()
333            .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build"))
334        {
335            continue;
336        }
337
338        // If the caller named specific targets, restrict to those.
339        if !config.bins.is_empty() {
340            let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or("");
341            if !config.bins.iter().any(|b| b == target_name) {
342                continue;
343            }
344        }
345
346        for filename in msg.filenames.unwrap_or_default() {
347            let path = std::path::PathBuf::from(&filename);
348
349            // Skip rlib / rmeta files; we only want executables and cdylibs.
350            let ext = path
351                .extension()
352                .map(|e| e.to_string_lossy())
353                .unwrap_or_default();
354            if ext == "rlib" || ext == "rmeta" || ext == "d" {
355                continue;
356            }
357
358            if !path.exists() {
359                continue;
360            }
361
362            // Name the artifact as `{stem}-{version}-{triple}{ext}` so that
363            // binaries for different targets can coexist in the same dist dir.
364            let stem = path
365                .file_stem()
366                .map(|s| s.to_string_lossy().into_owned())
367                .unwrap_or_default();
368            let dot_ext = path
369                .extension()
370                .map(|e| format!(".{}", e.to_string_lossy()))
371                .unwrap_or_default();
372            let name = format!("{stem}-{version}-{triple}{dot_ext}");
373
374            artifacts.push(ArtifactPath {
375                path,
376                name,
377                hash: None,
378                category: None,
379                group_name: None,
380                group_comment: None,
381            });
382        }
383    }
384
385    let status = child.wait().await.into_diagnostic()?;
386
387    if !status.success() {
388        return Err(miette!(
389            "{tool} build --release failed with exit status: {status}"
390        ));
391    }
392
393    Ok(artifacts)
394}
395
396/// Copy each artifact from its path inside `tmp_root` to the corresponding
397/// path under `target/`, creating parent directories as needed, and return
398/// updated [`ArtifactPath`]s pointing at the new stable locations.
399///
400/// When `--target-dir <tmpdir>` is passed to `cargo build`, artifacts land at
401/// `<tmpdir>/<triple>/release/<name>`.  Stripping the `tmpdir` prefix and
402/// prepending `target/` gives the canonical path `target/<triple>/release/<name>`,
403/// which is where a normal `cargo build --target <triple>` would place them.
404async fn relocate_artifacts(
405    artifacts: Vec<ArtifactPath>,
406    tmp_root: &std::path::Path,
407) -> Result<Vec<ArtifactPath>> {
408    let mut relocated = Vec::with_capacity(artifacts.len());
409    for artifact in artifacts {
410        let relative = artifact.path.strip_prefix(tmp_root).into_diagnostic()?;
411        let stable = std::path::PathBuf::from("target").join(relative);
412        if let Some(parent) = stable.parent() {
413            tokio::fs::create_dir_all(parent).await.into_diagnostic()?;
414        }
415        tokio::fs::copy(&artifact.path, &stable)
416            .await
417            .into_diagnostic()?;
418        relocated.push(ArtifactPath {
419            path: stable,
420            name: artifact.name,
421            hash: artifact.hash,
422            category: artifact.category,
423            group_name: None,
424            group_comment: None,
425        });
426    }
427    Ok(relocated)
428}
429
430/// Query `rustc -vV` and return the host target triple
431/// (e.g. `"x86_64-unknown-linux-gnu"`).
432async fn get_host_target() -> Result<String> {
433    let output = Command::new("rustc")
434        .args(["-vV"])
435        .output()
436        .await
437        .into_diagnostic()?;
438
439    if !output.status.success() {
440        return Err(miette!("rustc -vV failed"));
441    }
442
443    let stdout = String::from_utf8(output.stdout).into_diagnostic()?;
444    stdout
445        .lines()
446        .find(|l| l.starts_with("host:"))
447        .and_then(|l| l.split_whitespace().nth(1))
448        .map(str::to_owned)
449        .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output"))
450}
451
452/// Read `[package].version` from the Cargo.toml at `manifest_path`
453/// (defaults to `Cargo.toml` in the current directory).
454async fn read_crate_version(manifest_path: Option<&std::path::Path>) -> Result<String> {
455    let path = manifest_path.unwrap_or(std::path::Path::new("Cargo.toml"));
456    let content = tokio::fs::read_to_string(path).await.into_diagnostic()?;
457
458    #[derive(Deserialize)]
459    struct Manifest {
460        package: Option<Package>,
461    }
462    #[derive(Deserialize)]
463    struct Package {
464        version: Option<String>,
465    }
466
467    let manifest: Manifest = toml::from_str(&content).into_diagnostic()?;
468    manifest
469        .package
470        .ok_or_else(|| miette!("{} has no [package] section", path.display()))?
471        .version
472        .ok_or_else(|| miette!("no version field in [package] in {}", path.display()))
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use std::path::Path;
479
480    // ── CargoMessage deserialization ──────────────────────────────────────────
481
482    #[test]
483    fn deserialize_compiler_artifact_message() {
484        let json = r#"{
485            "reason": "compiler-artifact",
486            "package_id": "path+file:///home/user/project#abbaye@0.10.0",
487            "target": { "name": "abbaye", "kind": ["bin"] },
488            "filenames": ["/home/user/project/target/release/abbaye"]
489        }"#;
490        let msg: CargoMessage = serde_json::from_str(json).unwrap();
491        assert_eq!(msg.reason, "compiler-artifact");
492        assert!(msg.package_id.unwrap().contains("path+file://"));
493        let target = msg.target.unwrap();
494        assert_eq!(target.name, "abbaye");
495        assert_eq!(target.kind, vec!["bin"]);
496        assert_eq!(
497            msg.filenames.unwrap(),
498            vec!["/home/user/project/target/release/abbaye"]
499        );
500    }
501
502    #[test]
503    fn deserialize_build_script_message_correctly_skipped() {
504        let json = r#"{
505            "reason": "compiler-artifact",
506            "package_id": "path+file:///home/user/project#abbaye@0.10.0",
507            "target": { "name": "build-script-build", "kind": ["custom-build"] },
508            "filenames": ["/home/user/project/target/release/build-script-build"]
509        }"#;
510        let msg: CargoMessage = serde_json::from_str(json).unwrap();
511        let is_custom_build = msg
512            .target
513            .as_ref()
514            .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build"));
515        assert!(is_custom_build, "custom-build target should be identified");
516    }
517
518    #[test]
519    fn deserialize_external_dependency_skipped() {
520        let json = r#"{
521            "reason": "compiler-artifact",
522            "package_id": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0",
523            "target": { "name": "serde", "kind": ["lib"] },
524            "filenames": ["/home/user/project/target/release/libserde.rlib"]
525        }"#;
526        let msg: CargoMessage = serde_json::from_str(json).unwrap();
527        let is_local = msg
528            .package_id
529            .as_deref()
530            .is_some_and(|id| id.contains("path+file://"));
531        assert!(
532            !is_local,
533            "external dependency should NOT be identified as local"
534        );
535    }
536
537    #[test]
538    fn deserialize_multiple_filenames_for_bin() {
539        let json = r#"{
540            "reason": "compiler-artifact",
541            "package_id": "path+file:///home/user/project#my-app@0.1.0",
542            "target": { "name": "my-app", "kind": ["bin"] },
543            "filenames": [
544                "/home/user/project/target/release/my-app",
545                "/home/user/project/target/release/my-app.d"
546            ]
547        }"#;
548        let msg: CargoMessage = serde_json::from_str(json).unwrap();
549        let filenames = msg.filenames.unwrap();
550        assert_eq!(filenames.len(), 2);
551        assert!(filenames[0].ends_with("my-app"));
552        assert!(filenames[1].ends_with("my-app.d"));
553    }
554
555    #[test]
556    fn deserialize_cdylib_artifact() {
557        let json = r#"{
558            "reason": "compiler-artifact",
559            "package_id": "path+file:///home/user/project#libfoo@0.1.0",
560            "target": { "name": "libfoo", "kind": ["cdylib"] },
561            "filenames": ["/home/user/project/target/release/liblibfoo.so"]
562        }"#;
563        let msg: CargoMessage = serde_json::from_str(json).unwrap();
564        let filename = msg.filenames.unwrap().into_iter().next().unwrap();
565        let path = Path::new(&filename);
566        let ext = path
567            .extension()
568            .map(|e| e.to_string_lossy())
569            .unwrap_or_default();
570        // .so should NOT be filtered out (only rlib, rmeta, d)
571        assert!(
572            !matches!(ext.as_ref(), "rlib" | "rmeta" | "d"),
573            "cdylib .so file should not be skipped"
574        );
575    }
576
577    // ── Artifact name generation ─────────────────────────────────────────────
578
579    #[test]
580    fn artifact_name_includes_version_and_triple() {
581        let path = Path::new("/target/release/abbaye");
582        let stem = path
583            .file_stem()
584            .map(|s| s.to_string_lossy().into_owned())
585            .unwrap_or_default();
586        let dot_ext = path
587            .extension()
588            .map(|e| format!(".{}", e.to_string_lossy()))
589            .unwrap_or_default();
590        let version = "0.10.0";
591        let triple = "x86_64-unknown-linux-musl";
592        let name = format!("{stem}-{version}-{triple}{dot_ext}");
593        assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl");
594    }
595
596    #[test]
597    fn artifact_name_with_exe_extension() {
598        let path = Path::new("/target/release/abbaye.exe");
599        let stem = path
600            .file_stem()
601            .map(|s| s.to_string_lossy().into_owned())
602            .unwrap_or_default();
603        let dot_ext = path
604            .extension()
605            .map(|e| format!(".{}", e.to_string_lossy()))
606            .unwrap_or_default();
607        let name = format!("{stem}-0.10.0-x86_64-pc-windows-msvc{dot_ext}");
608        assert_eq!(name, "abbaye-0.10.0-x86_64-pc-windows-msvc.exe");
609    }
610
611    // ─── relocate_artifacts ──────────────────────────────────────────────────
612
613    #[tokio::test]
614    async fn test_relocate_artifacts_copies_to_target() {
615        let tmp = tempfile::tempdir().unwrap();
616        let tmp_root = tmp.path().join("cross-tmp");
617        let triple_dir = tmp_root.join("x86_64-unknown-linux-musl").join("release");
618        tokio::fs::create_dir_all(&triple_dir).await.unwrap();
619        let binary_path = triple_dir.join("abbaye");
620        tokio::fs::write(&binary_path, b"binary content")
621            .await
622            .unwrap();
623
624        let artifacts = vec![ArtifactPath {
625            path: binary_path,
626            name: "abbaye-0.10.0-x86_64-unknown-linux-musl".to_owned(),
627            hash: None,
628            category: None,
629            group_name: None,
630            group_comment: None,
631        }];
632
633        let relocated = relocate_artifacts(artifacts, &tmp_root).await.unwrap();
634        assert_eq!(relocated.len(), 1);
635        let expected = Path::new("target")
636            .join("x86_64-unknown-linux-musl")
637            .join("release")
638            .join("abbaye");
639        assert_eq!(relocated[0].path, expected);
640        assert!(expected.exists(), "binary should exist at canonical path");
641        let content = tokio::fs::read_to_string(&expected).await.unwrap();
642        assert_eq!(content, "binary content");
643    }
644
645    // ─── get_host_target ─────────────────────────────────────────────────────
646
647    #[tokio::test]
648    async fn test_get_host_target_returns_triple() {
649        let triple = get_host_target().await.unwrap();
650        assert!(!triple.is_empty(), "host target triple should not be empty");
651        // Should contain at least one dash (e.g. x86_64-unknown-linux-gnu)
652        assert!(
653            triple.contains('-'),
654            "triple should be dash-separated: {triple}"
655        );
656    }
657
658    // ─── read_crate_version ─────────────────────────────────────────────────
659
660    #[tokio::test]
661    async fn test_read_crate_version_from_toml() {
662        let tmp = tempfile::tempdir().unwrap();
663        let toml_path = tmp.path().join("Cargo.toml");
664        tokio::fs::write(
665            &toml_path,
666            "[package]\nname = \"test\"\nversion = \"0.5.0\"\n",
667        )
668        .await
669        .unwrap();
670
671        let version = read_crate_version(Some(&toml_path)).await.unwrap();
672        assert_eq!(version, "0.5.0");
673    }
674
675    #[tokio::test]
676    async fn test_read_crate_version_returns_error_on_missing() {
677        let tmp = tempfile::tempdir().unwrap();
678        let toml_path = tmp.path().join("Cargo.toml");
679        tokio::fs::write(&toml_path, "[package]\nname = \"no-version\"\n")
680            .await
681            .unwrap();
682
683        let result = read_crate_version(Some(&toml_path)).await;
684        assert!(
685            result.is_err(),
686            "should error when version field is missing"
687        );
688    }
689
690    // ─── use_cross parallel condition ────────────────────────────────────────
691
692    #[test]
693    fn use_cross_disables_parallel_isolation() {
694        // This validates the fix: when use_cross is true, the parallel
695        // isolation path (tempdir + relocate) must NOT be taken.
696        // The condition is `config.parallel && !config.use_cross` -- so
697        // when use_cross is true, the result should be false regardless
698        // of the parallel setting.
699        let uses_isolation = |parallel: bool, use_cross: bool| -> bool { parallel && !use_cross };
700
701        assert!(
702            !uses_isolation(true, true),
703            "parallel=true + use_cross=true should NOT use isolation"
704        );
705        assert!(
706            !uses_isolation(false, true),
707            "parallel=false + use_cross=true should NOT use isolation"
708        );
709        assert!(
710            uses_isolation(true, false),
711            "parallel=true + use_cross=false SHOULD use isolation"
712        );
713        assert!(
714            !uses_isolation(false, false),
715            "parallel=false + use_cross=false should NOT use isolation"
716        );
717    }
718
719    // ─── Binary name filtering (extension check) ────────────────────────────
720
721    #[test]
722    fn skips_rlib_and_rmeta_and_dot_d_files() {
723        for ext in ["rlib", "rmeta", "d"] {
724            let filename = format!("/target/release/libfoo.{ext}");
725            let path = Path::new(&filename);
726            let ext_str = path
727                .extension()
728                .map(|e| e.to_string_lossy())
729                .unwrap_or_default();
730            assert!(
731                ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d",
732                "{ext} should match skip condition"
733            );
734        }
735    }
736
737    #[test]
738    fn keeps_executable_and_cdylib_files() {
739        for ext in ["", "exe", "so", "dylib", "dll"] {
740            let filename = if ext.is_empty() {
741                "/target/release/my-bin".to_owned()
742            } else {
743                format!("/target/release/my-bin.{ext}")
744            };
745            let path = Path::new(&filename);
746            let ext_str = path
747                .extension()
748                .map(|e| e.to_string_lossy())
749                .unwrap_or_default();
750            let is_skippable = ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d";
751            assert!(!is_skippable, "{ext} should NOT be skipped");
752        }
753    }
754
755    // ─── Parallel flag default ───────────────────────────────────────────────
756
757    #[test]
758    fn default_parallel_is_true() {
759        assert!(default_parallel(), "parallel should default to true");
760    }
761
762    #[test]
763    fn use_cross_defaults_to_false() {
764        let config = CargoBuilderConfig::default();
765        assert!(!config.use_cross, "use_cross should default to false");
766    }
767}
768
769/// Configuration for [`CargoDocBuilder`].
770#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
771pub struct CargoDocBuilderConfig {
772    /// Optional path to the Cargo.toml manifest.
773    ///
774    /// Passed verbatim as `--manifest-path`. Defaults to the manifest in the
775    /// current working directory when absent.
776    pub manifest_path: Option<std::path::PathBuf>,
777
778    /// Skip building documentation for dependencies (`--no-deps`).
779    #[serde(default)]
780    pub no_deps: bool,
781}
782
783/// Runs `cargo doc` and returns the whole doc directory as an artifact.
784pub struct CargoDocBuilder;
785
786impl Builder for CargoDocBuilder {
787    type ConfigType = CargoDocBuilderConfig;
788
789    async fn build(
790        &self,
791        config: Self::ConfigType,
792        abbaye_version: &str,
793        log: LogSender,
794    ) -> Result<Vec<ArtifactPath>> {
795        let mut cmd = Command::new("cargo");
796        cmd.arg("doc");
797        cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version);
798
799        if config.no_deps {
800            cmd.arg("--no-deps");
801        }
802
803        if let Some(manifest) = &config.manifest_path {
804            cmd.arg("--manifest-path").arg(manifest);
805        }
806
807        let mut child = cmd.stderr(Stdio::piped()).spawn().into_diagnostic()?;
808
809        let stderr = child.stderr.take().expect("stderr was piped");
810        tokio::spawn(async move {
811            let mut stderr_lines = BufReader::new(stderr).lines();
812            while let Ok(Some(line)) = stderr_lines.next_line().await {
813                let _ = log.send(LogEvent::Line(line));
814            }
815        });
816
817        let status = child.wait().await.into_diagnostic()?;
818
819        if !status.success() {
820            return Err(miette!("cargo doc failed with exit status: {status}"));
821        }
822
823        let doc_dir = config
824            .manifest_path
825            .as_deref()
826            .and_then(|p| p.parent())
827            .unwrap_or_else(|| std::path::Path::new("."))
828            .join("target/doc");
829
830        if !doc_dir.exists() {
831            return Err(miette!("doc directory not found at {}", doc_dir.display()));
832        }
833
834        Ok(vec![ArtifactPath {
835            path: doc_dir,
836            name: "doc".to_owned(),
837            hash: None,
838            category: None,
839            group_name: None,
840            group_comment: None,
841        }])
842    }
843}