use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::config::AppPaths;
use crate::error::{RebeccaError, Result};
use crate::scan::ScanReport;
use super::{
SCAN_CACHE_DIR, SCAN_CACHE_PRUNE_BATCH_LIMIT, ScanCacheFingerprint, ScanCacheLookup,
ScanCacheMiss, ScanCachePolicy, ScanCachePruneReport, ScanCacheRecord, ScanCacheStore,
};
impl ScanCacheStore {
pub fn from_app_paths(paths: &AppPaths) -> Self {
Self::new(paths.cache_dir.join(SCAN_CACHE_DIR))
}
pub fn new(root_dir: impl Into<PathBuf>) -> Self {
Self {
root_dir: root_dir.into(),
}
}
pub fn root_dir(&self) -> &Path {
&self.root_dir
}
pub fn cache_file_for(&self, root: &Path) -> PathBuf {
self.root_dir.join(format!("{:016x}.json", path_hash(root)))
}
pub fn load(&self, root: &Path) -> ScanCacheLookup {
self.load_with_policy(root, ScanCachePolicy::default())
}
pub fn prune(&self) -> ScanCachePruneReport {
self.prune_with_policy(ScanCachePolicy::default())
}
pub fn prune_with_policy(&self, policy: ScanCachePolicy) -> ScanCachePruneReport {
let mut report = ScanCachePruneReport::default();
let entries = match std::fs::read_dir(&self.root_dir) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return report;
}
Err(err) => {
tracing::debug!(
path = %self.root_dir.display(),
error = %err,
"scan cache prune skipped"
);
return report;
}
};
for entry in entries.take(SCAN_CACHE_PRUNE_BATCH_LIMIT) {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
tracing::debug!(
path = %self.root_dir.display(),
error = %err,
"scan cache prune skipped"
);
continue;
}
};
let cache_file = entry.path();
let is_json_cache = cache_file
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("json"));
if !is_json_cache {
continue;
}
report.inspected = report.inspected.saturating_add(1);
let raw = match std::fs::read_to_string(&cache_file) {
Ok(raw) => raw,
Err(err) => {
prune_cache_file(&cache_file);
report.pruned = report.pruned.saturating_add(1);
tracing::debug!(
path = %cache_file.display(),
error = %err,
"scan cache prune removed unreadable record"
);
continue;
}
};
let record: ScanCacheRecord = match serde_json::from_str(&raw) {
Ok(record) => record,
Err(err) => {
prune_cache_file(&cache_file);
report.pruned = report.pruned.saturating_add(1);
tracing::debug!(
path = %cache_file.display(),
error = %err,
"scan cache prune removed corrupted record"
);
continue;
}
};
if self.cache_file_for(&record.root) != cache_file {
prune_cache_file(&cache_file);
report.pruned = report.pruned.saturating_add(1);
continue;
}
let fingerprint = match ScanCacheFingerprint::read_from_path(&record.root) {
Ok(fingerprint) => fingerprint,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
prune_cache_file(&cache_file);
report.pruned = report.pruned.saturating_add(1);
continue;
}
Err(_) => {
report.retained = report.retained.saturating_add(1);
continue;
}
};
match record.miss_reason(&record.root, &fingerprint, policy, unix_now()) {
Some(reason) if reason.should_prune_cache_file() => {
prune_cache_file(&cache_file);
report.pruned = report.pruned.saturating_add(1);
}
Some(_) => {
report.retained = report.retained.saturating_add(1);
}
None => {
report.retained = report.retained.saturating_add(1);
}
}
}
report
}
pub fn load_with_policy(&self, root: &Path, policy: ScanCachePolicy) -> ScanCacheLookup {
let cache_file = self.cache_file_for(root);
let fingerprint = match ScanCacheFingerprint::read_from_path(root) {
Ok(fingerprint) => fingerprint,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
prune_cache_file(&cache_file);
return ScanCacheLookup::pruned_miss(ScanCacheMiss::Missing);
}
Err(_) => return ScanCacheLookup::miss(ScanCacheMiss::MetadataUnavailable),
};
let raw = match std::fs::read_to_string(&cache_file) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return ScanCacheLookup::miss(ScanCacheMiss::Missing);
}
Err(_) => {
prune_cache_file(&cache_file);
return ScanCacheLookup::pruned_miss(ScanCacheMiss::Corrupted);
}
};
let record: ScanCacheRecord = match serde_json::from_str(&raw) {
Ok(record) => record,
Err(_) => {
prune_cache_file(&cache_file);
return ScanCacheLookup::pruned_miss(ScanCacheMiss::Corrupted);
}
};
match record.miss_reason(root, &fingerprint, policy, unix_now()) {
Some(reason) => {
if reason.should_prune_cache_file() {
prune_cache_file(&cache_file);
return ScanCacheLookup::pruned_miss(reason);
}
ScanCacheLookup::miss(reason)
}
None => ScanCacheLookup::Hit(record.report),
}
}
pub fn store(&self, root: &Path, report: ScanReport) -> Result<ScanCacheRecord> {
let fingerprint = ScanCacheFingerprint::from_path(root)?;
let record = ScanCacheRecord::new(root.to_path_buf(), fingerprint, report);
let cache_file = self.cache_file_for(root);
let parent = cache_file.parent().ok_or_else(|| {
RebeccaError::ScanCacheUnavailable(format!(
"scan cache path has no parent: {}",
cache_file.display()
))
})?;
std::fs::create_dir_all(parent).map_err(|err| {
RebeccaError::ScanCacheUnavailable(format!(
"scan cache directory unavailable at {}: {}",
parent.display(),
err
))
})?;
let raw = serde_json::to_vec_pretty(&record)?;
write_cache_file(&cache_file, &raw)?;
Ok(record)
}
}
fn write_cache_file(cache_file: &Path, raw: &[u8]) -> Result<()> {
let temp_file = temp_cache_file(cache_file);
let write_result = (|| -> std::io::Result<()> {
let mut file = std::fs::File::create(&temp_file)?;
file.write_all(raw)?;
file.sync_all()?;
replace_file(&temp_file, cache_file)
})();
if let Err(err) = write_result {
let _ = std::fs::remove_file(&temp_file);
return Err(RebeccaError::ScanCacheUnavailable(format!(
"scan cache write failed at {}: {}",
cache_file.display(),
err
)));
}
Ok(())
}
fn prune_cache_file(cache_file: &Path) {
if let Err(err) = std::fs::remove_file(cache_file) {
if err.kind() != std::io::ErrorKind::NotFound {
tracing::debug!(
path = %cache_file.display(),
error = %err,
"scan cache prune skipped"
);
}
}
}
fn replace_file(temp_file: &Path, cache_file: &Path) -> std::io::Result<()> {
match std::fs::rename(temp_file, cache_file) {
Ok(()) => Ok(()),
Err(_) if cache_file.exists() => {
std::fs::remove_file(cache_file)?;
std::fs::rename(temp_file, cache_file)
}
Err(err) => Err(err),
}
}
fn temp_cache_file(cache_file: &Path) -> PathBuf {
let file_name = cache_file
.file_name()
.map(|name| name.to_string_lossy())
.unwrap_or_else(|| "scan-cache.json".into());
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
cache_file.with_file_name(format!("{file_name}.tmp-{}-{unique}", std::process::id()))
}
fn path_hash(path: &Path) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in comparable_path(path).as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
fn comparable_path(path: &Path) -> String {
let value = path.to_string_lossy();
if cfg!(windows) {
value.to_ascii_lowercase()
} else {
value.into_owned()
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::{path_hash, unix_now};
use crate::scan::ScanReport;
use crate::scan_cache::{
DEFAULT_DIRECTORY_SCAN_CACHE_MAX_AGE_SECONDS, SCAN_CACHE_VERSION, ScanCacheLookup,
ScanCacheMiss, ScanCachePolicy, ScanCacheStore,
};
#[test]
fn scan_cache_round_trips_current_fingerprint() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("file.bin"), b"abc").unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let report = ScanReport {
bytes_scanned: 3,
files_scanned: 1,
directories_scanned: 1,
};
let record = store.store(&root, report).unwrap();
let lookup = store.load(&root);
assert_eq!(record.version, SCAN_CACHE_VERSION);
assert_eq!(record.root, root);
assert!(store.cache_file_for(&record.root).exists());
assert_eq!(lookup, ScanCacheLookup::Hit(report));
}
#[test]
fn scan_cache_store_is_derived_from_app_cache_dir() {
let temp = tempfile::tempdir().unwrap();
let paths = crate::config::AppPaths {
config_dir: temp.path().join("config"),
config_file: temp.path().join("config").join("config.toml"),
state_dir: temp.path().join("state"),
cache_dir: temp.path().join("cache"),
history_file: temp.path().join("state").join("history.jsonl"),
};
let store = ScanCacheStore::from_app_paths(&paths);
assert_eq!(store.root_dir(), paths.cache_dir.join("scan"));
}
#[test]
fn scan_cache_missing_file_is_cache_miss() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target");
std::fs::create_dir_all(&root).unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let lookup = store.load(&root);
assert_eq!(lookup, ScanCacheLookup::miss(ScanCacheMiss::Missing));
}
#[test]
fn scan_cache_missing_root_prunes_existing_record_on_load() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target.txt");
std::fs::write(&root, b"abc").unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let report = ScanReport {
bytes_scanned: 3,
files_scanned: 1,
directories_scanned: 0,
};
store.store(&root, report).unwrap();
std::fs::remove_file(&root).unwrap();
let lookup = store.load(&root);
assert_eq!(lookup, ScanCacheLookup::pruned_miss(ScanCacheMiss::Missing));
assert!(!store.cache_file_for(&root).exists());
}
#[test]
fn scan_cache_corrupted_file_is_cache_miss() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target");
std::fs::create_dir_all(&root).unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
std::fs::create_dir_all(store.root_dir()).unwrap();
std::fs::write(store.cache_file_for(&root), b"not json").unwrap();
let lookup = store.load(&root);
assert_eq!(
lookup,
ScanCacheLookup::pruned_miss(ScanCacheMiss::Corrupted)
);
assert!(!store.cache_file_for(&root).exists());
}
#[test]
fn scan_cache_future_version_is_stale() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target");
std::fs::create_dir_all(&root).unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let report = ScanReport {
bytes_scanned: 0,
files_scanned: 0,
directories_scanned: 1,
};
let mut record = store.store(&root, report).unwrap();
record.version = SCAN_CACHE_VERSION + 1;
std::fs::write(
store.cache_file_for(&root),
serde_json::to_vec_pretty(&record).unwrap(),
)
.unwrap();
let lookup = store.load(&root);
assert_eq!(lookup, ScanCacheLookup::pruned_miss(ScanCacheMiss::Stale));
assert!(!store.cache_file_for(&root).exists());
}
#[test]
fn scan_cache_store_overwrites_existing_record() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target");
std::fs::write(&root, b"abc").unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let first = ScanReport {
bytes_scanned: 1,
files_scanned: 1,
directories_scanned: 0,
};
let second = ScanReport {
bytes_scanned: 3,
files_scanned: 1,
directories_scanned: 0,
};
store.store(&root, first).unwrap();
store.store(&root, second).unwrap();
assert_eq!(store.load(&root), ScanCacheLookup::Hit(second));
}
#[test]
fn scan_cache_metadata_change_is_stale() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target.txt");
std::fs::write(&root, b"abc").unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let report = ScanReport {
bytes_scanned: 3,
files_scanned: 1,
directories_scanned: 0,
};
store.store(&root, report).unwrap();
std::fs::write(&root, b"abcdef").unwrap();
let lookup = store.load(&root);
assert_eq!(lookup, ScanCacheLookup::pruned_miss(ScanCacheMiss::Stale));
assert!(!store.cache_file_for(&root).exists());
}
#[test]
fn scan_cache_directory_record_expires_after_freshness_window() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target");
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("file.bin"), b"abc").unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let policy = ScanCachePolicy::new(1);
let report = ScanReport {
bytes_scanned: 3,
files_scanned: 1,
directories_scanned: 1,
};
let mut record = store.store(&root, report).unwrap();
record.written_at_unix_seconds =
unix_now().saturating_sub(policy.directory_record_max_age_seconds() + 1);
std::fs::write(
store.cache_file_for(&root),
serde_json::to_vec_pretty(&record).unwrap(),
)
.unwrap();
let lookup = store.load_with_policy(&root, policy);
assert_eq!(lookup, ScanCacheLookup::pruned_miss(ScanCacheMiss::Expired));
assert!(!store.cache_file_for(&root).exists());
}
#[test]
fn scan_cache_file_record_does_not_expire_by_age() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target.txt");
std::fs::write(&root, b"abc").unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let policy = ScanCachePolicy::new(1);
let report = ScanReport {
bytes_scanned: 3,
files_scanned: 1,
directories_scanned: 0,
};
let mut record = store.store(&root, report).unwrap();
record.written_at_unix_seconds =
unix_now().saturating_sub(DEFAULT_DIRECTORY_SCAN_CACHE_MAX_AGE_SECONDS + 1);
std::fs::write(
store.cache_file_for(&root),
serde_json::to_vec_pretty(&record).unwrap(),
)
.unwrap();
let lookup = store.load_with_policy(&root, policy);
assert_eq!(lookup, ScanCacheLookup::Hit(report));
assert!(store.cache_file_for(&root).exists());
}
#[test]
fn scan_cache_prune_removes_expired_directory_records() {
let temp = tempfile::tempdir().unwrap();
let fresh_root = temp.path().join("fresh");
let stale_root = temp.path().join("stale");
std::fs::create_dir_all(&fresh_root).unwrap();
std::fs::create_dir_all(&stale_root).unwrap();
std::fs::write(fresh_root.join("file.bin"), b"fresh").unwrap();
std::fs::write(stale_root.join("file.bin"), b"stale").unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let policy = ScanCachePolicy::new(1);
let report = ScanReport {
bytes_scanned: 5,
files_scanned: 1,
directories_scanned: 1,
};
store.store(&fresh_root, report).unwrap();
let mut stale_record = store.store(&stale_root, report).unwrap();
stale_record.written_at_unix_seconds = 0;
std::fs::write(
store.cache_file_for(&stale_root),
serde_json::to_vec_pretty(&stale_record).unwrap(),
)
.unwrap();
let prune_report = store.prune_with_policy(policy);
assert_eq!(prune_report.inspected, 2);
assert_eq!(prune_report.pruned, 1);
assert_eq!(prune_report.retained, 1);
assert!(store.cache_file_for(&fresh_root).exists());
assert!(!store.cache_file_for(&stale_root).exists());
}
#[test]
fn scan_cache_prune_removes_records_for_missing_roots() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("target.txt");
std::fs::write(&root, b"abc").unwrap();
let store = ScanCacheStore::new(temp.path().join("cache").join("scan"));
let report = ScanReport {
bytes_scanned: 3,
files_scanned: 1,
directories_scanned: 0,
};
store.store(&root, report).unwrap();
std::fs::remove_file(&root).unwrap();
let prune_report = store.prune();
assert_eq!(prune_report.inspected, 1);
assert_eq!(prune_report.pruned, 1);
assert_eq!(prune_report.retained, 0);
assert!(!store.cache_file_for(&root).exists());
}
#[test]
fn scan_cache_hash_is_case_insensitive_on_windows_only() {
let left = std::path::Path::new(r"C:\Temp\Cache");
let right = std::path::Path::new(r"c:\temp\cache");
if cfg!(windows) {
assert_eq!(path_hash(left), path_hash(right));
} else {
assert_ne!(path_hash(left), path_hash(right));
}
}
}