use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
use super::classify::Classification;
use super::error::Error;
use super::identity::AuthorId;
use super::options::Options;
use super::score::RISK_SCORE_VERSION;
use super::stats::VCS_SCHEMA_VERSION;
pub const CACHE_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct CacheConfig {
pub enabled: bool,
pub clear: bool,
pub dir: Option<PathBuf>,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
clear: false,
dir: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct CommitEvent {
pub oid: String,
pub time: i64,
pub authors: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub bug_fix: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub security_fix: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub revert: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub renames: Vec<(PathBuf, PathBuf)>,
pub touched: Vec<(PathBuf, u64)>,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(value: &bool) -> bool {
!*value
}
impl CommitEvent {
#[must_use]
pub(crate) fn author_ids(&self) -> Vec<AuthorId> {
self.authors
.iter()
.map(|digest| AuthorId::from_digest(digest.clone()))
.collect()
}
#[must_use]
pub(crate) fn classification(&self) -> Classification {
Classification {
bug_fix: self.bug_fix,
security_fix: self.security_fix,
revert: self.revert,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct HistoryCache {
pub cache_schema_version: u32,
pub vcs_schema_version: u32,
pub risk_score_version: u32,
pub options_fingerprint: u64,
pub head_sha: String,
pub walk_long_boundary: i64,
#[serde(default)]
pub truncated_shallow_clone: bool,
pub events: Vec<CommitEvent>,
}
impl HistoryCache {
#[must_use]
pub(crate) fn is_compatible(&self, options_fingerprint: u64, current_shallow: bool) -> bool {
self.cache_schema_version == CACHE_SCHEMA_VERSION
&& self.vcs_schema_version == VCS_SCHEMA_VERSION
&& self.risk_score_version == RISK_SCORE_VERSION
&& self.options_fingerprint == options_fingerprint
&& self.truncated_shallow_clone == current_shallow
}
}
#[must_use]
pub(crate) fn fingerprint(options: &Options) -> u64 {
let mut hasher = DefaultHasher::new();
options.long_window_secs.hash(&mut hasher);
options.recent_window_secs.hash(&mut hasher);
options.full_history.hash(&mut hasher);
options.include_merges.hash(&mut hasher);
options.follow_renames.hash(&mut hasher);
options.exclude_bots.hash(&mut hasher);
options.bot_pattern.hash(&mut hasher);
options.as_of.hash(&mut hasher);
hasher.finish()
}
#[must_use]
pub(crate) fn default_cache_dir() -> Option<PathBuf> {
let suffix = Path::new("big-code-analysis").join("vcs");
if let Some(xdg) = non_empty_env("XDG_CACHE_HOME") {
return Some(PathBuf::from(xdg).join(&suffix));
}
#[cfg(windows)]
if let Some(local) = non_empty_env("LOCALAPPDATA") {
return Some(PathBuf::from(local).join(&suffix));
}
let home = non_empty_env("HOME")?;
Some(PathBuf::from(home).join(".cache").join(&suffix))
}
fn non_empty_env(key: &str) -> Option<std::ffi::OsString> {
std::env::var_os(key).filter(|value| !value.is_empty())
}
#[must_use]
pub(crate) fn repo_dir(cache_root: &Path, repo_canonical: &Path) -> PathBuf {
let mut hasher = DefaultHasher::new();
repo_canonical.hash(&mut hasher);
cache_root.join(format!("{:016x}", hasher.finish()))
}
#[must_use]
pub(crate) fn entry_path(repo_dir: &Path, head_sha: &str) -> PathBuf {
repo_dir.join(format!("{head_sha}.json"))
}
#[must_use]
pub(crate) fn load(path: &Path) -> Option<HistoryCache> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[must_use]
pub(crate) fn load_compatible(
repo_dir: &Path,
options_fingerprint: u64,
current_shallow: bool,
) -> Vec<(PathBuf, HistoryCache)> {
let Ok(entries) = std::fs::read_dir(repo_dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json")
&& let Some(cache) = load(&path)
&& cache.is_compatible(options_fingerprint, current_shallow)
{
out.push((path, cache));
}
}
out
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(crate) fn write_atomic(path: &Path, cache: &HistoryCache) -> Result<(), Error> {
let dir = path
.parent()
.ok_or_else(|| Error::Cache(format!("cache path {} has no parent", path.display())))?;
std::fs::create_dir_all(dir).map_err(|e| cache_io_err("create cache directory", dir, &e))?;
let unique = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = dir.join(format!(".{}.{unique}.tmp", std::process::id()));
let json =
serde_json::to_vec(cache).map_err(|e| Error::Cache(format!("serializing cache: {e}")))?;
std::fs::write(&tmp, &json).map_err(|e| cache_io_err("write cache temp file", &tmp, &e))?;
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
cache_io_err("rename cache file", path, &e)
})
}
pub(crate) fn clear_repo(repo_dir: &Path) -> Result<(), Error> {
match std::fs::remove_dir_all(repo_dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(cache_io_err("clear cache directory", repo_dir, &e)),
}
}
fn cache_io_err(action: &str, path: &Path, error: &std::io::Error) -> Error {
Error::Cache(format!("{action} {}: {error}", path.display()))
}
#[cfg(test)]
#[path = "cache_tests.rs"]
mod tests;