use crate::{
Result,
cache::Cache,
cargo::{CargoMetadataOptions, CargoRunner, CargoVerbosity, Metadata},
cli::CliArgs,
config::Config,
downloader::DownloadedCrate,
error,
resolver::ResolvedSource,
};
use cargo_metadata::Target;
use snafu::ResultExt;
use std::{borrow::Cow, path::PathBuf, sync::Arc};
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum BuildTarget {
#[default]
DefaultBin,
Bin(String),
Example(String),
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct BuildOptions {
pub features: Vec<String>,
pub all_features: bool,
pub no_default_features: bool,
pub profile: Option<String>,
pub target: Option<String>,
pub locked: bool,
pub offline: bool,
pub jobs: Option<usize>,
pub ignore_rust_version: bool,
pub build_target: BuildTarget,
pub toolchain: Option<String>,
pub cargo_verbosity: CargoVerbosity,
}
impl BuildOptions {
pub fn load(config: &Config, args: &CliArgs) -> Result<Self> {
let features = if let Some(features_str) = &args.features {
Self::parse_features(features_str)
} else {
Vec::new()
};
let profile = if args.debug {
Some("dev".to_string())
} else {
args.profile.clone()
};
let build_target = match (&args.bin, &args.example) {
(Some(_), Some(_)) => {
unreachable!("BUG: clap should enforce mutual exclusivity");
}
(Some(bin_name), None) => BuildTarget::Bin(bin_name.clone()),
(None, Some(example_name)) => BuildTarget::Example(example_name.clone()),
(None, None) => BuildTarget::default(),
};
let locked = args.locked || args.frozen || config.locked;
let offline = args.offline || args.frozen || config.offline;
let toolchain = args.toolchain.clone().or_else(|| config.toolchain.clone());
Ok(BuildOptions {
features,
all_features: args.all_features,
no_default_features: args.no_default_features,
profile,
target: args.target.clone(),
locked,
offline,
jobs: args.jobs,
ignore_rust_version: args.ignore_rust_version,
build_target,
toolchain,
cargo_verbosity: CargoVerbosity::from_count(args.verbose),
})
}
fn parse_features(features_str: &str) -> Vec<String> {
features_str
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
}
pub trait CrateBuilder {
fn list_targets(
&self,
krate: &DownloadedCrate,
options: &BuildOptions,
) -> Result<(Option<Target>, Vec<Target>, Vec<Target>)>;
fn build(&self, krate: &DownloadedCrate, options: &BuildOptions) -> Result<PathBuf>;
}
pub(crate) fn create_builder(
config: Config,
cache: Cache,
cargo_runner: Arc<dyn CargoRunner>,
) -> impl CrateBuilder {
RealCrateBuilder {
config,
cache,
cargo_runner,
}
}
struct RealCrateBuilder {
config: Config,
cache: Cache,
cargo_runner: Arc<dyn CargoRunner>,
}
impl CrateBuilder for RealCrateBuilder {
fn list_targets(
&self,
krate: &DownloadedCrate,
options: &BuildOptions,
) -> Result<(Option<Target>, Vec<Target>, Vec<Target>)> {
let metadata = self
.cargo_runner
.metadata(&krate.crate_path, &CargoMetadataOptions::from(options))?;
Self::list_targets_internal(krate, &metadata)
}
fn build(&self, krate: &DownloadedCrate, options: &BuildOptions) -> Result<PathBuf> {
let metadata = self
.cargo_runner
.metadata(&krate.crate_path, &CargoMetadataOptions::from(options))?;
let options: Cow<'_, BuildOptions> = if matches!(options.build_target, BuildTarget::DefaultBin) {
Cow::Owned(BuildOptions {
build_target: Self::resolve_binary_target(krate, options, &metadata)?,
..options.clone()
})
} else {
Cow::Borrowed(options)
};
if matches!(krate.resolved.source, ResolvedSource::LocalDir { .. }) {
return self.build_uncached(krate, options.as_ref(), &metadata);
}
self.cache
.get_or_build_binary(&krate.resolved, options.as_ref(), &metadata, || {
self.build_uncached(krate, options.as_ref(), &metadata)
})
}
}
impl RealCrateBuilder {
fn list_targets_internal(
krate: &DownloadedCrate,
metadata: &Metadata,
) -> Result<(Option<Target>, Vec<Target>, Vec<Target>)> {
let package = metadata
.packages
.iter()
.find(|p| p.name.as_str() == krate.resolved.name)
.ok_or_else(|| {
error::PackageNotFoundInWorkspaceSnafu {
name: krate.resolved.name.clone(),
available: metadata
.packages
.iter()
.map(|p| p.name.to_string())
.collect::<Vec<_>>(),
}
.build()
})?;
let bin_targets: Vec<_> = package
.targets
.iter()
.filter(|t| {
t.kind
.iter()
.any(|k| matches!(k, cargo_metadata::TargetKind::Bin))
})
.cloned()
.collect();
let example_targets: Vec<_> = package
.targets
.iter()
.filter(|t| {
t.kind
.iter()
.any(|k| matches!(k, cargo_metadata::TargetKind::Example))
})
.cloned()
.collect();
let default = package.default_run.as_ref().and_then(|default_run| {
bin_targets
.iter()
.find(|t| t.name == default_run.as_str())
.cloned()
});
Ok((default, bin_targets, example_targets))
}
fn resolve_binary_target(
krate: &DownloadedCrate,
options: &BuildOptions,
metadata: &Metadata,
) -> Result<BuildTarget> {
let (default, bins, examples) = Self::list_targets_internal(krate, metadata)?;
let build_target = if matches!(options.build_target, BuildTarget::DefaultBin) {
if let Some(default) = default {
BuildTarget::Bin(default.name.clone())
} else {
BuildTarget::DefaultBin
}
} else {
options.build_target.clone()
};
match build_target {
BuildTarget::DefaultBin => {
match bins.len() {
0 => {
error::NoPackageBinariesSnafu {
krate: krate.resolved.name.clone(),
}
.fail()
}
1 => {
Ok(BuildTarget::Bin(bins[0].name.clone()))
}
_ => {
error::AmbiguousBinaryTargetSnafu {
package: krate.resolved.name.clone(),
available: bins.iter().map(|t| t.name.clone()).collect::<Vec<_>>(),
}
.fail()
}
}
}
BuildTarget::Bin(ref name) => {
if bins.iter().any(|t| t.name == *name) {
Ok(build_target)
} else {
error::RunnableTargetNotFoundSnafu {
kind: "binary",
package: krate.resolved.name.clone(),
target: name.clone(),
available: bins.iter().map(|t| t.name.clone()).collect::<Vec<_>>(),
}
.fail()
}
}
BuildTarget::Example(ref name) => {
if examples.iter().any(|t| t.name == *name) {
Ok(build_target)
} else {
error::RunnableTargetNotFoundSnafu {
kind: "example",
package: krate.resolved.name.clone(),
target: name.clone(),
available: bins.iter().map(|t| t.name.clone()).collect::<Vec<_>>(),
}
.fail()
}
}
}
}
fn build_uncached(
&self,
krate: &DownloadedCrate,
options: &BuildOptions,
metadata: &Metadata,
) -> Result<PathBuf> {
let build_dir = self.prepare_build_dir(krate)?;
let package_name = Self::resolve_package_name(metadata, &krate.resolved.name)?;
let binary_path = self
.cargo_runner
.build(&build_dir, package_name.as_deref(), options)?;
Ok(binary_path)
}
fn prepare_build_dir(&self, krate: &DownloadedCrate) -> Result<PathBuf> {
if let ResolvedSource::LocalDir { .. } = krate.resolved.source {
return Ok(krate.crate_path.clone());
}
std::fs::create_dir_all(&self.config.build_dir).with_context(|_| error::IoSnafu {
path: self.config.build_dir.clone(),
})?;
let temp_dir = tempfile::Builder::new()
.prefix(&format!("cgx-build-{}", &krate.resolved.name))
.tempdir_in(&self.config.build_dir)
.with_context(|_| error::TempDirCreationSnafu {
parent: self.config.build_dir.clone(),
})?;
let temp_path = temp_dir.path().to_path_buf();
crate::helpers::copy_source_tree(&krate.crate_path, &temp_path)?;
let _ = temp_dir.keep();
Ok(temp_path)
}
fn resolve_package_name(metadata: &Metadata, crate_name: &str) -> Result<Option<String>> {
let workspace_members: Vec<_> = metadata
.workspace_packages()
.iter()
.map(|p| p.name.as_str())
.collect();
match workspace_members.len() {
0 | 1 => Ok(None),
_ => {
if workspace_members.iter().any(|name| *name == crate_name) {
Ok(Some(crate_name.to_string()))
} else {
error::PackageNotFoundInWorkspaceSnafu {
name: crate_name.to_string(),
available: workspace_members
.into_iter()
.map(String::from)
.collect::<Vec<_>>(),
}
.fail()
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
cargo::find_cargo,
error::Error,
resolver::{ResolvedCrate, ResolvedSource},
testdata::CrateTestCase,
};
use assert_matches::assert_matches;
use semver::Version;
use std::{fs, path::Path};
fn test_builder() -> (RealCrateBuilder, tempfile::TempDir) {
crate::logging::init_test_logging();
let (temp_dir, config) = crate::config::create_test_env();
fs::create_dir_all(&config.cache_dir).unwrap();
fs::create_dir_all(&config.bin_dir).unwrap();
fs::create_dir_all(&config.build_dir).unwrap();
let cache = Cache::new(config.clone());
let cargo_runner = Arc::new(find_cargo().unwrap());
let builder = RealCrateBuilder {
config,
cache,
cargo_runner,
};
(builder, temp_dir)
}
#[derive(Debug, Clone)]
enum FakeSourceType {
Registry { version: String },
Git { url: String, rev: String },
LocalDir,
}
fn fake_downloaded_crate(
tc: &CrateTestCase,
source_type: FakeSourceType,
package_name: Option<&str>,
) -> DownloadedCrate {
let (resolved_source, crate_path) = match &source_type {
FakeSourceType::Registry { .. } => {
let path = if let Some(pkg) = package_name {
tc.path().join(pkg)
} else {
tc.path().to_path_buf()
};
(ResolvedSource::CratesIo, path)
}
FakeSourceType::Git { url, rev } => {
(
ResolvedSource::Git {
repo: url.clone(),
commit: rev.clone(),
},
tc.path().to_path_buf(),
)
}
FakeSourceType::LocalDir => {
let path = tc.path().to_path_buf();
(ResolvedSource::LocalDir { path: path.clone() }, path)
}
};
let name = package_name.unwrap_or(tc.name).to_string();
let version = match &source_type {
FakeSourceType::Registry { version } => Version::parse(version).unwrap(),
_ => Version::parse("0.1.0").unwrap(),
};
DownloadedCrate {
resolved: ResolvedCrate {
name,
version,
source: resolved_source,
},
crate_path,
}
}
fn read_sbom_for_binary(binary_path: &Path) -> PathBuf {
binary_path.parent().unwrap().join("sbom.cyclonedx.json")
}
fn expected_bin_name(base_name: &str) -> String {
format!("{}{}", base_name, std::env::consts::EXE_SUFFIX)
}
fn assert_cache_hit(path1: &Path, path2: &Path) {
assert_eq!(
path1,
path2,
"Cache hit expected: paths should be identical\n path1: {}\n path2: {}",
path1.display(),
path2.display()
);
let mtime1 = fs::metadata(path1).unwrap().modified().unwrap();
let mtime2 = fs::metadata(path2).unwrap().modified().unwrap();
assert_eq!(
mtime1,
mtime2,
"Cache hit expected: modification times should be identical\n path1: {}\n path2: {}",
path1.display(),
path2.display()
);
}
fn assert_cache_miss(path1: &Path, path2: &Path) {
let paths_differ = path1 != path2;
let mtimes_differ = if path1.exists() && path2.exists() {
let mtime1 = fs::metadata(path1).unwrap().modified().unwrap();
let mtime2 = fs::metadata(path2).unwrap().modified().unwrap();
mtime1 != mtime2
} else {
true
};
assert!(
paths_differ || mtimes_differ,
"Cache miss expected: paths or mtimes should differ\n path1: {}\n path2: {}\n paths_differ: \
{}\n mtimes_differ: {}",
path1.display(),
path2.display(),
paths_differ,
mtimes_differ
);
}
#[derive(Debug)]
struct TimestampOutput {
build_timestamp: String,
features: Vec<String>,
}
fn run_timestamp_binary(path: &Path) -> TimestampOutput {
let output = std::process::Command::new(path)
.output()
.unwrap_or_else(|e| panic!("Failed to execute timestamp binary at {}: {}", path.display(), e));
assert!(
output.status.success(),
"Timestamp binary failed with status {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let mut build_timestamp = None;
let mut features = Vec::new();
for line in stdout.lines() {
if let Some(ts) = line.strip_prefix("Built at: ") {
build_timestamp = Some(ts.to_string());
}
if let Some(feat_str) = line.strip_prefix("Features enabled: ") {
if feat_str != "none" {
features = feat_str.split(", ").map(|s| s.to_string()).collect();
}
}
}
TimestampOutput {
build_timestamp: build_timestamp.expect("No 'Built at:' line in timestamp output"),
features,
}
}
fn assert_cache_hit_by_timestamp(output1: &TimestampOutput, output2: &TimestampOutput) {
assert_eq!(
output1.build_timestamp, output2.build_timestamp,
"Cache hit expected: build timestamps should match\n ts1: {}\n ts2: {}",
output1.build_timestamp, output2.build_timestamp
);
}
fn assert_cache_miss_by_timestamp(output1: &TimestampOutput, output2: &TimestampOutput) {
assert_ne!(
output1.build_timestamp, output2.build_timestamp,
"Cache miss expected: build timestamps should differ\n ts1: {}\n ts2: {}",
output1.build_timestamp, output2.build_timestamp
);
}
mod smoke_tests {
use super::*;
#[test]
fn builds_all_testcases_with_bins() {
let (builder, _temp) = test_builder();
let cargo = find_cargo().unwrap();
for tc in CrateTestCase::all() {
let metadata_opts = CargoMetadataOptions::default();
let metadata = cargo.metadata(tc.path(), &metadata_opts).unwrap();
let workspace_pkgs = metadata.workspace_packages();
let buildable_packages: Vec<_> = workspace_pkgs
.iter()
.filter(|pkg| {
pkg.targets.iter().any(|t| {
t.kind
.iter()
.any(|k| matches!(k, cargo_metadata::TargetKind::Bin))
})
})
.collect();
if buildable_packages.is_empty() {
continue;
}
for pkg in buildable_packages {
let krate = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
Some(&pkg.name),
);
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let result = builder.build(&krate, &options);
if let Ok(binary) = result {
assert!(binary.exists(), "Binary missing for {}/{}", tc.name, pkg.name);
let binary_name = binary.file_name().unwrap().to_str().unwrap();
let bin_targets: Vec<_> = pkg
.targets
.iter()
.filter(|t| {
t.kind
.iter()
.any(|k| matches!(k, cargo_metadata::TargetKind::Bin))
})
.collect();
let expected_name = if bin_targets.len() == 1 {
bin_targets[0].name.as_str()
} else if let Some(ref default_run) = pkg.default_run {
default_run.as_str()
} else {
panic!(
"Build succeeded for {}/{} but should have failed due to ambiguous binary \
target",
tc.name, pkg.name
);
};
assert_eq!(
binary_name,
expected_bin_name(expected_name),
"Wrong binary name for {}/{}: expected '{}', got '{}'",
tc.name,
pkg.name,
expected_name,
binary_name
);
}
}
}
}
#[test]
fn simple_bin_no_deps_from_registry() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::simple_bin_no_deps();
let krate = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let binary = builder.build(&krate, &options).unwrap();
assert!(binary.exists());
assert!(binary.is_file());
assert!(binary.starts_with(&builder.config.bin_dir));
let binary_name = binary.file_name().unwrap().to_str().unwrap();
assert_eq!(binary_name, expected_bin_name("simple-bin-no-deps"));
}
}
mod binary_selection {
use super::*;
#[test]
fn default_bin_selected_automatically() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::single_crate_multiple_bins_with_default();
let krate = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
build_target: BuildTarget::DefaultBin,
..Default::default()
};
let binary = builder.build(&krate, &options).unwrap();
assert!(binary.exists());
let binary_name = binary.file_name().unwrap().to_str().unwrap();
assert_eq!(
binary_name,
expected_bin_name("bin1"),
"Should build bin1 or the crate's default binary, got: {}",
binary_name
);
}
#[test]
fn explicit_bin_overrides_default() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::single_crate_multiple_bins_with_default();
let krate = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
build_target: BuildTarget::Bin("bin2".to_string()),
..Default::default()
};
let binary = builder.build(&krate, &options).unwrap();
assert!(binary.exists());
let binary_name = binary.file_name().unwrap().to_str().unwrap();
assert_eq!(binary_name, expected_bin_name("bin2"));
}
#[test]
fn multiple_bins_without_default_fails() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::single_crate_multiple_bins();
let krate = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let result = builder.build(&krate, &options);
assert_matches!(
result,
Err(Error::AmbiguousBinaryTarget { ref package, ref available })
if package == "single-crate-multiple-bins"
&& available.len() == 2
&& available.contains(&"bin1".to_string())
&& available.contains(&"bin2".to_string())
);
}
}
mod workspace_handling {
use super::*;
#[test]
fn workspace_with_correct_package_succeeds() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::workspace_multiple_bin_crates();
let krate = fake_downloaded_crate(
&tc,
FakeSourceType::Git {
url: "https://github.com/example/test.git".to_string(),
rev: "abc123".to_string(),
},
Some("bin1"),
);
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let binary = builder.build(&krate, &options).unwrap();
assert!(binary.exists());
let binary_name = binary.file_name().unwrap().to_str().unwrap();
assert_eq!(binary_name, expected_bin_name("bin1"));
}
#[test]
fn workspace_with_wrong_package_fails() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::workspace_multiple_bin_crates();
let krate = DownloadedCrate {
resolved: ResolvedCrate {
name: "nonexistent-package".to_string(),
version: Version::parse("1.0.0").unwrap(),
source: ResolvedSource::CratesIo,
},
crate_path: tc.path().to_path_buf(),
};
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let result = builder.build(&krate, &options);
assert_matches!(
result,
Err(Error::PackageNotFoundInWorkspace { ref name, ref available })
if name == "nonexistent-package" && !available.is_empty()
);
}
}
mod cache_functional {
use super::*;
#[test]
fn identical_builds_hit_cache() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::timestamp();
let krate1 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let binary1 = builder.build(&krate1, &options).unwrap();
let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
assert_eq!(binary1_name, expected_bin_name("timestamp"));
let output1 = run_timestamp_binary(&binary1);
std::thread::sleep(std::time::Duration::from_millis(100));
let krate2 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let binary2 = builder.build(&krate2, &options).unwrap();
let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
assert_eq!(binary2_name, expected_bin_name("timestamp"));
let output2 = run_timestamp_binary(&binary2);
assert_cache_hit_by_timestamp(&output1, &output2);
assert_cache_hit(&binary1, &binary2);
}
#[test]
fn different_profile_cache_miss() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::timestamp();
let krate1 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options1 = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let binary1 = builder.build(&krate1, &options1).unwrap();
let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
assert_eq!(binary1_name, expected_bin_name("timestamp"));
let output1 = run_timestamp_binary(&binary1);
let krate2 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options2 = BuildOptions {
profile: Some("release".to_string()),
..Default::default()
};
let binary2 = builder.build(&krate2, &options2).unwrap();
let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
assert_eq!(binary2_name, expected_bin_name("timestamp"));
let output2 = run_timestamp_binary(&binary2);
assert_cache_miss_by_timestamp(&output1, &output2);
assert_cache_miss(&binary1, &binary2);
}
#[test]
fn different_target_cache_miss() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::simple_bin_no_deps();
let krate1 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options1 = BuildOptions {
profile: Some("dev".to_string()),
target: None,
..Default::default()
};
let binary1 = builder.build(&krate1, &options1).unwrap();
let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
assert_eq!(binary1_name, expected_bin_name("simple-bin-no-deps"));
let krate2 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options2 = BuildOptions {
profile: Some("dev".to_string()),
target: Some(build_context::TARGET.to_string()),
..Default::default()
};
let binary2 = builder.build(&krate2, &options2).unwrap();
let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
assert_eq!(binary2_name, expected_bin_name("simple-bin-no-deps"));
assert_cache_miss(&binary1, &binary2);
}
}
mod dependency_resolution {
use super::*;
use crate::sbom::tests::get_sbom_component_version;
#[test]
fn locked_vs_unlocked_produces_different_cache_entries() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::stale_serde();
let krate1 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options1 = BuildOptions {
profile: Some("dev".to_string()),
locked: true,
..Default::default()
};
let binary1 = builder.build(&krate1, &options1).unwrap();
let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
assert_eq!(binary1_name, expected_bin_name("stale-serde"));
let sbom1 = read_sbom_for_binary(&binary1);
assert_eq!(
get_sbom_component_version(&sbom1, "serde"),
Some("1.0.5".to_string()),
"With --locked, should use old serde from Cargo.lock"
);
fs::remove_file(tc.path().join("Cargo.lock")).unwrap();
let krate2 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options2 = BuildOptions {
profile: Some("dev".to_string()),
locked: false,
..Default::default()
};
let binary2 = builder.build(&krate2, &options2).unwrap();
let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
assert_eq!(binary2_name, expected_bin_name("stale-serde"));
let sbom2 = read_sbom_for_binary(&binary2);
let version = get_sbom_component_version(&sbom2, "serde").unwrap();
assert_ne!(
version, "1.0.5",
"Without --locked, should resolve to newer serde"
);
assert!(version.starts_with("1.0."), "Should still be serde 1.0.x");
crate::sbom::tests::assert_sboms_ne(&sbom1, &sbom2);
assert_cache_miss(&binary1, &binary2);
}
#[test]
fn same_locked_flag_produces_cache_hit() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::stale_serde();
let krate1 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
locked: true,
..Default::default()
};
let binary1 = builder.build(&krate1, &options).unwrap();
let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
assert_eq!(binary1_name, expected_bin_name("stale-serde"));
let krate2 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let binary2 = builder.build(&krate2, &options).unwrap();
let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
assert_eq!(binary2_name, expected_bin_name("stale-serde"));
assert_cache_hit(&binary1, &binary2);
}
#[test]
fn different_features_different_dependencies() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::timestamp();
let krate1 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options1 = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let binary1 = builder.build(&krate1, &options1).unwrap();
let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
assert_eq!(binary1_name, expected_bin_name("timestamp"));
let sbom1 = read_sbom_for_binary(&binary1);
let output1 = run_timestamp_binary(&binary1);
let krate2 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options2 = BuildOptions {
profile: Some("dev".to_string()),
features: vec!["frobnulator".to_string()],
no_default_features: true,
..Default::default()
};
let binary2 = builder.build(&krate2, &options2).unwrap();
let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
assert_eq!(binary2_name, expected_bin_name("timestamp"));
let sbom2 = read_sbom_for_binary(&binary2);
let output2 = run_timestamp_binary(&binary2);
assert!(output1.features.contains(&"gonkolator".to_string()));
assert!(output2.features.contains(&"frobnulator".to_string()));
crate::sbom::tests::assert_sboms_ne(&sbom1, &sbom2);
assert_cache_miss_by_timestamp(&output1, &output2);
}
#[test]
fn all_features_includes_all_dependencies() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::timestamp();
let krate = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
all_features: true,
..Default::default()
};
let binary = builder.build(&krate, &options).unwrap();
let binary_name = binary.file_name().unwrap().to_str().unwrap();
assert_eq!(binary_name, expected_bin_name("timestamp"));
let output = run_timestamp_binary(&binary);
assert!(
output.features.contains(&"gonkolator".to_string()),
"Should have gonkolator"
);
assert!(
output.features.contains(&"frobnulator".to_string()),
"Should have frobnulator"
);
}
}
mod source_types {
use super::*;
#[test]
fn local_dir_never_cached() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::simple_bin_no_deps();
let krate = fake_downloaded_crate(&tc, FakeSourceType::LocalDir, None);
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let binary = builder.build(&krate, &options).unwrap();
assert!(!binary.starts_with(&builder.config.bin_dir));
assert!(binary.starts_with(tc.path()));
let binary_name = binary.file_name().unwrap().to_str().unwrap();
assert_eq!(binary_name, expected_bin_name("simple-bin-no-deps"));
let sbom_path = read_sbom_for_binary(&binary);
assert!(!sbom_path.exists());
}
#[test]
fn registry_source_cached_with_sbom() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::simple_bin_no_deps();
let krate1 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let binary1 = builder.build(&krate1, &options).unwrap();
assert!(binary1.starts_with(&builder.config.bin_dir));
let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
assert_eq!(binary1_name, expected_bin_name("simple-bin-no-deps"));
let sbom_path = read_sbom_for_binary(&binary1);
assert!(sbom_path.exists());
let krate2 = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let binary2 = builder.build(&krate2, &options).unwrap();
let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
assert_eq!(binary2_name, expected_bin_name("simple-bin-no-deps"));
assert_cache_hit(&binary1, &binary2);
}
#[test]
fn git_source_cached_with_sbom() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::simple_bin_no_deps();
let krate1 = fake_downloaded_crate(
&tc,
FakeSourceType::Git {
url: "https://github.com/example/test.git".to_string(),
rev: "abc123".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let binary1 = builder.build(&krate1, &options).unwrap();
assert!(binary1.starts_with(&builder.config.bin_dir));
let binary1_name = binary1.file_name().unwrap().to_str().unwrap();
assert_eq!(binary1_name, expected_bin_name("simple-bin-no-deps"));
let sbom_path = read_sbom_for_binary(&binary1);
assert!(sbom_path.exists());
let krate2 = fake_downloaded_crate(
&tc,
FakeSourceType::Git {
url: "https://github.com/example/test.git".to_string(),
rev: "abc123".to_string(),
},
None,
);
let binary2 = builder.build(&krate2, &options).unwrap();
let binary2_name = binary2.file_name().unwrap().to_str().unwrap();
assert_eq!(binary2_name, expected_bin_name("simple-bin-no-deps"));
assert_cache_hit(&binary1, &binary2);
}
}
mod proc_macro_detection {
use super::*;
#[test]
fn proc_macro_marked_as_build_dep() {
let (builder, _temp) = test_builder();
let tc = CrateTestCase::proc_macro_dep();
let krate = fake_downloaded_crate(
&tc,
FakeSourceType::Registry {
version: "1.0.0".to_string(),
},
None,
);
let options = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let binary = builder.build(&krate, &options).unwrap();
let binary_name = binary.file_name().unwrap().to_str().unwrap();
assert_eq!(binary_name, expected_bin_name("proc-macro-dep"));
let sbom_path = read_sbom_for_binary(&binary);
let json_str = fs::read_to_string(&sbom_path).unwrap();
let bom: serde_cyclonedx::cyclonedx::v_1_4::CycloneDx = serde_json::from_str(&json_str).unwrap();
let components = bom.components.unwrap();
let serde_derive = components
.iter()
.find(|c| c.name.as_str() == "serde_derive")
.expect("serde_derive should be in components");
if let Some(ref props) = serde_derive.properties {
let has_build_kind = props.iter().any(|p| {
p.name.as_deref() == Some("cdx:rustc:dependency_kind")
&& p.value.as_deref() == Some("build")
});
assert!(has_build_kind, "proc-macro should be marked as build dependency");
} else {
panic!("proc-macro should have dependency_kind property");
}
}
}
mod build_options {
use super::*;
mod features_parsing {
use super::*;
#[test]
fn empty_features_string() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--features", "", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.features.is_empty());
}
#[test]
fn single_feature() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--features", "feat1", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.features, vec!["feat1"]);
}
#[test]
fn comma_separated_features() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--features", "feat1,feat2", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.features, vec!["feat1", "feat2"]);
}
#[test]
fn space_separated_features() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--features", "feat1 feat2", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.features, vec!["feat1", "feat2"]);
}
#[test]
fn mixed_separator_features() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--features", "feat1, feat2 feat3", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.features, vec!["feat1", "feat2", "feat3"]);
}
#[test]
fn whitespace_handling() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--features", " feat1 , feat2 ", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.features, vec!["feat1", "feat2"]);
}
#[test]
fn no_features_flag() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.features.is_empty());
}
}
mod profile_selection {
use super::*;
#[test]
fn debug_flag_maps_to_dev() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--debug", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.profile, Some("dev".to_string()));
}
#[test]
fn explicit_profile() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--profile", "custom", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.profile, Some("custom".to_string()));
}
#[test]
fn no_profile_specified() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.profile, None);
}
}
mod build_target_selection {
use super::*;
#[test]
fn default_bin_when_no_flags() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.build_target, BuildTarget::DefaultBin);
}
#[test]
fn explicit_bin() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--bin", "foo", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.build_target, BuildTarget::Bin("foo".to_string()));
}
#[test]
fn explicit_example() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--example", "bar", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.build_target, BuildTarget::Example("bar".to_string()));
}
}
mod locked_offline_precedence {
use super::*;
#[test]
fn no_config_no_cli() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(!options.locked);
assert!(!options.offline);
}
#[test]
fn config_locked_true() {
let config = Config {
locked: true,
..Default::default()
};
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.locked);
assert!(!options.offline);
}
#[test]
fn config_offline_true() {
let config = Config {
offline: true,
..Default::default()
};
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(!options.locked);
assert!(options.offline);
}
#[test]
fn config_both_true() {
let config = Config {
locked: true,
offline: true,
..Default::default()
};
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.locked);
assert!(options.offline);
}
#[test]
fn cli_locked_overrides_config() {
let config = Config {
locked: false,
..Default::default()
};
let args = CliArgs::parse_from_test_args(["--locked", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.locked);
assert!(!options.offline);
}
#[test]
fn cli_offline_overrides_config() {
let config = Config {
offline: false,
..Default::default()
};
let args = CliArgs::parse_from_test_args(["--offline", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(!options.locked);
assert!(options.offline);
}
#[test]
fn cli_frozen_sets_both() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--frozen", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.locked);
assert!(options.offline);
}
#[test]
fn frozen_overrides_config_locked_false() {
let config = Config {
locked: false,
offline: false,
..Default::default()
};
let args = CliArgs::parse_from_test_args(["--frozen", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.locked);
assert!(options.offline);
}
#[test]
fn frozen_overrides_config_offline_false() {
let config = Config {
offline: false,
..Default::default()
};
let args = CliArgs::parse_from_test_args(["--frozen", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.locked);
assert!(options.offline);
}
#[test]
fn frozen_with_config_values_set() {
let config = Config {
locked: true,
offline: true,
..Default::default()
};
let args = CliArgs::parse_from_test_args(["--frozen", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.locked);
assert!(options.offline);
}
}
mod toolchain_precedence {
use super::*;
#[test]
fn no_config_no_cli() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.toolchain, None);
}
#[test]
fn config_toolchain_used() {
let config = Config {
toolchain: Some("stable".to_string()),
..Default::default()
};
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.toolchain, Some("stable".to_string()));
}
#[test]
fn cli_toolchain_overrides_config() {
let config = Config {
toolchain: Some("stable".to_string()),
..Default::default()
};
let args = CliArgs::parse_from_test_args(["+nightly", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.toolchain, Some("nightly".to_string()));
}
}
mod direct_passthrough {
use super::*;
#[test]
fn all_features() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--all-features", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.all_features);
}
#[test]
fn no_default_features() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--no-default-features", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.no_default_features);
}
#[test]
fn target() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--target", "x86_64-unknown-linux-gnu", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.target, Some("x86_64-unknown-linux-gnu".to_string()));
}
#[test]
fn jobs() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--jobs", "4", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.jobs, Some(4));
}
#[test]
fn ignore_rust_version() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["--ignore-rust-version", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert!(options.ignore_rust_version);
}
#[test]
fn cargo_verbosity() {
let config = Config::default();
let args = CliArgs::parse_from_test_args(["tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.cargo_verbosity, CargoVerbosity::Normal);
let args = CliArgs::parse_from_test_args(["-v", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.cargo_verbosity, CargoVerbosity::Verbose);
let args = CliArgs::parse_from_test_args(["-vv", "tool"]);
let options = BuildOptions::load(&config, &args).unwrap();
assert_eq!(options.cargo_verbosity, CargoVerbosity::VeryVerbose);
}
}
}
}