use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use crate::entry::Entry;
use crate::key::{CacheKey, FORMAT_VERSION};
const MAGIC: &[u8; 8] = b"LKCACHE\x04";
const CACHE_PATH: &str = ".lanekeep/cache";
#[derive(Debug, Default)]
pub struct Store {
entries: BTreeMap<CacheKey, Entry>,
}
impl Store {
#[must_use]
pub fn empty() -> Self {
Self::default()
}
#[must_use]
pub fn load(project_root: &Path) -> Self {
let Ok(bytes) = std::fs::read(Self::path_for(project_root)) else {
return Self::default();
};
Self::decode(&bytes).unwrap_or_default()
}
#[must_use]
pub fn get(&self, key: &CacheKey) -> Option<&Entry> {
self.entries.get(key)
}
pub fn insert(&mut self, key: CacheKey, entry: Entry) {
self.entries.insert(key, entry);
}
pub fn keys(&self) -> impl Iterator<Item = &CacheKey> {
self.entries.keys()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn save(&self, project_root: &Path) {
let path = Self::path_for(project_root);
let Some(parent) = path.parent() else {
return;
};
if std::fs::create_dir_all(parent).is_err() {
return;
}
let temporary = path.with_extension(format!("tmp{}", std::process::id()));
if std::fs::write(&temporary, self.encode()).is_err() {
let _ = std::fs::remove_file(&temporary);
return;
}
if std::fs::rename(&temporary, &path).is_err() {
let _ = std::fs::remove_file(&temporary);
}
}
#[must_use]
pub fn path_for(project_root: &Path) -> PathBuf {
project_root.join(CACHE_PATH)
}
fn encode(&self) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(MAGIC);
out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
out.extend_from_slice(&(self.entries.len() as u64).to_le_bytes());
let mut payload = Vec::new();
for (key, entry) in &self.entries {
out.extend_from_slice(key.as_bytes());
payload.clear();
entry.encode(&mut payload);
out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
out.extend_from_slice(&payload);
}
out
}
fn decode(bytes: &[u8]) -> Option<Self> {
let mut at = 0usize;
let magic = bytes.get(at..at + MAGIC.len())?;
if magic != MAGIC {
return None;
}
at += MAGIC.len();
let version = u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?);
if version != FORMAT_VERSION {
return None;
}
at += 4;
let count = u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?);
at += 8;
let mut entries = BTreeMap::new();
for _ in 0..count {
let key: [u8; 32] = bytes.get(at..at + 32)?.try_into().ok()?;
at += 32;
let len = u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?);
at += 8;
let len = usize::try_from(len).ok()?;
let payload = bytes.get(at..at.checked_add(len)?)?;
at += len;
entries.insert(CacheKey::from_bytes(key), Entry::decode(payload)?);
}
(at == bytes.len()).then_some(Self { entries })
}
}
#[cfg(test)]
mod tests {
use lanekeep_core::tracked::TrackedRead;
use lanekeep_core::{FilePath, Location, Position, Severity, Violation};
use super::*;
struct Project {
dir: PathBuf,
}
impl Project {
fn new(name: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("lanekeep-store-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("creates dir");
Self { dir }
}
}
impl Drop for Project {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn key(seed: u8) -> CacheKey {
CacheKey::from_bytes([seed; 32])
}
fn entry(line: u32) -> Entry {
Entry {
violations: vec![Violation {
rule_id: "local/a".parse().expect("valid id"),
location: Location::new(FilePath::new("src/a.ts"), Position::new(line, 1)),
message: "a message".to_owned(),
remediation: "a remediation".to_owned(),
severity: Severity::Error,
fix: None,
}],
facts: Vec::new(),
dependencies: vec![TrackedRead::absent(FilePath::new("tsconfig.json"))],
suppressions: Vec::new(),
used_suppressions: Vec::new(),
}
}
#[test]
fn a_saved_cache_loads_back() {
let project = Project::new("round-trip");
let mut store = Store::empty();
store.insert(key(1), entry(10));
store.insert(key(2), entry(20));
store.save(&project.dir);
let loaded = Store::load(&project.dir);
assert_eq!(loaded.len(), 2);
assert_eq!(loaded.get(&key(1)), Some(&entry(10)));
assert_eq!(loaded.get(&key(2)), Some(&entry(20)));
}
#[test]
fn loading_from_nothing_gives_an_empty_store() {
let project = Project::new("absent");
assert!(Store::load(&project.dir).is_empty());
}
#[test]
fn a_corrupt_file_gives_an_empty_store() {
let project = Project::new("corrupt");
let path = Store::path_for(&project.dir);
std::fs::create_dir_all(path.parent().expect("has a parent")).expect("creates dir");
std::fs::write(&path, b"not a cache file at all").expect("writes");
assert!(Store::load(&project.dir).is_empty());
}
#[test]
fn a_truncated_file_gives_an_empty_store() {
let project = Project::new("truncated");
let mut store = Store::empty();
store.insert(key(1), entry(10));
store.save(&project.dir);
let path = Store::path_for(&project.dir);
let bytes = std::fs::read(&path).expect("reads");
for cut in 0..bytes.len() {
std::fs::write(&path, &bytes[..cut]).expect("writes");
assert!(
Store::load(&project.dir).is_empty(),
"a {cut}-byte prefix loaded as a cache"
);
}
}
#[test]
fn a_file_from_another_format_version_is_ignored() {
let project = Project::new("version");
let mut store = Store::empty();
store.insert(key(1), entry(10));
store.save(&project.dir);
let path = Store::path_for(&project.dir);
let mut bytes = std::fs::read(&path).expect("reads");
bytes[MAGIC.len()] = bytes[MAGIC.len()].wrapping_add(1);
std::fs::write(&path, &bytes).expect("writes");
assert!(Store::load(&project.dir).is_empty());
}
#[test]
fn one_damaged_entry_discards_the_whole_file() {
let project = Project::new("damaged");
let mut store = Store::empty();
store.insert(key(1), entry(10));
store.insert(key(2), entry(20));
store.save(&project.dir);
let path = Store::path_for(&project.dir);
let mut bytes = std::fs::read(&path).expect("reads");
let last = bytes.len() - 1;
bytes[last] = 9;
std::fs::write(&path, &bytes).expect("writes");
assert!(Store::load(&project.dir).is_empty());
}
#[test]
fn saving_the_same_entries_produces_identical_bytes() {
let project = Project::new("stable");
let mut one = Store::empty();
one.insert(key(2), entry(20));
one.insert(key(1), entry(10));
one.save(&project.dir);
let first = std::fs::read(Store::path_for(&project.dir)).expect("reads");
let mut other = Store::empty();
other.insert(key(1), entry(10));
other.insert(key(2), entry(20));
other.save(&project.dir);
let second = std::fs::read(Store::path_for(&project.dir)).expect("reads");
assert_eq!(first, second, "insertion order leaked into the file");
}
#[test]
fn saving_replaces_rather_than_appends() {
let project = Project::new("replace");
let mut store = Store::empty();
store.insert(key(1), entry(10));
store.save(&project.dir);
let mut replacement = Store::empty();
replacement.insert(key(2), entry(20));
replacement.save(&project.dir);
let loaded = Store::load(&project.dir);
assert_eq!(loaded.len(), 1);
assert!(loaded.get(&key(1)).is_none());
}
#[test]
fn saving_leaves_no_temporary_behind() {
let project = Project::new("no-temp");
let mut store = Store::empty();
store.insert(key(1), entry(10));
store.save(&project.dir);
let dir = Store::path_for(&project.dir);
let parent = dir.parent().expect("has a parent");
let leftovers: Vec<String> = std::fs::read_dir(parent)
.expect("reads dir")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name.contains("tmp"))
.collect();
assert!(leftovers.is_empty(), "left behind: {leftovers:?}");
}
#[test]
fn saving_into_an_unwritable_place_is_silent() {
let store = Store::empty();
store.save(Path::new("/definitely/not/a/writable/place"));
}
}