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