use std::process::Stdio; use crate::builders::{ArtifactPath, Builder, LogEvent, LogSender}; use miette::{IntoDiagnostic, Result, miette}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tempfile::TempDir; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use tokio::sync::mpsc::UnboundedSender; fn default_parallel() -> bool { true } /// Configuration for [`CargoBuilder`]. #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] pub struct CargoBuilderConfig { /// Cargo target triples to build for (e.g. `"x86_64-unknown-linux-musl"`). /// /// Each entry is passed as `--target <triple>` in a separate `cargo build` /// invocation. When the list is empty, cargo builds for the host target. #[serde(default)] pub targets: Vec<String>, /// Optional path to the Cargo.toml manifest. /// /// Passed verbatim as `--manifest-path`. Defaults to the manifest in the /// current working directory when absent. pub manifest_path: Option<std::path::PathBuf>, /// Restrict collected artifacts to these binary (or cdylib) target names. /// /// When empty every artifact produced by a **workspace member or local /// path-dependency** is kept. Use this to avoid picking up extra binaries /// from dev-tools or examples that live in the same workspace. /// /// ```toml /// [[builders]] /// type = "cargo" /// bins = ["my_binary", "my_cdylib"] /// ``` #[serde(default)] pub bins: Vec<String>, /// Use `cross` instead of `cargo` for the build invocation. /// /// When `true`, the builder runs `cross build --release` instead of /// `cargo build --release`. This is useful when the host does not have /// native cross-compilation toolchains installed — `cross` handles /// toolchain provisioning via Docker or Podman automatically. /// /// Only affects the `cargo build` command name; all other flags /// (`--target`, `--manifest-path`, `--target-dir`, /// `--message-format=json`) and artifact discovery work identically. #[serde(default)] pub use_cross: bool, /// Cargo feature flags to activate. /// /// Passed as `--features <comma-joined>`. When non-empty, the artifact /// name includes a feature suffix (e.g. `myapp-1.0-x86_64-full`) so /// artifacts built with different feature sets do not collide in the /// distribution directory. /// /// Combined with `no_default_features` to disable the default feature set. /// /// ```toml /// [[builders]] /// type = "cargo" /// features = ["full"] /// ``` #[serde(default)] pub features: Vec<String>, /// Do not activate the `default` feature (`--no-default-features`). /// /// When set without `features`, the artifact name is suffixed with /// `no-default` to distinguish it from a default-features build. #[serde(default)] pub no_default_features: bool, /// Override the auto-generated feature suffix in artifact names. /// /// By default the suffix is the `+`-joined list of feature names (e.g. /// `full`, `foo+bar`), or `no-default` when only `no_default_features` is /// set. Set this to a custom string to replace the suffix entirely, or to /// `""` to omit any suffix. /// /// ```toml /// [[builders]] /// type = "cargo" /// features = ["full"] /// suffix = "production" /// ``` pub suffix: Option<String>, /// Run cross-compilation targets in parallel using isolated temporary /// target directories. /// /// When `true` (the default), each target triple is given its own /// `--target-dir` backed by a [`tempfile::TempDir`], so multiple /// `cargo build` processes can compile simultaneously without contending /// on cargo's file lock (`target/.cargo-lock`). Compiled artifacts are /// copied to the canonical `target/<triple>/release/` paths and the /// temporary directories are then removed automatically. /// /// Set this to `false` when: /// /// - **Disk space is tight.** Each temporary build tree can occupy several /// gigabytes for dependency-heavy crates. Four targets running in /// parallel means roughly four times the peak disk usage of a single /// build. /// - **Incremental compilation matters.** Temporary target directories /// always start cold, discarding Rust's incremental cache. Disabling /// parallelism lets all targets share the persistent `target/` directory /// and reuse previously compiled artefacts on subsequent runs. /// - **The build host is resource-constrained.** Parallel `cargo build` /// processes each consume significant CPU and RAM. On CI machines with /// limited memory, running them sequentially avoids thrashing or /// out-of-memory failures. /// - **Your cross-compilation toolchain is not concurrency-safe.** Some /// custom linkers or build-script tools assume exclusive access and may /// produce corrupt output when invoked concurrently. #[serde(default = "default_parallel")] pub parallel: bool, } impl Default for CargoBuilderConfig { fn default() -> Self { Self { targets: Vec::new(), manifest_path: None, bins: Vec::new(), parallel: default_parallel(), use_cross: false, features: Vec::new(), no_default_features: false, suffix: None, } } } /// Runs `cargo build --release` (or `cross build --release`) and returns the /// produced artifacts. pub struct CargoBuilder; impl Builder for CargoBuilder { type ConfigType = CargoBuilderConfig; async fn build( &self, config: Self::ConfigType, abbaye_version: &str, log: LogSender, ) -> Result<Vec<ArtifactPath>> { let crate_version = read_crate_version(config.manifest_path.as_deref()).await?; let feat_suf = feature_suffix(&config.features, config.no_default_features, &config.suffix); let rel_override = feat_suf.as_deref().map(|s| format!("release-{s}")); if config.targets.is_empty() { // Single host target: forward stderr lines as plain LogEvent::Line events. let host = get_host_target().await?; let line_tx = line_bridge(log, LogEvent::Line); if let Some(ref rel) = rel_override { // Feature flags affect the binary, so use an isolated target dir // to prevent cargo cache contamination from the default build. let tmpdir = TempDir::new().into_diagnostic()?; let artifacts = run_cargo_build( &config, None, &host, &crate_version, abbaye_version, line_tx, Some(tmpdir.path()), ) .await?; relocate_artifacts(artifacts, tmpdir.path(), Some(rel)).await } else { run_cargo_build( &config, None, &host, &crate_version, abbaye_version, line_tx, None, ) .await } } else { // Multiple targets: each runs in its own task with its own // temporary target directory so cargo's file lock does not // serialise them. let mut join_set = tokio::task::JoinSet::new(); for target in &config.targets { let config = config.clone(); let crate_version = crate_version.clone(); let abbaye_version = abbaye_version.to_owned(); let target = target.clone(); let log = log.clone(); let rel = rel_override.clone(); join_set.spawn(async move { // Announce this target as a child task. let _ = log.send(LogEvent::ChildStart { id: target.clone(), label: target.clone(), }); // Bridge: run_cargo_build emits plain Strings; forward // them as ChildLine events on the parent LogSender. let target_id = target.clone(); let line_tx = line_bridge(log.clone(), move |l| LogEvent::ChildLine { id: target_id.clone(), line: l, }); let use_isolation = (config.parallel || rel.is_some()) && !config.use_cross; let result = if use_isolation { // Give this invocation its own target directory so it // does not contend with sibling builds on cargo's lock. // Also required when feature flags differ to prevent // binary overwrites from sibling builder entries. let tmpdir = TempDir::new().into_diagnostic()?; let r = run_cargo_build( &config, Some(target.as_str()), &target, &crate_version, &abbaye_version, line_tx, Some(tmpdir.path()), ) .await; // Copy artifacts to stable paths inside target/ before // tmpdir is dropped, then let tmpdir clean up. match r { Ok(artifacts) => { relocate_artifacts(artifacts, tmpdir.path(), rel.as_deref()).await } Err(e) => Err(e), } } else { // Sequential mode (or sequential cross-compilation): share the default target/ directory. // Cargo's file lock ensures the invocations do not // corrupt each other; they simply queue up. run_cargo_build( &config, Some(target.as_str()), &target, &crate_version, &abbaye_version, line_tx, None, ) .await }; let _ = log.send(LogEvent::ChildFinish { id: target.clone(), success: result.is_ok(), summary: match &result { Ok(artifacts) => format!("{} artifact(s)", artifacts.len()), Err(e) => e.to_string(), }, }); result }); } let mut all_artifacts = Vec::new(); while let Some(res) = join_set.join_next().await { all_artifacts.extend(res.into_diagnostic()??); } Ok(all_artifacts) } } } /// Creates a plain-string sender whose lines are mapped through `f` and /// forwarded to `log`. This lets `run_cargo_build` (which only knows about /// strings) feed into the structured [`LogSender`] without depending on /// [`LogEvent`] directly. fn line_bridge( log: LogSender, f: impl Fn(String) -> LogEvent + Send + 'static, ) -> UnboundedSender<String> { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>(); tokio::spawn(async move { while let Some(line) = rx.recv().await { let _ = log.send(f(line)); } }); tx } /// Minimal representation of the JSON messages emitted by /// `cargo build --message-format=json`. #[derive(Deserialize)] struct CargoMessage { reason: String, /// Identifies the crate that produced this artifact. /// Local packages (workspace members and path-deps) always contain /// `path+file://`; external registry/git crates do not. package_id: Option<String>, target: Option<CargoMessageTarget>, filenames: Option<Vec<String>>, } #[derive(Deserialize)] struct CargoMessageTarget { name: String, /// The kind(s) of the target, e.g. `["bin"]`, `["lib"]`, `["custom-build"]`. #[serde(default)] kind: Vec<String>, } /// Spawn `cargo build --release --message-format=json [--target <triple>] /// [--manifest-path <path>]` and collect every artifact path from the /// `compiler-artifact` messages. /// /// Stderr lines are forwarded to `line_tx` as plain strings; the caller is /// responsible for mapping them to the appropriate [`LogEvent`] variant. async fn run_cargo_build( config: &CargoBuilderConfig, target: Option<&str>, triple: &str, version: &str, abbaye_version: &str, line_tx: UnboundedSender<String>, target_dir: Option<&std::path::Path>, ) -> Result<Vec<ArtifactPath>> { let tool = if config.use_cross { "cross" } else { "cargo" }; let mut cmd = Command::new(tool); cmd.args(["build", "--release", "--message-format=json"]); cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version); if let Some(t) = target { cmd.args(["--target", t]); } if let Some(manifest) = &config.manifest_path { cmd.arg("--manifest-path").arg(manifest); } if let Some(dir) = target_dir { cmd.arg("--target-dir").arg(dir); } if !config.features.is_empty() { cmd.arg("--features").arg(config.features.join(",")); } if config.no_default_features { cmd.arg("--no-default-features"); } let mut child = cmd .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .into_diagnostic()?; // Forward stderr lines to the caller's line sender concurrently with // JSON stdout parsing. let stderr = child.stderr.take().expect("stderr was piped"); tokio::spawn(async move { let mut stderr_lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = stderr_lines.next_line().await { let _ = line_tx.send(line); } }); let stdout = child.stdout.take().expect("stdout was piped"); let mut lines = BufReader::new(stdout).lines(); let mut artifacts = Vec::new(); while let Some(line) = lines.next_line().await.into_diagnostic()? { let Ok(msg) = serde_json::from_str::<CargoMessage>(&line) else { continue; }; if msg.reason != "compiler-artifact" { continue; } // Skip artifacts from external (registry / git) dependencies. // Both the old package_id format ("name ver (path+file://...)") and the // newer spec format ("path+file://...#name@ver") contain "path+file://" // for every local crate, so a substring check is version-agnostic. if !msg .package_id .as_deref() .is_some_and(|id| id.contains("path+file://")) { continue; } // Skip build-script artifacts (kind == ["custom-build"]). if msg .target .as_ref() .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build")) { continue; } // If the caller named specific targets, restrict to those. if !config.bins.is_empty() { let target_name = msg.target.as_ref().map(|t| t.name.as_str()).unwrap_or(""); if !config.bins.iter().any(|b| b == target_name) { continue; } } for filename in msg.filenames.unwrap_or_default() { let path = std::path::PathBuf::from(&filename); // Skip rlib / rmeta files; we only want executables and cdylibs. let ext = path .extension() .map(|e| e.to_string_lossy()) .unwrap_or_default(); if ext == "rlib" || ext == "rmeta" || ext == "d" { continue; } if !path.exists() { continue; } // Name the artifact as `{stem}-{version}-{triple}[-{suffix}]{ext}` so // that binaries for different targets / feature sets can coexist. let stem = path .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); let dot_ext = path .extension() .map(|e| format!(".{}", e.to_string_lossy())) .unwrap_or_default(); let feature_suf = feature_suffix(&config.features, config.no_default_features, &config.suffix); let name = if let Some(ref suf) = feature_suf { format!("{stem}-{version}-{triple}-{suf}{dot_ext}") } else { format!("{stem}-{version}-{triple}{dot_ext}") }; artifacts.push(ArtifactPath { path, name, hash: None, category: None, group_name: None, group_comment: None, }); } } let status = child.wait().await.into_diagnostic()?; if !status.success() { return Err(miette!( "{tool} build --release failed with exit status: {status}" )); } Ok(artifacts) } /// Copy each artifact from its path inside `tmp_root` to the corresponding /// path under `target/`, creating parent directories as needed, and return /// updated [`ArtifactPath`]s pointing at the new stable locations. /// /// When `--target-dir <tmpdir>` is passed to `cargo build`, artifacts land at /// `<tmpdir>/<triple>/release/<name>`. Stripping the `tmpdir` prefix and /// prepending `target/` gives the canonical path `target/<triple>/release/<name>`, /// which is where a normal `cargo build --target <triple>` would place them. /// /// When `release_override` is set (e.g. `"release-no-default"`), the `release` /// component in the destination path is replaced, so artifacts from builds /// with different feature flags do not overwrite each other. async fn relocate_artifacts( artifacts: Vec<ArtifactPath>, tmp_root: &std::path::Path, release_override: Option<&str>, ) -> Result<Vec<ArtifactPath>> { let mut relocated = Vec::with_capacity(artifacts.len()); for artifact in artifacts { let relative = artifact.path.strip_prefix(tmp_root).into_diagnostic()?; let stable = if let Some(rel) = release_override { let relative_str = relative.to_string_lossy(); let replaced = relative_str.replace("/release/", &format!("/{rel}/")); std::path::PathBuf::from("target").join(&replaced) } else { std::path::PathBuf::from("target").join(relative) }; if let Some(parent) = stable.parent() { tokio::fs::create_dir_all(parent).await.into_diagnostic()?; } tokio::fs::copy(&artifact.path, &stable) .await .into_diagnostic()?; relocated.push(ArtifactPath { path: stable, name: artifact.name, hash: artifact.hash, category: artifact.category, group_name: None, group_comment: None, }); } Ok(relocated) } /// Query `rustc -vV` and return the host target triple /// (e.g. `"x86_64-unknown-linux-gnu"`). async fn get_host_target() -> Result<String> { let output = Command::new("rustc") .args(["-vV"]) .output() .await .into_diagnostic()?; if !output.status.success() { return Err(miette!("rustc -vV failed")); } let stdout = String::from_utf8(output.stdout).into_diagnostic()?; stdout .lines() .find(|l| l.starts_with("host:")) .and_then(|l| l.split_whitespace().nth(1)) .map(str::to_owned) .ok_or_else(|| miette!("could not parse host triple from `rustc -vV` output")) } /// Read `[package].version` from the Cargo.toml at `manifest_path` /// (defaults to `Cargo.toml` in the current directory). async fn read_crate_version(manifest_path: Option<&std::path::Path>) -> Result<String> { let path = manifest_path.unwrap_or(std::path::Path::new("Cargo.toml")); let content = tokio::fs::read_to_string(path).await.into_diagnostic()?; #[derive(Deserialize)] struct Manifest { package: Option<Package>, } #[derive(Deserialize)] struct Package { version: Option<String>, } let manifest: Manifest = toml::from_str(&content).into_diagnostic()?; manifest .package .ok_or_else(|| miette!("{} has no [package] section", path.display()))? .version .ok_or_else(|| miette!("no version field in [package] in {}", path.display())) } /// Compute the feature-derived suffix for an artifact name. /// /// Returns `None` when no suffix is needed (backward-compatible default). fn feature_suffix( features: &[String], no_default_features: bool, suffix: &Option<String>, ) -> Option<String> { suffix .clone() .or_else(|| { if !features.is_empty() { Some(features.join("+")) } else if no_default_features { Some("no-default".to_owned()) } else { None } }) .filter(|s| !s.is_empty()) } #[cfg(test)] mod tests { use super::*; use std::path::Path; // ── CargoMessage deserialization ────────────────────────────────────────── #[test] fn deserialize_compiler_artifact_message() { let json = r#"{ "reason": "compiler-artifact", "package_id": "path+file:///home/user/project#abbaye@0.10.0", "target": { "name": "abbaye", "kind": ["bin"] }, "filenames": ["/home/user/project/target/release/abbaye"] }"#; let msg: CargoMessage = serde_json::from_str(json).unwrap(); assert_eq!(msg.reason, "compiler-artifact"); assert!(msg.package_id.unwrap().contains("path+file://")); let target = msg.target.unwrap(); assert_eq!(target.name, "abbaye"); assert_eq!(target.kind, vec!["bin"]); assert_eq!( msg.filenames.unwrap(), vec!["/home/user/project/target/release/abbaye"] ); } #[test] fn deserialize_build_script_message_correctly_skipped() { let json = r#"{ "reason": "compiler-artifact", "package_id": "path+file:///home/user/project#abbaye@0.10.0", "target": { "name": "build-script-build", "kind": ["custom-build"] }, "filenames": ["/home/user/project/target/release/build-script-build"] }"#; let msg: CargoMessage = serde_json::from_str(json).unwrap(); let is_custom_build = msg .target .as_ref() .is_some_and(|t| t.kind.iter().any(|k| k == "custom-build")); assert!(is_custom_build, "custom-build target should be identified"); } #[test] fn deserialize_external_dependency_skipped() { let json = r#"{ "reason": "compiler-artifact", "package_id": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.0", "target": { "name": "serde", "kind": ["lib"] }, "filenames": ["/home/user/project/target/release/libserde.rlib"] }"#; let msg: CargoMessage = serde_json::from_str(json).unwrap(); let is_local = msg .package_id .as_deref() .is_some_and(|id| id.contains("path+file://")); assert!( !is_local, "external dependency should NOT be identified as local" ); } #[test] fn deserialize_multiple_filenames_for_bin() { let json = r#"{ "reason": "compiler-artifact", "package_id": "path+file:///home/user/project#my-app@0.1.0", "target": { "name": "my-app", "kind": ["bin"] }, "filenames": [ "/home/user/project/target/release/my-app", "/home/user/project/target/release/my-app.d" ] }"#; let msg: CargoMessage = serde_json::from_str(json).unwrap(); let filenames = msg.filenames.unwrap(); assert_eq!(filenames.len(), 2); assert!(filenames[0].ends_with("my-app")); assert!(filenames[1].ends_with("my-app.d")); } #[test] fn deserialize_cdylib_artifact() { let json = r#"{ "reason": "compiler-artifact", "package_id": "path+file:///home/user/project#libfoo@0.1.0", "target": { "name": "libfoo", "kind": ["cdylib"] }, "filenames": ["/home/user/project/target/release/liblibfoo.so"] }"#; let msg: CargoMessage = serde_json::from_str(json).unwrap(); let filename = msg.filenames.unwrap().into_iter().next().unwrap(); let path = Path::new(&filename); let ext = path .extension() .map(|e| e.to_string_lossy()) .unwrap_or_default(); // .so should NOT be filtered out (only rlib, rmeta, d) assert!( !matches!(ext.as_ref(), "rlib" | "rmeta" | "d"), "cdylib .so file should not be skipped" ); } // ── Artifact name generation ───────────────────────────────────────────── #[test] fn artifact_name_includes_version_and_triple() { let path = Path::new("/target/release/abbaye"); let stem = path .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); let dot_ext = path .extension() .map(|e| format!(".{}", e.to_string_lossy())) .unwrap_or_default(); let version = "0.10.0"; let triple = "x86_64-unknown-linux-musl"; let name = format!("{stem}-{version}-{triple}{dot_ext}"); assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl"); } #[test] fn artifact_name_with_exe_extension() { let path = Path::new("/target/release/abbaye.exe"); let stem = path .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); let dot_ext = path .extension() .map(|e| format!(".{}", e.to_string_lossy())) .unwrap_or_default(); let name = format!("{stem}-0.10.0-x86_64-pc-windows-msvc{dot_ext}"); assert_eq!(name, "abbaye-0.10.0-x86_64-pc-windows-msvc.exe"); } // ─── feature_suffix ─────────────────────────────────────────────────────── #[test] fn suffix_none_when_no_features_and_defaults() { let s = feature_suffix(&[], false, &None); assert_eq!(s, None); } #[test] fn suffix_single_feature() { let s = feature_suffix(&["full".into()], false, &None); assert_eq!(s.as_deref(), Some("full")); } #[test] fn suffix_multiple_features_joined_with_plus() { let s = feature_suffix(&["foo".into(), "bar".into()], false, &None); assert_eq!(s.as_deref(), Some("foo+bar")); } #[test] fn suffix_no_default_without_features() { let s = feature_suffix(&[], true, &None); assert_eq!(s.as_deref(), Some("no-default")); } #[test] fn suffix_no_default_with_features_uses_features() { let s = feature_suffix(&["full".into()], true, &None); assert_eq!(s.as_deref(), Some("full")); } #[test] fn suffix_custom_override() { let s = feature_suffix(&["full".into()], false, &Some("production".into())); assert_eq!(s.as_deref(), Some("production")); } #[test] fn suffix_empty_string_treated_as_none() { let s = feature_suffix(&["full".into()], false, &Some(String::new())); assert_eq!(s, None); } // ─── Artifact name generation ───────────────────────────────────────────── #[test] fn artifact_name_with_single_feature() { let stem = "abbaye"; let dot_ext = ""; let version = "0.10.0"; let triple = "x86_64-unknown-linux-musl"; let suf = "full"; let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}"); assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl-full"); } #[test] fn artifact_name_with_exe_and_feature() { let stem = "abbaye"; let dot_ext = ".exe"; let version = "0.10.0"; let triple = "x86_64-pc-windows-msvc"; let suf = "lite"; let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}"); assert_eq!(name, "abbaye-0.10.0-x86_64-pc-windows-msvc-lite.exe"); } #[test] fn artifact_name_with_no_default_only() { let stem = "abbaye"; let dot_ext = ""; let version = "0.10.0"; let triple = "x86_64-unknown-linux-gnu"; let suf = "no-default"; let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}"); assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-gnu-no-default"); } #[test] fn artifact_name_with_custom_suffix() { let stem = "abbaye"; let dot_ext = ""; let version = "0.10.0"; let triple = "x86_64-unknown-linux-musl"; let suf = "production"; let name = format!("{stem}-{version}-{triple}-{suf}{dot_ext}"); assert_eq!(name, "abbaye-0.10.0-x86_64-unknown-linux-musl-production"); } // ─── relocate_artifacts ────────────────────────────────────────────────── #[tokio::test] async fn test_relocate_artifacts_copies_to_target() { let tmp = tempfile::tempdir().unwrap(); let tmp_root = tmp.path().join("cross-tmp"); let triple_dir = tmp_root.join("x86_64-unknown-linux-musl").join("release"); tokio::fs::create_dir_all(&triple_dir).await.unwrap(); let binary_path = triple_dir.join("abbaye"); tokio::fs::write(&binary_path, b"binary content") .await .unwrap(); let artifacts = vec![ArtifactPath { path: binary_path, name: "abbaye-0.10.0-x86_64-unknown-linux-musl".to_owned(), hash: None, category: None, group_name: None, group_comment: None, }]; let relocated = relocate_artifacts(artifacts, &tmp_root, None) .await .unwrap(); assert_eq!(relocated.len(), 1); let expected = Path::new("target") .join("x86_64-unknown-linux-musl") .join("release") .join("abbaye"); assert_eq!(relocated[0].path, expected); assert!(expected.exists(), "binary should exist at canonical path"); let content = tokio::fs::read_to_string(&expected).await.unwrap(); assert_eq!(content, "binary content"); } // ─── get_host_target ───────────────────────────────────────────────────── #[tokio::test] async fn relocate_artifacts_with_release_override() { let tmp = tempfile::tempdir().unwrap(); let tmp_root = tmp.path().join("cross-tmp"); let triple = "arm-unknown-linux-gnueabihf"; let triple_dir = tmp_root.join(triple).join("release"); tokio::fs::create_dir_all(&triple_dir).await.unwrap(); let binary_path = triple_dir.join("myapp"); tokio::fs::write(&binary_path, b"feature-specific content") .await .unwrap(); let artifacts = vec![ArtifactPath { path: binary_path, name: "myapp-0.10.0-arm-unknown-linux-gnueabihf-no-default".to_owned(), hash: None, category: None, group_name: None, group_comment: None, }]; let relocated = relocate_artifacts(artifacts, &tmp_root, Some("release-no-default")) .await .unwrap(); assert_eq!(relocated.len(), 1); let expected = Path::new("target") .join(triple) .join("release-no-default") .join("myapp"); assert_eq!(relocated[0].path, expected); assert!(expected.exists(), "binary should exist at override path"); let content = tokio::fs::read_to_string(&expected).await.unwrap(); assert_eq!(content, "feature-specific content"); } #[tokio::test] async fn test_get_host_target_returns_triple() { let triple = get_host_target().await.unwrap(); assert!(!triple.is_empty(), "host target triple should not be empty"); // Should contain at least one dash (e.g. x86_64-unknown-linux-gnu) assert!( triple.contains('-'), "triple should be dash-separated: {triple}" ); } // ─── read_crate_version ───────────────────────────────────────────────── #[tokio::test] async fn test_read_crate_version_from_toml() { let tmp = tempfile::tempdir().unwrap(); let toml_path = tmp.path().join("Cargo.toml"); tokio::fs::write( &toml_path, "[package]\nname = \"test\"\nversion = \"0.5.0\"\n", ) .await .unwrap(); let version = read_crate_version(Some(&toml_path)).await.unwrap(); assert_eq!(version, "0.5.0"); } #[tokio::test] async fn test_read_crate_version_returns_error_on_missing() { let tmp = tempfile::tempdir().unwrap(); let toml_path = tmp.path().join("Cargo.toml"); tokio::fs::write(&toml_path, "[package]\nname = \"no-version\"\n") .await .unwrap(); let result = read_crate_version(Some(&toml_path)).await; assert!( result.is_err(), "should error when version field is missing" ); } // ─── use_cross parallel condition ──────────────────────────────────────── #[test] fn use_cross_disables_parallel_isolation() { // This validates the fix: when use_cross is true, the parallel // isolation path (tempdir + relocate) must NOT be taken. // The condition is `config.parallel && !config.use_cross` -- so // when use_cross is true, the result should be false regardless // of the parallel setting. let uses_isolation = |parallel: bool, use_cross: bool| -> bool { parallel && !use_cross }; assert!( !uses_isolation(true, true), "parallel=true + use_cross=true should NOT use isolation" ); assert!( !uses_isolation(false, true), "parallel=false + use_cross=true should NOT use isolation" ); assert!( uses_isolation(true, false), "parallel=true + use_cross=false SHOULD use isolation" ); assert!( !uses_isolation(false, false), "parallel=false + use_cross=false should NOT use isolation" ); } // ─── Binary name filtering (extension check) ──────────────────────────── #[test] fn skips_rlib_and_rmeta_and_dot_d_files() { for ext in ["rlib", "rmeta", "d"] { let filename = format!("/target/release/libfoo.{ext}"); let path = Path::new(&filename); let ext_str = path .extension() .map(|e| e.to_string_lossy()) .unwrap_or_default(); assert!( ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d", "{ext} should match skip condition" ); } } #[test] fn keeps_executable_and_cdylib_files() { for ext in ["", "exe", "so", "dylib", "dll"] { let filename = if ext.is_empty() { "/target/release/my-bin".to_owned() } else { format!("/target/release/my-bin.{ext}") }; let path = Path::new(&filename); let ext_str = path .extension() .map(|e| e.to_string_lossy()) .unwrap_or_default(); let is_skippable = ext_str == "rlib" || ext_str == "rmeta" || ext_str == "d"; assert!(!is_skippable, "{ext} should NOT be skipped"); } } // ─── Parallel flag default ─────────────────────────────────────────────── #[test] fn default_parallel_is_true() { assert!(default_parallel(), "parallel should default to true"); } #[test] fn use_cross_defaults_to_false() { let config = CargoBuilderConfig::default(); assert!(!config.use_cross, "use_cross should default to false"); } // ─── Feature fields defaults ────────────────────────────────────────────── #[test] fn features_defaults_to_empty() { let config = CargoBuilderConfig::default(); assert!(config.features.is_empty()); } #[test] fn no_default_features_defaults_to_false() { let config = CargoBuilderConfig::default(); assert!(!config.no_default_features); } #[test] fn suffix_defaults_to_none() { let config = CargoBuilderConfig::default(); assert!(config.suffix.is_none()); } } /// Configuration for [`CargoDocBuilder`]. #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)] pub struct CargoDocBuilderConfig { /// Optional path to the Cargo.toml manifest. /// /// Passed verbatim as `--manifest-path`. Defaults to the manifest in the /// current working directory when absent. pub manifest_path: Option<std::path::PathBuf>, /// Skip building documentation for dependencies (`--no-deps`). #[serde(default)] pub no_deps: bool, } /// Runs `cargo doc` and returns the whole doc directory as an artifact. pub struct CargoDocBuilder; impl Builder for CargoDocBuilder { type ConfigType = CargoDocBuilderConfig; async fn build( &self, config: Self::ConfigType, abbaye_version: &str, log: LogSender, ) -> Result<Vec<ArtifactPath>> { let mut cmd = Command::new("cargo"); cmd.arg("doc"); cmd.env("ABBAYE_BUILDING_VERSION", abbaye_version); if config.no_deps { cmd.arg("--no-deps"); } if let Some(manifest) = &config.manifest_path { cmd.arg("--manifest-path").arg(manifest); } let mut child = cmd.stderr(Stdio::piped()).spawn().into_diagnostic()?; let stderr = child.stderr.take().expect("stderr was piped"); tokio::spawn(async move { let mut stderr_lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = stderr_lines.next_line().await { let _ = log.send(LogEvent::Line(line)); } }); let status = child.wait().await.into_diagnostic()?; if !status.success() { return Err(miette!("cargo doc failed with exit status: {status}")); } let doc_dir = config .manifest_path .as_deref() .and_then(|p| p.parent()) .unwrap_or_else(|| std::path::Path::new(".")) .join("target/doc"); if !doc_dir.exists() { return Err(miette!("doc directory not found at {}", doc_dir.display())); } Ok(vec![ArtifactPath { path: doc_dir, name: "doc".to_owned(), hash: None, category: None, group_name: None, group_comment: None, }]) } }