Skip to main content

open_loops/
cache.rs

1//! Distillation cache at <base>/cache/<repo>/<branch>@<head-sha>.md.
2//! Keying by the HEAD SHA makes the cache self-invalidate when the branch advances.
3use crate::scanner::OpenLoop;
4use anyhow::Result;
5use std::path::{Path, PathBuf};
6
7/// Distillation cache persisted to disk.
8pub struct Cache {
9    dir: PathBuf,
10}
11
12impl Cache {
13    /// Creates a `Cache` whose files live under `base/cache/`.
14    pub fn new(base: &Path) -> Self {
15        Self {
16            dir: base.join("cache"),
17        }
18    }
19
20    fn path(&self, lp: &OpenLoop) -> PathBuf {
21        // branches contain '/', which cannot appear in a file name
22        let branch = lp.branch.replace('/', "__");
23        self.dir
24            .join(&lp.root_label)
25            .join(&lp.repo_name)
26            .join(format!("{branch}@{}.md", lp.head_sha))
27    }
28
29    /// Returns the cached content for `lp`, or `None` if it does not exist.
30    pub fn get(&self, lp: &OpenLoop) -> Option<String> {
31        std::fs::read_to_string(self.path(lp)).ok()
32    }
33
34    /// Persists `content` as the distillation of `lp`.
35    ///
36    /// # Errors
37    ///
38    /// Returns `Err` if the directories cannot be created or the file cannot be written.
39    pub fn put(&self, lp: &OpenLoop, content: &str) -> Result<()> {
40        let path = self.path(lp);
41        std::fs::create_dir_all(
42            path.parent()
43                .ok_or_else(|| anyhow::anyhow!("cache path has no parent directory"))?,
44        )?;
45        std::fs::write(path, content)?;
46        Ok(())
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::scanner::OpenLoop;
54    use chrono::Utc;
55    use std::path::PathBuf;
56
57    fn fake_loop(sha: &str) -> OpenLoop {
58        OpenLoop {
59            root_label: "work".into(),
60            repo_name: "app".into(),
61            repo_path: PathBuf::from("/tmp/app"),
62            branch: "feat/login".into(),
63            head_sha: sha.into(),
64            last_commit: Utc::now(),
65            ahead: Some(1),
66            behind: Some(0),
67        }
68    }
69
70    #[test]
71    fn miss_then_put_then_hit() {
72        let tmp = tempfile::tempdir().unwrap();
73        let cache = Cache::new(tmp.path());
74        let lp = fake_loop("abc123");
75        assert!(cache.get(&lp).is_none());
76        cache.put(&lp, "distilled context").unwrap();
77        assert_eq!(cache.get(&lp).unwrap(), "distilled context");
78    }
79
80    #[test]
81    fn new_head_self_invalidates() {
82        let tmp = tempfile::tempdir().unwrap();
83        let cache = Cache::new(tmp.path());
84        cache.put(&fake_loop("old-sha"), "old").unwrap();
85        assert!(cache.get(&fake_loop("new-sha")).is_none());
86    }
87
88    #[test]
89    fn path_includes_root_label_segment() {
90        let tmp = tempfile::tempdir().unwrap();
91        let cache = Cache::new(tmp.path());
92        let lp = fake_loop("sha1");
93        cache.put(&lp, "x").unwrap();
94        // distinct labels for the same repo/branch must not collide
95        let mut other = fake_loop("sha1");
96        other.root_label = "personal".into();
97        assert!(cache.get(&other).is_none());
98    }
99}