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