Abbaye

at 8b03dd8

use std::{
    path::{Path, PathBuf},
    process::Stdio,
};

use miette::{IntoDiagnostic, Result, miette};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;

use crate::builders::{ArtifactPath, Builder};

/// Configuration for [`CargoBuilder`].
#[derive(Debug, Default, 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<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>,
}

/// Runs `cargo 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,
    ) -> Result<Vec<ArtifactPath>> {
        let crate_version = read_crate_version(config.manifest_path.as_deref()).await?;

        if config.targets.is_empty() {
            let host = get_host_target().await?;
            run_cargo_build(&config, None, &host, &crate_version, abbaye_version).await
        } else {
            let mut all_artifacts = Vec::new();
            for target in &config.targets {
                let artifacts = run_cargo_build(
                    &config,
                    Some(target.as_str()),
                    target,
                    &crate_version,
                    abbaye_version,
                )
                .await?;
                all_artifacts.extend(artifacts);
            }
            Ok(all_artifacts)
        }
    }
}

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

/// Spawn `cargo build --release --message-format=json [--target <triple>]
/// [--manifest-path <path>]` and collect every artifact path from the
/// `compiler-artifact` messages.
async fn run_cargo_build(
    config: &CargoBuilderConfig,
    target: Option<&str>,
    triple: &str,
    version: &str,
    abbaye_version: &str,
) -> Result<Vec<ArtifactPath>> {
    let mut cmd = Command::new("cargo");
    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);
    }

    let mut child = cmd
        .stdout(Stdio::piped())
        .stderr(Stdio::inherit()) // let cargo's human-readable errors reach the terminal
        .spawn()
        .into_diagnostic()?;

    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;
        }

        // 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 = PathBuf::from(&filename);

            // Skip rlib / rmeta files; we only want executables and cdylibs.
            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
            if matches!(ext, "rlib" | "rmeta" | "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,
            });
        }
    }

    let status = child.wait().await.into_diagnostic()?;

    if !status.success() {
        return Err(miette!(
            "cargo build --release failed with exit status: {status}"
        ));
    }

    Ok(artifacts)
}

/// 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<&Path>) -> Result<String> {
    let path = manifest_path.unwrap_or(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()))
}

/// 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<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,
    ) -> 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 status = cmd
            .stderr(Stdio::inherit())
            .status()
            .await
            .into_diagnostic()?;

        if !status.success() {
            return Err(miette!("cargo doc failed with exit status: {status}"));
        }

        // Resolve the doc output directory. When a manifest path is given the
        // workspace root is its parent directory; otherwise fall back to CWD.
        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()));
        }

        // Return the entire target/doc tree as a single artifact so that the
        // shared rustdoc assets (CSS, JS, fonts, search indices) that live at
        // the root of target/doc/ are preserved alongside the per-crate HTML.
        Ok(vec![ArtifactPath {
            path: doc_dir,
            name: "doc".to_owned(),
            hash: None,
        }])
    }
}