use std::fs;
use std::path::{Path, PathBuf};
use crate::FactSet;
#[derive(Debug, thiserror::Error)]
pub enum CacheError {
#[error("cache io error: {0}")]
Io(#[from] std::io::Error),
#[error("cache json error: {0}")]
Json(#[from] serde_json::Error),
}
pub struct ObjectCache {
root: PathBuf,
}
impl ObjectCache {
pub fn open(root: impl Into<PathBuf>) -> Result<Self, CacheError> {
let root = root.into();
fs::create_dir_all(&root)?;
Ok(Self { root })
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
fn path_for(&self, blob_id: &str) -> PathBuf {
let (shard, rest) = blob_id.split_at(blob_id.len().min(2));
self.root.join(shard).join(format!("{rest}.json"))
}
#[must_use]
pub fn contains(&self, blob_id: &str) -> bool {
self.path_for(blob_id).exists()
}
pub fn get(&self, blob_id: &str) -> Result<Option<FactSet>, CacheError> {
let path = self.path_for(blob_id);
match fs::read(&path) {
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
pub fn put(&self, blob_id: &str, facts: &FactSet) -> Result<(), CacheError> {
let path = self.path_for(blob_id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let unique = format!(
"{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let tmp = path.with_extension(format!("json.tmp.{unique}"));
let bytes = serde_json::to_vec(facts)?;
fs::write(&tmp, &bytes)?;
match fs::rename(&tmp, &path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
match fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
fs::rename(&tmp, &path)?;
Ok(())
}
Err(e) => Err(e.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::ObjectCache;
use crate::{Edge, EdgeKind, FactSet, Node, NodeKind};
fn sample() -> FactSet {
FactSet::new()
.with_node(Node::new("a", NodeKind::Fn, "a"))
.with_node(Node::new("b", NodeKind::Fn, "b"))
.with_edge(Edge::derived("a", "b", EdgeKind::Calls))
}
#[test]
fn put_get_round_trip_and_miss() {
let dir = std::env::temp_dir().join(format!("roteiro-cache-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let cache = ObjectCache::open(&dir).expect("open");
assert!(!cache.contains("deadbeef"));
assert!(cache.get("deadbeef").expect("get").is_none());
let facts = sample();
cache.put("deadbeef", &facts).expect("put");
assert!(cache.contains("deadbeef"));
assert_eq!(cache.get("deadbeef").expect("get"), Some(facts));
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn put_overwrites_existing_entry() {
let dir =
std::env::temp_dir().join(format!("roteiro-cache-overwrite-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let cache = ObjectCache::open(&dir).expect("open");
cache.put("beef", &sample()).expect("first put");
let replacement = FactSet::new().with_node(Node::new("only", NodeKind::File, "only"));
cache.put("beef", &replacement).expect("overwrite");
assert_eq!(cache.get("beef").expect("get"), Some(replacement));
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn short_ids_do_not_panic_on_shard() {
let dir = std::env::temp_dir().join(format!("roteiro-cache-short-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
let cache = ObjectCache::open(&dir).expect("open");
cache.put("a", &FactSet::new()).expect("put short id");
assert_eq!(cache.get("a").expect("get"), Some(FactSet::new()));
std::fs::remove_dir_all(&dir).expect("cleanup");
}
}