1use std::fs;
12use std::path::{Path, PathBuf};
13
14use crate::FactSet;
15
16#[derive(Debug, thiserror::Error)]
18pub enum CacheError {
19 #[error("cache io error: {0}")]
21 Io(#[from] std::io::Error),
22 #[error("cache json error: {0}")]
24 Json(#[from] serde_json::Error),
25}
26
27pub struct ObjectCache {
29 root: PathBuf,
30}
31
32impl ObjectCache {
33 pub fn open(root: impl Into<PathBuf>) -> Result<Self, CacheError> {
38 let root = root.into();
39 fs::create_dir_all(&root)?;
40 Ok(Self { root })
41 }
42
43 #[must_use]
45 pub fn root(&self) -> &Path {
46 &self.root
47 }
48
49 fn path_for(&self, blob_id: &str) -> PathBuf {
50 let (shard, rest) = blob_id.split_at(blob_id.len().min(2));
52 self.root.join(shard).join(format!("{rest}.json"))
53 }
54
55 #[must_use]
57 pub fn contains(&self, blob_id: &str) -> bool {
58 self.path_for(blob_id).exists()
59 }
60
61 pub fn get(&self, blob_id: &str) -> Result<Option<FactSet>, CacheError> {
67 let path = self.path_for(blob_id);
68 match fs::read(&path) {
69 Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
70 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
71 Err(e) => Err(e.into()),
72 }
73 }
74
75 pub fn put(&self, blob_id: &str, facts: &FactSet) -> Result<(), CacheError> {
82 let path = self.path_for(blob_id);
83 if let Some(parent) = path.parent() {
84 fs::create_dir_all(parent)?;
85 }
86
87 let unique = format!(
89 "{}-{}",
90 std::process::id(),
91 std::time::SystemTime::now()
92 .duration_since(std::time::UNIX_EPOCH)
93 .unwrap_or_default()
94 .as_nanos()
95 );
96 let tmp = path.with_extension(format!("json.tmp.{unique}"));
97
98 let bytes = serde_json::to_vec(facts)?;
99 fs::write(&tmp, &bytes)?;
100
101 match fs::rename(&tmp, &path) {
102 Ok(()) => Ok(()),
103 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
104 match fs::remove_file(&path) {
105 Ok(()) => {}
106 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
107 Err(e) => return Err(e.into()),
108 }
109 fs::rename(&tmp, &path)?;
110 Ok(())
111 }
112 Err(e) => Err(e.into()),
113 }
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::ObjectCache;
120 use crate::{Edge, EdgeKind, FactSet, Node, NodeKind};
121
122 fn sample() -> FactSet {
123 FactSet::new()
124 .with_node(Node::new("a", NodeKind::Fn, "a"))
125 .with_node(Node::new("b", NodeKind::Fn, "b"))
126 .with_edge(Edge::derived("a", "b", EdgeKind::Calls))
127 }
128
129 #[test]
130 fn put_get_round_trip_and_miss() {
131 let dir = std::env::temp_dir().join(format!("roteiro-cache-{}", std::process::id()));
132 std::fs::remove_dir_all(&dir).ok();
133 let cache = ObjectCache::open(&dir).expect("open");
134
135 assert!(!cache.contains("deadbeef"));
136 assert!(cache.get("deadbeef").expect("get").is_none());
137
138 let facts = sample();
139 cache.put("deadbeef", &facts).expect("put");
140 assert!(cache.contains("deadbeef"));
141 assert_eq!(cache.get("deadbeef").expect("get"), Some(facts));
142
143 std::fs::remove_dir_all(&dir).expect("cleanup");
144 }
145
146 #[test]
147 fn put_overwrites_existing_entry() {
148 let dir =
149 std::env::temp_dir().join(format!("roteiro-cache-overwrite-{}", std::process::id()));
150 std::fs::remove_dir_all(&dir).ok();
151 let cache = ObjectCache::open(&dir).expect("open");
152
153 cache.put("beef", &sample()).expect("first put");
154 let replacement = FactSet::new().with_node(Node::new("only", NodeKind::File, "only"));
156 cache.put("beef", &replacement).expect("overwrite");
157 assert_eq!(cache.get("beef").expect("get"), Some(replacement));
158
159 std::fs::remove_dir_all(&dir).expect("cleanup");
160 }
161
162 #[test]
163 fn short_ids_do_not_panic_on_shard() {
164 let dir = std::env::temp_dir().join(format!("roteiro-cache-short-{}", std::process::id()));
165 std::fs::remove_dir_all(&dir).ok();
166 let cache = ObjectCache::open(&dir).expect("open");
167 cache.put("a", &FactSet::new()).expect("put short id");
168 assert_eq!(cache.get("a").expect("get"), Some(FactSet::new()));
169 std::fs::remove_dir_all(&dir).expect("cleanup");
170 }
171}