Skip to main content

rto_graph/
cache.rs

1//! Content-addressed on-disk cache of per-blob [`FactSet`]s.
2//!
3//! The cache is a simple content-addressed key→[`FactSet`] store; the caller
4//! derives the key (see [`crate::sync`], which keys by blob oid **and** path,
5//! because extraction is a pure function of both). The cache lives under the
6//! repository's *common* git directory (e.g. `<common>/roteiro/objects/`), so
7//! all worktrees and branches that share a key share its extracted facts.
8//! Entries are JSON, sharded by the first two characters of the key (git-style)
9//! to keep directories small.
10
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use crate::FactSet;
15
16/// Errors raised by the object cache.
17#[derive(Debug, thiserror::Error)]
18pub enum CacheError {
19    /// Filesystem failure.
20    #[error("cache io error: {0}")]
21    Io(#[from] std::io::Error),
22    /// A cached entry could not be (de)serialized.
23    #[error("cache json error: {0}")]
24    Json(#[from] serde_json::Error),
25}
26
27/// A content-addressed store of fact sets on disk.
28pub struct ObjectCache {
29    root: PathBuf,
30}
31
32impl ObjectCache {
33    /// Open (creating if absent) a cache rooted at `root`.
34    ///
35    /// # Errors
36    /// Returns [`CacheError::Io`] if the root directory cannot be created.
37    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    /// The directory this cache stores objects under.
44    #[must_use]
45    pub fn root(&self) -> &Path {
46        &self.root
47    }
48
49    fn path_for(&self, blob_id: &str) -> PathBuf {
50        // Shard by the first two characters, like git's `objects/ab/cdef…`.
51        let (shard, rest) = blob_id.split_at(blob_id.len().min(2));
52        self.root.join(shard).join(format!("{rest}.json"))
53    }
54
55    /// Whether a fact set is cached for `blob_id`.
56    #[must_use]
57    pub fn contains(&self, blob_id: &str) -> bool {
58        self.path_for(blob_id).exists()
59    }
60
61    /// Load the cached fact set for `blob_id`, if present.
62    ///
63    /// # Errors
64    /// Returns [`CacheError::Io`] on read failure or [`CacheError::Json`] if the
65    /// entry cannot be decoded.
66    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    /// Store `facts` under `blob_id`, replacing any existing entry. The write is
76    /// atomic (write-to-temp then rename) so a crash never leaves a torn entry.
77    ///
78    /// # Errors
79    /// Returns [`CacheError::Io`] on write failure or [`CacheError::Json`] if
80    /// `facts` cannot be encoded.
81    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        // Use a unique temp file name to avoid cross-process clobbering.
88        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        // A second put for the same key must atomically replace the entry.
155        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}