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