use std::{
fs,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use snafu::ResultExt;
use tracing::*;
use crate::{
Result,
bin_resolver::BinaryCacheEntry,
builder::{BuildOptions, BuildTarget},
config::Config,
crate_resolver::{ResolvedCrate, ResolvedSource},
cratespec::{CrateSpec, Forge, RegistrySource},
downloader::DownloadedCrate,
error,
messages::{BuildCacheMessage, CrateResolutionMessage, PrebuiltBinaryMessage, SourceMessage},
target::TargetTriple,
};
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct CacheEntry<T> {
#[serde(flatten)]
value: T,
cached_at: DateTime<Utc>,
}
impl<T> CacheEntry<T> {
fn new(value: T) -> Self {
Self {
value,
cached_at: Utc::now(),
}
}
fn age(&self) -> Duration {
Utc::now()
.signed_duration_since(self.cached_at)
.to_std()
.unwrap_or(Duration::ZERO)
}
fn into_inner(self) -> T {
self.value
}
}
type CrateResolveCacheEntry = CacheEntry<ResolvedCrate>;
#[derive(Clone, Debug)]
pub(crate) struct Cache {
inner: Arc<CacheInner>,
}
impl Cache {
pub(crate) fn new(config: Config, reporter: crate::messages::MessageReporter) -> Self {
Self {
inner: Arc::new(CacheInner { config, reporter }),
}
}
pub(crate) fn get_or_resolve_crate<F>(&self, spec: &CrateSpec, resolver: F) -> Result<ResolvedCrate>
where
F: FnOnce() -> Result<ResolvedCrate>,
{
self.inner
.reporter
.report(|| CrateResolutionMessage::cache_lookup(spec));
let stale_entry = if !self.inner.config.refresh {
if let Ok(Some(entry)) = self.get_resolved_crate(spec) {
let age = entry.age();
let ttl = self.inner.config.resolve_cache_timeout;
if age < ttl {
let cache_path = self.crate_resolve_cache_path(spec).ok();
if let Some(path) = &cache_path {
self.inner
.reporter
.report(|| CrateResolutionMessage::cache_hit(path, age, ttl.saturating_sub(age)));
}
self.inner
.reporter
.report(|| CrateResolutionMessage::resolved(&entry.value));
return Ok(entry.value);
}
self.inner
.reporter
.report(|| CrateResolutionMessage::cache_stale(spec, age));
Some(entry)
} else {
self.inner
.reporter
.report(|| CrateResolutionMessage::cache_miss(spec));
None
}
} else {
self.inner
.reporter
.report(|| CrateResolutionMessage::cache_miss(spec));
None
};
self.inner
.reporter
.report(|| CrateResolutionMessage::resolving(spec));
match resolver() {
Ok(resolved) => {
self.inner
.reporter
.report(|| CrateResolutionMessage::resolved(&resolved));
if let Ok(path) = self.crate_resolve_cache_path(spec) {
let _ = self.put_resolved_crate(spec, &resolved);
self.inner
.reporter
.report(|| CrateResolutionMessage::cache_stored(&path));
} else {
let _ = self.put_resolved_crate(spec, &resolved);
}
Ok(resolved)
}
Err(e) if !self.inner.config.refresh && Self::should_use_stale_cache(&e) => {
if let Some(entry) = stale_entry {
let age = entry.age();
let resolved = entry.into_inner();
self.inner
.reporter
.report(|| CrateResolutionMessage::using_stale_fallback(spec, age));
self.inner
.reporter
.report(|| CrateResolutionMessage::resolved(&resolved));
Ok(resolved)
} else {
Err(e)
}
}
Err(e) => Err(e),
}
}
pub(crate) fn get_or_download_crate<F>(
&self,
resolved: &ResolvedCrate,
downloader: F,
) -> Result<DownloadedCrate>
where
F: FnOnce(&Path) -> Result<()>,
{
self.inner
.reporter
.report(|| SourceMessage::cache_lookup(resolved));
let cache_path = self.crate_source_cache_path(resolved)?;
if !self.inner.config.refresh {
if let Ok(Some(cached)) = self.get_cached_crate_source(resolved) {
self.inner
.reporter
.report(|| SourceMessage::cache_hit(&cached.crate_path));
return Ok(cached);
}
} else {
if cache_path.exists() {
debug!(
"Refresh mode: removing existing source cache at {}",
cache_path.display()
);
let _ = fs::remove_dir_all(&cache_path);
}
}
self.inner.reporter.report(|| SourceMessage::cache_miss(resolved));
self.inner
.reporter
.report(|| SourceMessage::downloading(resolved));
let parent = cache_path
.parent()
.expect("BUG: cache_path is built by joining onto the cache root, so it always has a parent");
fs::create_dir_all(parent).with_context(|_| error::IoSnafu {
path: parent.to_path_buf(),
})?;
let temp_dir = tempfile::Builder::new()
.prefix(&format!("cgx-download-{}-temp", &resolved.name))
.tempdir_in(parent)
.with_context(|_| error::TempDirInCreationSnafu {
parent: parent.to_path_buf(),
})?;
downloader(temp_dir.path())?;
self.inner
.reporter
.report(|| SourceMessage::downloaded(temp_dir.path()));
let temp_path = temp_dir.keep();
match fs::rename(&temp_path, &cache_path) {
Ok(()) => {
self.inner
.reporter
.report(|| SourceMessage::cache_stored(&cache_path));
Ok(DownloadedCrate {
resolved: resolved.clone(),
crate_path: cache_path,
})
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let _ = fs::remove_dir_all(&temp_path);
Ok(DownloadedCrate {
resolved: resolved.clone(),
crate_path: cache_path,
})
}
Err(e) => {
let _ = fs::remove_dir_all(&temp_path);
Err(e).with_context(|_| error::RenameFileSnafu {
src: temp_path.clone(),
dst: cache_path.clone(),
})
}
}
}
fn get_resolved_crate(&self, spec: &CrateSpec) -> Result<Option<CacheEntry<ResolvedCrate>>> {
let cache_file = self.crate_resolve_cache_path(spec)?;
if !cache_file.exists() {
return Ok(None);
}
let contents = fs::read_to_string(&cache_file).with_context(|_| error::IoSnafu {
path: cache_file.clone(),
})?;
let entry: CrateResolveCacheEntry = serde_json::from_str(&contents).context(error::JsonSnafu)?;
Ok(Some(entry))
}
fn put_resolved_crate(&self, spec: &CrateSpec, resolved: &ResolvedCrate) -> Result<()> {
let cache_file = self.crate_resolve_cache_path(spec)?;
if let Some(parent) = cache_file.parent() {
fs::create_dir_all(parent).with_context(|_| error::IoSnafu {
path: parent.to_path_buf(),
})?;
}
let entry = CacheEntry::new(resolved.clone());
let json = serde_json::to_string_pretty(&entry).context(error::JsonSnafu)?;
fs::write(&cache_file, json).with_context(|_| error::IoSnafu {
path: cache_file.clone(),
})?;
Ok(())
}
pub(crate) fn get_cached_binary_resolution(
&self,
krate: &ResolvedCrate,
) -> Result<Option<BinaryCacheEntry>> {
self.inner
.reporter
.report(|| PrebuiltBinaryMessage::cache_lookup(krate));
let entry = self.read_binary_cache_entry(krate);
if entry.is_none() {
self.inner
.reporter
.report(|| PrebuiltBinaryMessage::cache_miss(krate));
}
Ok(entry)
}
pub(crate) fn put_cached_binary_resolution(
&self,
krate: &ResolvedCrate,
entry: BinaryCacheEntry,
) -> Result<()> {
let cache_file = self.binary_cache_path(krate)?;
if let Some(parent) = cache_file.parent() {
fs::create_dir_all(parent).with_context(|_| error::IoSnafu {
path: parent.to_path_buf(),
})?;
}
let entry = CacheEntry::new(entry);
let json = serde_json::to_string_pretty(&entry).context(error::JsonSnafu)?;
fs::write(&cache_file, json).with_context(|_| error::IoSnafu {
path: cache_file.clone(),
})?;
self.inner
.reporter
.report(|| PrebuiltBinaryMessage::cache_stored(&cache_file));
Ok(())
}
#[instrument(skip_all, fields(krate = %krate.name, version = %krate.version))]
fn read_binary_cache_entry(&self, krate: &ResolvedCrate) -> Option<BinaryCacheEntry> {
let cache_file = match self.binary_cache_path(krate) {
Ok(cache_file) => cache_file,
Err(e) => {
debug!(
error = %e,
"failed to compute binary cache path; this should not ever happen"
);
return None;
}
};
if !cache_file.exists() {
return None;
}
let contents = match fs::read_to_string(&cache_file) {
Ok(contents) => contents,
Err(e) => {
debug!(
path = %cache_file.display(),
error = %e,
"ignoring unreadable binary cache entry");
return None;
}
};
match serde_json::from_str::<CacheEntry<BinaryCacheEntry>>(&contents) {
Ok(entry) => Some(entry.into_inner()),
Err(e) => {
debug!(path = %cache_file.display(),
error = %e,
"ignoring unparsable binary cache entry");
None
}
}
}
fn binary_cache_path(&self, krate: &ResolvedCrate) -> Result<PathBuf> {
let hash = Self::compute_binary_cache_hash(krate, TargetTriple::host())?;
Ok(self
.inner
.config
.cache_dir
.join("binaries")
.join(format!("{}.json", hash)))
}
fn compute_binary_cache_hash(krate: &ResolvedCrate, target: &TargetTriple) -> Result<String> {
#[derive(Serialize)]
struct BinaryCacheKey<'a> {
name: &'a str,
version: &'a semver::Version,
source: &'a ResolvedSource,
platform: &'a TargetTriple,
}
let key = BinaryCacheKey {
name: &krate.name,
version: &krate.version,
source: &krate.source,
platform: target,
};
let json = serde_json::to_string(&key).context(error::JsonSnafu)?;
Ok(Self::compute_hash(json.as_bytes()))
}
fn crate_resolve_cache_path(&self, spec: &CrateSpec) -> Result<PathBuf> {
let hash = Self::compute_spec_hash(spec)?;
Ok(self
.inner
.config
.cache_dir
.join("resolve")
.join(format!("{}.json", hash)))
}
fn compute_spec_hash(spec: &CrateSpec) -> Result<String> {
let json = serde_json::to_string(spec).context(error::JsonSnafu)?;
Ok(Self::compute_hash(json.as_bytes()))
}
fn compute_hash(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
crate::helpers::format_hex_lower(hasher.finalize())
}
fn should_use_stale_cache(error: &error::Error) -> bool {
matches!(
error,
error::Error::Registry { .. } | error::Error::Git { .. } | error::Error::Io { .. }
)
}
fn get_cached_crate_source(&self, resolved: &ResolvedCrate) -> Result<Option<DownloadedCrate>> {
let cache_path = self.crate_source_cache_path(resolved)?;
if cache_path.exists() {
Ok(Some(DownloadedCrate {
resolved: resolved.clone(),
crate_path: cache_path,
}))
} else {
Ok(None)
}
}
fn crate_source_cache_path(&self, resolved: &ResolvedCrate) -> Result<PathBuf> {
let base = self.inner.config.cache_dir.join("sources");
let path = match &resolved.source {
ResolvedSource::CratesIo => base
.join("crates-io")
.join(&resolved.name)
.join(resolved.version.to_string()),
ResolvedSource::Registry { source } => match source {
RegistrySource::Named(name) => base
.join("registry")
.join(name)
.join(&resolved.name)
.join(resolved.version.to_string()),
RegistrySource::IndexUrl(url) => {
let url_hash = Self::compute_hash(url.as_str().as_bytes());
base.join("registry-index")
.join(url_hash)
.join(&resolved.name)
.join(resolved.version.to_string())
}
},
ResolvedSource::Git { repo, commit } => {
let repo_hash = Self::compute_hash(repo.as_bytes());
base.join("git").join(repo_hash).join(commit)
}
ResolvedSource::Forge { forge, commit } => match forge {
Forge::GitHub { owner, repo, .. } => base.join("github").join(owner).join(repo).join(commit),
Forge::GitLab { owner, repo, .. } => base.join("gitlab").join(owner).join(repo).join(commit),
},
ResolvedSource::LocalDir { .. } => {
unreachable!("BUG: LocalDir sources should not be passed to source_cache_path")
}
};
Ok(path)
}
pub(crate) fn crate_bin_root(&self, krate: &ResolvedCrate) -> PathBuf {
self.inner
.config
.bin_dir
.join(format!("{}-{}", krate.name, krate.version))
.join(krate.source.source_hash())
}
pub(crate) fn git_db_path(&self, url: &str) -> PathBuf {
let ident = Self::compute_git_ident(url);
self.inner.config.cache_dir.join("git-db").join(ident)
}
pub(crate) fn git_checkout_path(&self, url: &str, commit: &str) -> PathBuf {
let ident = Self::compute_git_ident(url);
self.inner
.config
.cache_dir
.join("git-checkouts")
.join(ident)
.join(commit)
}
fn compute_git_ident(url: &str) -> String {
let name = url
.trim_end_matches('/')
.trim_end_matches(".git")
.rsplit('/')
.next()
.unwrap_or("repo");
#[expect(
clippy::string_slice,
reason = "compute_hash returns a 64-char ASCII hex digest, so [..8] is in range and on a char \
boundary"
)]
let hash = &Self::compute_hash(url.as_bytes())[..8];
format!("{}-{}", name, hash)
}
#[cfg(test)]
pub(crate) fn insert_stale_resolve_entry(
&self,
spec: &CrateSpec,
resolved: &ResolvedCrate,
age: Duration,
) -> Result<()> {
let cache_file = self.crate_resolve_cache_path(spec)?;
if let Some(parent) = cache_file.parent() {
fs::create_dir_all(parent).with_context(|_| error::IoSnafu {
path: parent.to_path_buf(),
})?;
}
let cached_at = Utc::now() - chrono::Duration::from_std(age).unwrap();
let entry = CacheEntry {
value: resolved.clone(),
cached_at,
};
let json = serde_json::to_string_pretty(&entry).context(error::JsonSnafu)?;
fs::write(&cache_file, json).with_context(|_| error::IoSnafu {
path: cache_file.clone(),
})?;
Ok(())
}
pub(crate) fn get_or_build_binary<F>(
&self,
krate: &ResolvedCrate,
options: &BuildOptions,
build_fn: F,
) -> Result<PathBuf>
where
F: FnOnce() -> Result<(PathBuf, crate::sbom::CycloneDx)>,
{
if matches!(krate.source, ResolvedSource::LocalDir { .. }) {
self.inner
.reporter
.report(BuildCacheMessage::skipping_cache_local_dir);
let (binary_path, _sbom) = build_fn()?;
return Ok(binary_path);
}
self.inner
.reporter
.report(|| BuildCacheMessage::cache_lookup(krate, options));
let build_hash = Self::compute_build_hash(options);
let binary_name = Self::expected_binary_name(&krate.name, &options.build_target);
let cache_dir = self.crate_bin_root(krate).join(build_hash);
let cache_path = cache_dir.join(&binary_name);
let sbom_path = cache_dir.join("sbom.cyclonedx.json");
if cache_path.exists() {
if !self.inner.config.refresh {
self.inner
.reporter
.report(|| BuildCacheMessage::cache_hit(&cache_path, &sbom_path));
return Ok(cache_path);
} else {
debug!(
cache_dir = %cache_dir.display(),
"Refresh mode: removing existing binary cache",
);
let _ = fs::remove_dir_all(&cache_dir);
}
}
self.inner
.reporter
.report(|| BuildCacheMessage::cache_miss(krate));
let (built_binary, sbom) = build_fn()?;
fs::create_dir_all(&cache_dir).with_context(|_| error::IoSnafu {
path: cache_dir.clone(),
})?;
fs::copy(&built_binary, &cache_path).with_context(|_| error::CopyBinarySnafu {
src: built_binary.clone(),
dst: cache_path.clone(),
})?;
let sbom_json = serde_json::to_string_pretty(&sbom).context(error::JsonSnafu)?;
fs::write(&sbom_path, sbom_json).with_context(|_| error::IoSnafu {
path: sbom_path.clone(),
})?;
self.inner
.reporter
.report(|| BuildCacheMessage::cache_stored(&cache_path, &sbom_path));
Ok(cache_path)
}
fn compute_build_hash(options: &BuildOptions) -> String {
let mut features = options.features.clone();
features.sort();
#[derive(Serialize)]
struct BuildCacheKey<'a> {
features: &'a [String],
all_features: bool,
no_default_features: bool,
profile: &'a Option<String>,
target: &'a Option<TargetTriple>,
build_target: &'a BuildTarget,
toolchain: &'a Option<String>,
locked: bool,
}
let key = BuildCacheKey {
features: &features,
all_features: options.all_features,
no_default_features: options.no_default_features,
profile: &options.profile,
target: &options.target,
build_target: &options.build_target,
toolchain: &options.toolchain,
locked: options.locked,
};
let json =
serde_json::to_string(&key).expect("serializing a BuildCacheKey of plain fields cannot fail");
Self::compute_hash(json.as_bytes())
}
fn expected_binary_name(crate_name: &str, build_target: &BuildTarget) -> String {
let base_name = match build_target {
BuildTarget::DefaultBin => crate_name,
BuildTarget::Bin(name) | BuildTarget::Example(name) => name.as_str(),
};
#[cfg(windows)]
return format!("{}.exe", base_name);
#[cfg(not(windows))]
return base_name.to_string();
}
}
#[derive(Debug)]
struct CacheInner {
config: Config,
reporter: crate::messages::MessageReporter,
}
#[cfg(test)]
mod tests {
use std::{cell::RefCell, rc::Rc, time::Duration};
use assert_matches::assert_matches;
use semver::Version;
use snafu::IntoError;
use tempfile::TempDir;
use super::*;
use crate::{
bin_resolver::{BinaryCacheEntry, ConclusiveResolution, ResolvedBinary},
testdata::target_triple,
};
fn test_cache() -> (Cache, TempDir) {
test_cache_with_timeout(Duration::from_secs(3600))
}
fn test_cache_with_timeout(timeout: Duration) -> (Cache, TempDir) {
crate::logging::init_test_logging();
let (temp_dir, mut config) = crate::config::create_test_env();
config.resolve_cache_timeout = timeout;
(
Cache::new(config, crate::messages::MessageReporter::null()),
temp_dir,
)
}
fn test_cache_with_refresh() -> (Cache, TempDir) {
crate::logging::init_test_logging();
let (temp_dir, mut config) = crate::config::create_test_env();
config.refresh = true;
(
Cache::new(config, crate::messages::MessageReporter::null()),
temp_dir,
)
}
fn test_spec() -> CrateSpec {
CrateSpec::CratesIo {
name: "serde".to_string(),
version: None,
}
}
fn test_spec_alt() -> CrateSpec {
CrateSpec::CratesIo {
name: "tokio".to_string(),
version: None,
}
}
fn test_resolved() -> ResolvedCrate {
ResolvedCrate {
name: "serde".to_string(),
version: Version::parse("1.0.0").unwrap(),
source: ResolvedSource::CratesIo,
}
}
fn test_resolved_alt() -> ResolvedCrate {
ResolvedCrate {
name: "serde".to_string(),
version: Version::parse("1.0.1").unwrap(),
source: ResolvedSource::CratesIo,
}
}
mod compat {
use chrono::{DateTime, Utc};
use super::*;
use crate::config::BinaryProvider;
fn fixed_cached_at() -> DateTime<Utc> {
"2024-01-15T12:30:00Z".parse().unwrap()
}
mod resolved_binary {
use super::*;
fn positive_entry() -> CacheEntry<BinaryCacheEntry> {
CacheEntry {
value: BinaryCacheEntry {
outcome: ConclusiveResolution::Found(ResolvedBinary {
krate: ResolvedCrate {
name: "eza".to_string(),
version: Version::parse("0.23.1").unwrap(),
source: ResolvedSource::CratesIo,
},
provider: BinaryProvider::GithubReleases,
path: PathBuf::from("/cache/bin/eza"),
target: "x86_64-pc-windows-gnu".to_string(),
}),
enabled_providers: vec![
BinaryProvider::Binstall,
BinaryProvider::GithubReleases,
BinaryProvider::GitlabReleases,
BinaryProvider::Quickinstall,
],
},
cached_at: fixed_cached_at(),
}
}
fn negative_entry() -> CacheEntry<BinaryCacheEntry> {
CacheEntry {
value: BinaryCacheEntry {
outcome: ConclusiveResolution::Nonexistent,
enabled_providers: vec![
BinaryProvider::GithubReleases,
BinaryProvider::GitlabReleases,
],
},
cached_at: fixed_cached_at(),
}
}
const POSITIVE_JSON: &str = r#"{
"outcome": "found",
"krate": {
"name": "eza",
"version": "0.23.1",
"source": "crates_io"
},
"provider": "github-releases",
"path": "/cache/bin/eza",
"target": "x86_64-pc-windows-gnu",
"enabled_providers": [
"binstall",
"github-releases",
"gitlab-releases",
"quickinstall"
],
"cached_at": "2024-01-15T12:30:00Z"
}"#;
const NEGATIVE_JSON: &str = r#"{
"outcome": "nonexistent",
"enabled_providers": [
"github-releases",
"gitlab-releases"
],
"cached_at": "2024-01-15T12:30:00Z"
}"#;
#[test]
fn positive_entry_serializes_to_expected_json() {
assert_eq!(
serde_json::to_string_pretty(&positive_entry()).unwrap(),
POSITIVE_JSON
);
}
#[test]
fn negative_entry_serializes_to_expected_json() {
assert_eq!(
serde_json::to_string_pretty(&negative_entry()).unwrap(),
NEGATIVE_JSON
);
}
#[test]
fn positive_entry_round_trips() {
let original = positive_entry();
let json = serde_json::to_string_pretty(&original).unwrap();
let back: CacheEntry<BinaryCacheEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(back, original);
let from_literal: CacheEntry<BinaryCacheEntry> = serde_json::from_str(POSITIVE_JSON).unwrap();
assert_eq!(from_literal, original);
}
#[test]
fn negative_entry_round_trips() {
let original = negative_entry();
let json = serde_json::to_string_pretty(&original).unwrap();
let back: CacheEntry<BinaryCacheEntry> = serde_json::from_str(&json).unwrap();
assert_eq!(back, original);
let from_literal: CacheEntry<BinaryCacheEntry> = serde_json::from_str(NEGATIVE_JSON).unwrap();
assert_eq!(from_literal, original);
}
}
mod resolved_crate {
use super::*;
fn crate_with(source: ResolvedSource) -> ResolvedCrate {
ResolvedCrate {
name: "demo".to_string(),
version: Version::parse("1.2.3").unwrap(),
source,
}
}
const ON_DISK_JSON: &str = r#"{
"name": "demo",
"version": "1.2.3",
"source": "crates_io",
"cached_at": "2024-01-15T12:30:00Z"
}"#;
#[test]
fn on_disk_cache_entry_round_trips_to_expected_json() {
let entry = CacheEntry {
value: crate_with(ResolvedSource::CratesIo),
cached_at: fixed_cached_at(),
};
assert_eq!(serde_json::to_string_pretty(&entry).unwrap(), ON_DISK_JSON);
let back: CacheEntry<ResolvedCrate> = serde_json::from_str(ON_DISK_JSON).unwrap();
assert_eq!(back, entry);
}
fn check(source: ResolvedSource, expected: &str) {
assert_eq!(serde_json::to_string_pretty(&source).unwrap(), expected);
let back: ResolvedSource = serde_json::from_str(expected).unwrap();
assert_eq!(back, source);
}
#[test]
fn crates_io() {
check(ResolvedSource::CratesIo, r#""crates_io""#);
}
#[test]
fn registry_named() {
check(
ResolvedSource::Registry {
source: RegistrySource::Named("my-registry".to_string()),
},
r#"{
"registry": {
"source": {
"named": "my-registry"
}
}
}"#,
);
}
#[test]
fn registry_index_url() {
check(
ResolvedSource::Registry {
source: RegistrySource::IndexUrl(
url::Url::parse("https://example.com/index").unwrap(),
),
},
r#"{
"registry": {
"source": {
"index_url": "https://example.com/index"
}
}
}"#,
);
}
#[test]
fn git() {
check(
ResolvedSource::Git {
repo: "https://github.com/owner/repo.git".to_string(),
commit: "abc123".to_string(),
},
r#"{
"git": {
"repo": "https://github.com/owner/repo.git",
"commit": "abc123"
}
}"#,
);
}
#[test]
fn forge_github() {
check(
ResolvedSource::Forge {
forge: Forge::GitHub {
custom_url: None,
owner: "owner".to_string(),
repo: "repo".to_string(),
},
commit: "abc123".to_string(),
},
r#"{
"forge": {
"forge": {
"git_hub": {
"custom_url": null,
"owner": "owner",
"repo": "repo"
}
},
"commit": "abc123"
}
}"#,
);
}
#[test]
fn forge_gitlab() {
check(
ResolvedSource::Forge {
forge: Forge::GitLab {
custom_url: None,
owner: "owner".to_string(),
repo: "repo".to_string(),
},
commit: "def456".to_string(),
},
r#"{
"forge": {
"forge": {
"git_lab": {
"custom_url": null,
"owner": "owner",
"repo": "repo"
}
},
"commit": "def456"
}
}"#,
);
}
#[test]
fn local_dir() {
check(
ResolvedSource::LocalDir {
path: PathBuf::from("/some/local/path"),
},
r#"{
"local_dir": {
"path": "/some/local/path"
}
}"#,
);
}
}
mod crate_spec_hash {
use semver::VersionReq;
use super::*;
use crate::git::GitSelector;
#[test]
fn crates_io() {
let spec = CrateSpec::CratesIo {
name: "serde".to_string(),
version: Some(VersionReq::parse("=1.0.0").unwrap()),
};
assert_eq!(
Cache::compute_spec_hash(&spec).unwrap(),
"79b48b0d3feee138ee1ea1f22108170f85431c6240de6571ed3693c1001a9974"
);
}
#[test]
fn registry_named() {
let spec = CrateSpec::Registry {
source: RegistrySource::Named("my-registry".to_string()),
name: "serde".to_string(),
version: None,
};
assert_eq!(
Cache::compute_spec_hash(&spec).unwrap(),
"7a3b4789c6780ca2b848cc536fbb0ea1e034128e2e492ada1a31ce7f633c4f52"
);
}
#[test]
fn git() {
let spec = CrateSpec::Git {
repo: "https://github.com/owner/repo.git".to_string(),
selector: GitSelector::Tag("v1.0.0".to_string()),
name: None,
version: None,
};
assert_eq!(
Cache::compute_spec_hash(&spec).unwrap(),
"5d8eebf800c54c1e46ed66e72ff8b1c9bd7bfc88325aa2473b068aa69d9466a4"
);
}
#[test]
fn forge_github() {
let spec = CrateSpec::Forge {
forge: Forge::GitHub {
custom_url: None,
owner: "owner".to_string(),
repo: "repo".to_string(),
},
selector: GitSelector::DefaultBranch,
name: None,
version: None,
};
assert_eq!(
Cache::compute_spec_hash(&spec).unwrap(),
"9338fcc8761b894c6681a9100281143350ad2ded6bce067a4b39d2a787972ef2"
);
}
#[test]
fn local_dir() {
let spec = CrateSpec::LocalDir {
path: PathBuf::from("/some/local/path"),
name: None,
version: None,
};
assert_eq!(
Cache::compute_spec_hash(&spec).unwrap(),
"e87d6dd1b220d02006524fbaac4d7c654b55eff1bd9a1332fd52a5e738a8fca5"
);
}
}
mod binary_cache_hash {
use super::*;
#[test]
fn crates_io() {
let krate = ResolvedCrate {
name: "eza".to_string(),
version: Version::parse("0.23.1").unwrap(),
source: ResolvedSource::CratesIo,
};
assert_eq!(
Cache::compute_binary_cache_hash(&krate, &target_triple("x86_64-unknown-linux-gnu"))
.unwrap(),
"e73c84363ece02d92a3dfe4ff390dcbe0dbe3381f050280505a1adb3916fad78"
);
}
#[test]
fn platform_changes_the_hash() {
let krate = ResolvedCrate {
name: "eza".to_string(),
version: Version::parse("0.23.1").unwrap(),
source: ResolvedSource::CratesIo,
};
assert_ne!(
Cache::compute_binary_cache_hash(&krate, &target_triple("x86_64-unknown-linux-gnu"))
.unwrap(),
Cache::compute_binary_cache_hash(&krate, &target_triple("aarch64-apple-darwin")).unwrap(),
);
}
}
mod build_cache_hash {
use super::*;
use crate::builder::{BuildOptions, BuildTarget};
#[test]
fn source_hash_crates_io() {
assert_eq!(
ResolvedSource::CratesIo.source_hash(),
"797cfdcfdf4d45ed0d3963577b0e549fe0c37295320d320d7173bfcf7bc42842"
);
}
#[test]
fn build_hash_default_options() {
assert_eq!(
Cache::compute_build_hash(&BuildOptions::default()),
"115fc6c55136730f0dbc99c9d90074d669d03b8ab9c61ccad2b76cb535f688af"
);
}
#[test]
fn build_hash_with_options() {
let options = BuildOptions {
features: vec!["json".to_string(), "tls".to_string()],
all_features: false,
no_default_features: true,
profile: Some("release".to_string()),
target: Some(target_triple("x86_64-unknown-linux-gnu")),
build_target: BuildTarget::Bin("mybin".to_string()),
toolchain: Some("stable".to_string()),
locked: true,
..Default::default()
};
assert_eq!(
Cache::compute_build_hash(&options),
"122238fafcf514ae03c828f2b94c68a787ec9b3c69854aff6b51ae4b7f732c85"
);
}
}
mod source_cache_paths {
use super::*;
fn assert_source_path(source: ResolvedSource, expected_suffix: &[&str]) {
let (cache, _temp) = test_cache();
let resolved = ResolvedCrate {
name: "demo".to_string(),
version: Version::parse("1.2.3").unwrap(),
source,
};
let mut expected = cache.inner.config.cache_dir.join("sources");
for component in expected_suffix {
expected = expected.join(component);
}
assert_eq!(cache.crate_source_cache_path(&resolved).unwrap(), expected);
}
#[test]
fn crates_io() {
assert_source_path(ResolvedSource::CratesIo, &["crates-io", "demo", "1.2.3"]);
}
#[test]
fn registry_named() {
assert_source_path(
ResolvedSource::Registry {
source: RegistrySource::Named("my-registry".to_string()),
},
&["registry", "my-registry", "demo", "1.2.3"],
);
}
#[test]
fn registry_index_url() {
assert_source_path(
ResolvedSource::Registry {
source: RegistrySource::IndexUrl(
url::Url::parse("https://example.com/index").unwrap(),
),
},
&[
"registry-index",
"bf2749857dd97af7bf8b1b035bd103caee1276d7cf54cd24ea19dc9167bb0918",
"demo",
"1.2.3",
],
);
}
#[test]
fn git() {
assert_source_path(
ResolvedSource::Git {
repo: "https://github.com/owner/repo.git".to_string(),
commit: "abc123".to_string(),
},
&[
"git",
"bc40893b43beea6303cb93d2e61df787840b4e09dcf293506e68491b9a082686",
"abc123",
],
);
}
#[test]
fn forge_github() {
assert_source_path(
ResolvedSource::Forge {
forge: Forge::GitHub {
custom_url: None,
owner: "owner".to_string(),
repo: "repo".to_string(),
},
commit: "abc123".to_string(),
},
&["github", "owner", "repo", "abc123"],
);
}
#[test]
fn forge_gitlab() {
assert_source_path(
ResolvedSource::Forge {
forge: Forge::GitLab {
custom_url: None,
owner: "owner".to_string(),
repo: "repo".to_string(),
},
commit: "def456".to_string(),
},
&["gitlab", "owner", "repo", "def456"],
);
}
}
}
mod get_or_resolve {
use super::*;
#[test]
fn cache_miss_calls_closure() {
let (cache, _temp) = test_cache();
let spec = test_spec();
let resolved = test_resolved();
let call_count = Rc::new(RefCell::new(0));
let call_count_clone = call_count.clone();
let resolved_clone = resolved.clone();
let result = cache.get_or_resolve_crate(&spec, || {
*call_count_clone.borrow_mut() += 1;
Ok(resolved_clone.clone())
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), resolved);
assert_eq!(*call_count.borrow(), 1);
let cached = cache.get_resolved_crate(&spec).unwrap();
assert_eq!(cached.map(|e| e.value), Some(resolved));
}
#[test]
fn cache_hit_valid_skips_closure() {
let (cache, _temp) = test_cache();
let spec = test_spec();
let resolved = test_resolved();
cache.put_resolved_crate(&spec, &resolved).unwrap();
let call_count = Rc::new(RefCell::new(0));
let call_count_clone = call_count.clone();
let result = cache.get_or_resolve_crate(&spec, || {
*call_count_clone.borrow_mut() += 1;
Ok(test_resolved_alt())
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), resolved);
assert_eq!(*call_count.borrow(), 0);
}
#[test]
fn cache_hit_expired_calls_closure() {
let (cache, _temp) = test_cache_with_timeout(Duration::from_secs(0));
let spec = test_spec();
let old_resolved = test_resolved();
let new_resolved = test_resolved_alt();
cache.put_resolved_crate(&spec, &old_resolved).unwrap();
std::thread::sleep(Duration::from_secs(1));
let call_count = Rc::new(RefCell::new(0));
let call_count_clone = call_count.clone();
let new_resolved_clone = new_resolved.clone();
let result = cache.get_or_resolve_crate(&spec, || {
*call_count_clone.borrow_mut() += 1;
Ok(new_resolved_clone.clone())
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), new_resolved);
assert_eq!(*call_count.borrow(), 1);
}
#[test]
fn network_error_with_stale_returns_stale() {
let (cache, _temp) = test_cache_with_timeout(Duration::from_secs(0));
let spec = test_spec();
let resolved = test_resolved();
cache.put_resolved_crate(&spec, &resolved).unwrap();
std::thread::sleep(Duration::from_secs(1));
let result = cache.get_or_resolve_crate(&spec, || {
Err(
error::RegistrySnafu.into_error(tame_index::Error::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"network error",
))),
)
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), resolved);
}
#[test]
fn network_error_without_stale_propagates() {
let (cache, _temp) = test_cache();
let spec = test_spec();
let result = cache.get_or_resolve_crate(&spec, || {
Err(
error::RegistrySnafu.into_error(tame_index::Error::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"network error",
))),
)
});
assert_matches!(result.unwrap_err(), error::Error::Registry { .. });
}
#[test]
fn io_error_with_stale_returns_stale() {
let (cache, _temp) = test_cache_with_timeout(Duration::from_secs(0));
let spec = test_spec();
let resolved = test_resolved();
cache.put_resolved_crate(&spec, &resolved).unwrap();
std::thread::sleep(Duration::from_secs(1));
let result = cache.get_or_resolve_crate(&spec, || {
Err(error::IoSnafu {
path: PathBuf::from("/fake/test/path"),
}
.into_error(std::io::Error::new(std::io::ErrorKind::Other, "io error")))
});
assert!(result.is_ok());
assert_eq!(result.unwrap(), resolved);
}
#[test]
fn other_error_never_uses_stale() {
let (cache, _temp) = test_cache_with_timeout(Duration::from_secs(0));
let spec = test_spec();
let resolved = test_resolved();
cache.put_resolved_crate(&spec, &resolved).unwrap();
std::thread::sleep(Duration::from_secs(1));
let call_count = Rc::new(RefCell::new(0));
let call_count_clone = call_count.clone();
let result = cache.get_or_resolve_crate(&spec, || {
*call_count_clone.borrow_mut() += 1;
error::VersionMismatchSnafu {
requirement: "2.0.0".to_string(),
found: Version::parse("1.0.0").unwrap(),
}
.fail()
});
assert_eq!(*call_count.borrow(), 1, "Closure should have been called");
assert_matches!(result.unwrap_err(), error::Error::VersionMismatch { .. });
}
#[test]
fn successful_resolve_updates_cache() {
let (cache, _temp) = test_cache_with_timeout(Duration::from_secs(0));
let spec = test_spec();
let old_resolved = test_resolved();
let new_resolved = test_resolved_alt();
cache.put_resolved_crate(&spec, &old_resolved).unwrap();
std::thread::sleep(Duration::from_secs(1));
let result = cache.get_or_resolve_crate(&spec, || Ok(new_resolved.clone()));
assert!(result.is_ok());
assert_eq!(result.unwrap(), new_resolved);
let cached = cache.get_resolved_crate(&spec).unwrap();
assert_eq!(cached.map(|e| e.value), Some(new_resolved));
}
#[test]
fn refresh_bypasses_valid_cache() {
let (cache, _temp) = test_cache_with_refresh();
let spec = test_spec();
let cached_resolved = test_resolved();
let new_resolved = test_resolved_alt();
cache.put_resolved_crate(&spec, &cached_resolved).unwrap();
let call_count = Rc::new(RefCell::new(0));
let call_count_clone = call_count.clone();
let new_resolved_clone = new_resolved.clone();
let resolved_crate = cache
.get_or_resolve_crate(&spec, || {
*call_count_clone.borrow_mut() += 1;
Ok(new_resolved_clone.clone())
})
.unwrap();
assert_eq!(resolved_crate, new_resolved);
assert_eq!(
*call_count.borrow(),
1,
"Resolver should be called even with valid cache"
);
}
#[test]
fn refresh_disables_stale_cache_fallback() {
let (cache, _temp) = test_cache_with_refresh();
let spec = test_spec();
let stale_resolved = test_resolved();
cache
.insert_stale_resolve_entry(&spec, &stale_resolved, Duration::from_secs(9999))
.unwrap();
let result = cache.get_or_resolve_crate(&spec, || {
Err(
error::RegistrySnafu.into_error(tame_index::Error::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"network error",
))),
)
});
assert_matches!(result, Err(error::Error::Registry { .. }));
}
}
mod get_or_download {
use super::*;
#[test]
fn source_cache_hit_skips_downloader() {
let (cache, _temp) = test_cache();
let resolved = test_resolved();
let cache_path = cache.crate_source_cache_path(&resolved).unwrap();
fs::create_dir_all(&cache_path).unwrap();
let call_count = Rc::new(RefCell::new(0));
let call_count_clone = call_count.clone();
let result = cache.get_or_download_crate(&resolved, |_download_path| {
*call_count_clone.borrow_mut() += 1;
Err(error::IoSnafu {
path: PathBuf::from("/fake/test/path"),
}
.into_error(std::io::Error::new(
std::io::ErrorKind::Other,
"should not be called",
)))
});
assert!(result.is_ok());
assert_eq!(result.unwrap().crate_path, cache_path);
assert_eq!(*call_count.borrow(), 0);
}
#[test]
fn source_cache_miss_calls_downloader() {
let (cache, _temp) = test_cache();
let resolved = test_resolved();
let cache_path = cache.crate_source_cache_path(&resolved).unwrap();
let call_count = Rc::new(RefCell::new(0));
let call_count_clone = call_count.clone();
let result = cache.get_or_download_crate(&resolved, |download_path| {
*call_count_clone.borrow_mut() += 1;
fs::create_dir_all(download_path).unwrap();
fs::write(download_path.join("test.txt"), b"test content").unwrap();
Ok(())
});
assert!(result.is_ok());
assert_eq!(result.unwrap().crate_path, cache_path);
assert_eq!(*call_count.borrow(), 1);
assert!(cache_path.join("test.txt").exists());
}
#[test]
fn download_error_without_cache() {
let (cache, _temp) = test_cache();
let resolved = test_resolved();
let result = cache.get_or_download_crate(&resolved, |_download_path| {
Err(error::IoSnafu {
path: PathBuf::from("/fake/test/path"),
}
.into_error(std::io::Error::new(std::io::ErrorKind::Other, "download failed")))
});
assert_matches!(result.unwrap_err(), error::Error::Io { .. });
}
#[test]
fn successful_download_creates_cache_entry() {
let (cache, _temp) = test_cache();
let resolved = test_resolved();
let cache_path = cache.crate_source_cache_path(&resolved).unwrap();
assert!(!cache_path.exists());
let result = cache.get_or_download_crate(&resolved, |download_path| {
fs::create_dir_all(download_path).unwrap();
fs::write(download_path.join("Cargo.toml"), b"[package]\nname = \"test\"").unwrap();
fs::write(download_path.join("lib.rs"), b"pub fn test() {}").unwrap();
Ok(())
});
assert!(result.is_ok());
let cached = result.unwrap();
assert_eq!(cached.crate_path, cache_path);
assert!(cache_path.join("Cargo.toml").exists());
assert!(cache_path.join("lib.rs").exists());
}
#[test]
fn failed_download_does_not_create_cache_entry() {
let (cache, _temp) = test_cache();
let resolved = test_resolved();
let cache_path = cache.crate_source_cache_path(&resolved).unwrap();
let result = cache.get_or_download_crate(&resolved, |download_path| {
fs::create_dir_all(download_path).unwrap();
fs::write(download_path.join("partial.txt"), b"partial data").unwrap();
Err(error::IoSnafu {
path: PathBuf::from("/fake/test/path"),
}
.into_error(std::io::Error::new(
std::io::ErrorKind::Other,
"simulated failure",
)))
});
assert_matches!(result.unwrap_err(), error::Error::Io { .. });
assert!(!cache_path.exists());
let cache_parent = cache_path.parent().unwrap();
if cache_parent.exists() {
let entries: Vec<_> = fs::read_dir(cache_parent)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(entries.is_empty() || !entries.iter().any(|e| e.path() == cache_path));
}
}
#[test]
fn race_condition_both_downloads_succeed() {
let (cache, _temp) = test_cache();
let resolved = test_resolved();
let cache_path = cache.crate_source_cache_path(&resolved).unwrap();
let result1 = cache.get_or_download_crate(&resolved, |download_path| {
fs::create_dir_all(download_path).unwrap();
fs::write(download_path.join("version.txt"), b"download1").unwrap();
Ok(())
});
assert!(result1.is_ok());
let cached1 = result1.unwrap();
assert_eq!(cached1.crate_path, cache_path);
let call_count = Rc::new(RefCell::new(0));
let call_count_clone = call_count.clone();
let result2 = cache.get_or_download_crate(&resolved, |download_path| {
*call_count_clone.borrow_mut() += 1;
fs::create_dir_all(download_path).unwrap();
fs::write(download_path.join("version.txt"), b"download2").unwrap();
Ok(())
});
assert!(result2.is_ok());
let cached2 = result2.unwrap();
assert_eq!(cached2.crate_path, cache_path);
assert_eq!(*call_count.borrow(), 0);
let content = fs::read_to_string(cache_path.join("version.txt")).unwrap();
assert_eq!(content, "download1");
}
#[test]
fn refresh_bypasses_source_cache() {
let (cache, _temp) = test_cache_with_refresh();
let resolved = test_resolved();
let cache_path = cache.crate_source_cache_path(&resolved).unwrap();
fs::create_dir_all(&cache_path).unwrap();
fs::write(cache_path.join("cached.txt"), b"cached content").unwrap();
let call_count = Rc::new(RefCell::new(0));
let call_count_clone = call_count.clone();
let result = cache.get_or_download_crate(&resolved, |download_path| {
*call_count_clone.borrow_mut() += 1;
fs::create_dir_all(download_path).unwrap();
fs::write(download_path.join("fresh.txt"), b"fresh content").unwrap();
Ok(())
});
result.unwrap();
assert_eq!(
*call_count.borrow(),
1,
"Downloader should be called even with cached source"
);
}
}
mod binary_cache_hash {
use super::*;
use crate::builder::{BuildOptions, BuildTarget};
#[test]
fn same_inputs_produce_same_hash() {
let options = BuildOptions {
features: vec!["foo".to_string(), "bar".to_string()],
profile: Some("release".to_string()),
..Default::default()
};
let hash1 = Cache::compute_build_hash(&options);
let hash2 = Cache::compute_build_hash(&options);
assert_eq!(hash1, hash2);
}
#[test]
fn feature_order_doesnt_matter() {
let options1 = BuildOptions {
features: vec!["foo".to_string(), "bar".to_string(), "baz".to_string()],
..Default::default()
};
let options2 = BuildOptions {
features: vec!["baz".to_string(), "foo".to_string(), "bar".to_string()],
..Default::default()
};
assert_eq!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2),
"Same features in different order should produce same hash"
);
}
#[test]
fn different_features_produce_different_hash() {
let options1 = BuildOptions {
features: vec!["foo".to_string()],
..Default::default()
};
let options2 = BuildOptions {
features: vec!["bar".to_string()],
..Default::default()
};
assert_ne!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2)
);
}
#[test]
fn different_profile_produces_different_hash() {
let options1 = BuildOptions {
profile: Some("dev".to_string()),
..Default::default()
};
let options2 = BuildOptions {
profile: Some("release".to_string()),
..Default::default()
};
assert_ne!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2)
);
}
#[test]
fn different_target_produces_different_hash() {
let options1 = BuildOptions {
target: Some(target_triple("x86_64-unknown-linux-gnu")),
..Default::default()
};
let options2 = BuildOptions {
target: Some(target_triple("aarch64-unknown-linux-gnu")),
..Default::default()
};
assert_ne!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2)
);
}
#[test]
fn different_toolchain_produces_different_hash() {
let options1 = BuildOptions {
toolchain: Some("stable".to_string()),
..Default::default()
};
let options2 = BuildOptions {
toolchain: Some("nightly".to_string()),
..Default::default()
};
assert_ne!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2)
);
}
#[test]
fn different_build_target_produces_different_hash() {
let options1 = BuildOptions {
build_target: BuildTarget::DefaultBin,
..Default::default()
};
let options2 = BuildOptions {
build_target: BuildTarget::Bin("foo".to_string()),
..Default::default()
};
assert_ne!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2)
);
}
#[test]
fn all_features_affects_hash() {
let options1 = BuildOptions {
all_features: false,
..Default::default()
};
let options2 = BuildOptions {
all_features: true,
..Default::default()
};
assert_ne!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2)
);
}
#[test]
fn no_default_features_affects_hash() {
let options1 = BuildOptions {
no_default_features: false,
..Default::default()
};
let options2 = BuildOptions {
no_default_features: true,
..Default::default()
};
assert_ne!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2)
);
}
#[test]
fn locked_flag_affects_hash() {
let options1 = BuildOptions {
locked: true,
..Default::default()
};
let options2 = BuildOptions {
locked: false,
..Default::default()
};
assert_ne!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2),
"locked flag affects dependency resolution, so it must affect hash"
);
}
#[test]
fn offline_flag_does_not_affect_hash() {
let options1 = BuildOptions {
offline: true,
..Default::default()
};
let options2 = BuildOptions {
offline: false,
..Default::default()
};
assert_eq!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2),
"offline flag should not affect hash"
);
}
#[test]
fn jobs_does_not_affect_hash() {
let options1 = BuildOptions {
jobs: Some(1),
..Default::default()
};
let options2 = BuildOptions {
jobs: Some(8),
..Default::default()
};
assert_eq!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2),
"jobs setting should not affect hash"
);
}
#[test]
fn ignore_rust_version_does_not_affect_hash() {
let options1 = BuildOptions {
ignore_rust_version: true,
..Default::default()
};
let options2 = BuildOptions {
ignore_rust_version: false,
..Default::default()
};
assert_eq!(
Cache::compute_build_hash(&options1),
Cache::compute_build_hash(&options2),
"ignore_rust_version should not affect hash"
);
}
#[test]
fn source_hash_distinguishes_crates_io() {
let hash = ResolvedSource::CratesIo.source_hash();
assert_eq!(hash.len(), 64, "SHA-256 hash should be 64 hex chars");
}
#[test]
fn source_hash_distinguishes_git() {
let hash1 = ResolvedSource::Git {
repo: "https://github.com/rust-lang/cargo".to_string(),
commit: "abc123".to_string(),
}
.source_hash();
let hash2 = ResolvedSource::Git {
repo: "https://github.com/rust-lang/cargo".to_string(),
commit: "def456".to_string(),
}
.source_hash();
assert_ne!(hash1, hash2, "Different commits should produce different hashes");
}
#[test]
fn source_hash_distinguishes_forge() {
let hash1 = ResolvedSource::Forge {
forge: Forge::GitHub {
custom_url: None,
owner: "rust-lang".to_string(),
repo: "cargo".to_string(),
},
commit: "abc123".to_string(),
}
.source_hash();
let hash2 = ResolvedSource::Forge {
forge: Forge::GitHub {
custom_url: None,
owner: "rust-lang".to_string(),
repo: "cargo".to_string(),
},
commit: "def456".to_string(),
}
.source_hash();
assert_ne!(hash1, hash2, "Different commits should produce different hashes");
}
#[test]
fn source_hash_distinguishes_registry() {
let hash1 = ResolvedSource::Registry {
source: RegistrySource::Named("my-registry".to_string()),
}
.source_hash();
let hash2 = ResolvedSource::Registry {
source: RegistrySource::Named("other-registry".to_string()),
}
.source_hash();
assert_ne!(
hash1, hash2,
"Different registries should produce different hashes"
);
}
#[test]
fn expected_binary_name_default_bin() {
let name = Cache::expected_binary_name("my-crate", &BuildTarget::DefaultBin);
#[cfg(windows)]
assert_eq!(name, "my-crate.exe");
#[cfg(not(windows))]
assert_eq!(name, "my-crate");
}
#[test]
fn expected_binary_name_specific_bin() {
let name = Cache::expected_binary_name("my-crate", &BuildTarget::Bin("foo".to_string()));
#[cfg(windows)]
assert_eq!(name, "foo.exe");
#[cfg(not(windows))]
assert_eq!(name, "foo");
}
#[test]
fn expected_binary_name_example() {
let name = Cache::expected_binary_name("my-crate", &BuildTarget::Example("bar".to_string()));
#[cfg(windows)]
assert_eq!(name, "bar.exe");
#[cfg(not(windows))]
assert_eq!(name, "bar");
}
}
mod utility {
use super::*;
#[test]
fn hash_stability() {
let spec = test_spec();
let hash1 = Cache::compute_spec_hash(&spec).unwrap();
let hash2 = Cache::compute_spec_hash(&spec).unwrap();
assert_eq!(hash1, hash2);
}
#[test]
fn hash_uniqueness() {
let spec1 = test_spec();
let spec2 = test_spec_alt();
let hash1 = Cache::compute_spec_hash(&spec1).unwrap();
let hash2 = Cache::compute_spec_hash(&spec2).unwrap();
assert_ne!(hash1, hash2);
}
}
mod binary_cache {
use super::*;
use crate::config::BinaryProvider;
#[test]
fn put_then_get_round_trips_outcome_and_providers() {
let (cache, _temp) = test_cache();
let krate = test_resolved();
let providers = vec![BinaryProvider::Binstall, BinaryProvider::GithubReleases];
assert_matches!(cache.get_cached_binary_resolution(&krate), Ok(None));
cache
.put_cached_binary_resolution(
&krate,
BinaryCacheEntry {
outcome: ConclusiveResolution::Nonexistent,
enabled_providers: providers.clone(),
},
)
.unwrap();
let entry = cache.get_cached_binary_resolution(&krate).unwrap().unwrap();
assert_eq!(entry.outcome, ConclusiveResolution::Nonexistent);
assert_eq!(entry.enabled_providers, providers);
}
#[test]
fn put_overwrites_previous_entry() {
let (cache, _temp) = test_cache();
let krate = test_resolved();
cache
.put_cached_binary_resolution(
&krate,
BinaryCacheEntry {
outcome: ConclusiveResolution::Nonexistent,
enabled_providers: vec![BinaryProvider::GitlabReleases],
},
)
.unwrap();
let found = BinaryCacheEntry {
outcome: ConclusiveResolution::Found(ResolvedBinary {
krate: test_resolved(),
provider: BinaryProvider::GithubReleases,
path: PathBuf::from("/cache/bin/serde"),
target: build_context::TARGET.to_string(),
}),
enabled_providers: vec![BinaryProvider::GithubReleases],
};
cache.put_cached_binary_resolution(&krate, found.clone()).unwrap();
let entry = cache.get_cached_binary_resolution(&krate).unwrap().unwrap();
assert_eq!(entry, found);
}
}
}