Abbaye

at 14e0e47 Raw

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,

    /// 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,
        }
    }
}

/// 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?;

        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);
            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();

                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 result = if config.parallel && !config.use_cross {
                        // Give this invocation its own target directory so it
                        // does not contend with sibling builds on cargo's lock.
                        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()).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);
    }

    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}{ext}` so that
            // binaries for different targets can coexist in the same dist dir.
            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}-{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.
async fn relocate_artifacts(
    artifacts: Vec<ArtifactPath>,
    tmp_root: &std::path::Path,
) -> 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 = 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()))
}

#[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");
    }

    // ─── 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).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 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");
    }
}

/// 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,
        }])
    }
}