use crate::error::{InstallerError, Result};
use crate::toolchain::Toolchain;
use camino::Utf8PathBuf;
use std::process::Command;
pub use crate::crate_name::CrateName;
pub use crate::resolution::{
CrateResolutionOptions, EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE, is_known_crate,
resolve_crates, validate_crate_names,
};
pub use crate::workspace::find_workspace_root;
#[derive(Debug, Clone)]
pub struct BuildConfig {
pub toolchain: Toolchain,
pub target_dir: Utf8PathBuf,
pub jobs: Option<usize>,
pub verbosity: u8,
pub experimental: bool,
}
#[derive(Debug, Clone)]
pub struct BuildResult {
pub crate_name: CrateName,
pub library_path: Utf8PathBuf,
}
#[cfg_attr(test, mockall::automock)]
pub trait CrateBuilder {
fn build_all(&self, crates: &[CrateName]) -> Result<Vec<BuildResult>>;
}
pub struct Builder {
config: BuildConfig,
}
impl Builder {
#[must_use]
pub fn new(config: BuildConfig) -> Self {
Self { config }
}
pub fn build_crate(&self, crate_name: &CrateName) -> Result<BuildResult> {
let mut cmd = Command::new("cargo");
cmd.arg(format!("+{}", self.config.toolchain.channel()));
cmd.args(["build", "--release"]);
let features = self.features_for_crate(crate_name);
cmd.args(["--features", &features]);
cmd.args(["-p", crate_name.as_str()]);
if let Some(jobs) = self.config.jobs {
cmd.args(["-j", &jobs.to_string()]);
}
cmd.env("CARGO_TARGET_DIR", self.config.target_dir.as_str());
cmd.current_dir(self.config.toolchain.workspace_root());
for _ in 0..self.config.verbosity {
cmd.arg("-v");
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(InstallerError::BuildFailed {
crate_name: crate_name.clone(),
reason: stderr.to_string(),
});
}
let library_path = self.library_path(crate_name);
if !library_path.exists() {
return Err(InstallerError::BuildFailed {
crate_name: crate_name.clone(),
reason: format!(
"cargo succeeded but expected library was not found at: {library_path}"
),
});
}
Ok(BuildResult {
crate_name: crate_name.clone(),
library_path,
})
}
pub fn build_all(&self, crates: &[CrateName]) -> Result<Vec<BuildResult>> {
let mut results = Vec::with_capacity(crates.len());
for crate_name in crates {
let result = self.build_crate(crate_name)?;
results.push(result);
}
Ok(results)
}
fn library_path(&self, crate_name: &CrateName) -> Utf8PathBuf {
let lib_name = format!(
"{}{}{}",
library_prefix(),
crate_name.as_str().replace('-', "_"),
library_extension()
);
self.config.target_dir.join("release").join(lib_name)
}
fn features_for_crate(&self, crate_name: &CrateName) -> String {
if crate_name.as_str() == SUITE_CRATE && self.config.experimental {
let experimental = Self::experimental_features();
if experimental.is_empty() {
"dylint-driver".to_owned()
} else {
format!("dylint-driver,{experimental}")
}
} else {
"dylint-driver".to_owned()
}
}
fn experimental_features() -> String {
EXPERIMENTAL_LINT_CRATES
.iter()
.map(|&name| format!("experimental-{}", name.replace('_', "-")))
.collect::<Vec<_>>()
.join(",")
}
#[must_use]
pub fn config(&self) -> &BuildConfig {
&self.config
}
}
impl CrateBuilder for Builder {
fn build_all(&self, crates: &[CrateName]) -> Result<Vec<BuildResult>> {
self.build_all(crates)
}
}
#[must_use]
pub const fn library_extension() -> &'static str {
#[cfg(target_os = "macos")]
{
".dylib"
}
#[cfg(target_os = "windows")]
{
".dll"
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
".so"
}
}
#[must_use]
pub const fn library_prefix() -> &'static str {
#[cfg(target_os = "windows")]
{
""
}
#[cfg(not(target_os = "windows"))]
{
"lib"
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::{fixture, rstest};
#[fixture]
fn builder() -> Builder {
Builder {
config: BuildConfig {
toolchain: Toolchain::with_override(
&Utf8PathBuf::from("/tmp/test"),
"nightly-2026-05-28",
),
target_dir: Utf8PathBuf::from("/tmp/target"),
jobs: None,
verbosity: 0,
experimental: false,
},
}
}
#[test]
fn library_extension_is_correct() {
let ext = library_extension();
#[cfg(target_os = "linux")]
assert_eq!(ext, ".so");
#[cfg(target_os = "macos")]
assert_eq!(ext, ".dylib");
#[cfg(target_os = "windows")]
assert_eq!(ext, ".dll");
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
assert_eq!(ext, ".so");
}
#[rstest]
#[case::non_suite_crate("module_max_lines", false, "dylint-driver")]
#[case::non_suite_with_experimental("module_max_lines", true, "dylint-driver")]
#[case::suite_without_experimental("whitaker_suite", false, "dylint-driver")]
fn features_for_crate_returns_expected_features(
mut builder: Builder,
#[case] crate_name: &str,
#[case] experimental: bool,
#[case] expected: &str,
) {
builder.config.experimental = experimental;
let result = builder.features_for_crate(&CrateName::from(crate_name));
assert_eq!(result, expected);
}
#[rstest]
fn features_for_crate_handles_suite_experimental_mode(mut builder: Builder) {
builder.config.experimental = true;
let result = builder.features_for_crate(&CrateName::from("whitaker_suite"));
let mut expected_features = vec!["dylint-driver".to_owned()];
expected_features.extend(
EXPERIMENTAL_LINT_CRATES
.iter()
.map(|&lint| format!("experimental-{}", lint.replace('_', "-"))),
);
let expected = expected_features.join(",");
assert_eq!(result, expected);
}
#[test]
fn experimental_features_derives_from_experimental_lint_crates() {
let features = Builder::experimental_features();
let expected = EXPERIMENTAL_LINT_CRATES
.iter()
.map(|&lint| format!("experimental-{}", lint.replace('_', "-")))
.collect::<Vec<_>>()
.join(",");
if expected.is_empty() {
assert!(
features.is_empty(),
concat!(
"Builder::experimental_features should return an empty string when ",
"EXPERIMENTAL_LINT_CRATES is empty"
)
);
return;
}
assert_eq!(features, expected);
}
}