Skip to main content

agent_runtime/render/
cache.rs

1//! Per-product render cache persisted as `.render-cache.json` at the
2//! root of `build/<product>/`. The cache keeps the cache-hit path
3//! byte-identical to the cache-miss path: when the recorded hash for a
4//! skill matches and the output file is still on disk, render leaves
5//! the file alone; otherwise the skill is re-rendered and overwritten.
6//!
7//! Serialization uses [`BTreeMap`] so the on-disk file is byte-stable
8//! across runs (the cache-hit equality test depends on that).
9
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::fs;
13use std::path::Path;
14
15pub const CACHE_FILE: &str = ".render-cache.json";
16/// Separate cache file for the optional agents render surface. Kept
17/// distinct from `CACHE_FILE` so the agents loop and the skills loop
18/// never reconcile against each other's entries (a shared cache would
19/// make each surface delete the other's outputs on save).
20pub const AGENTS_CACHE_FILE: &str = ".render-cache-agents.json";
21/// Bumped to 2 alongside the multi-file render landing (v0.14): cache
22/// entries now record every file the skill wrote (`outputs: Vec<String>`)
23/// so the renderer can surgically remove sibling files that disappear
24/// from source on a subsequent run. Caches written by older binaries
25/// silently load as empty and force a full re-render.
26pub const CACHE_SCHEMA_VERSION: u32 = 2;
27
28#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
29pub struct RenderCache {
30    pub schema_version: u32,
31    pub skills: BTreeMap<String, CacheEntry>,
32}
33
34impl RenderCache {
35    pub fn empty() -> Self {
36        Self {
37            schema_version: CACHE_SCHEMA_VERSION,
38            skills: BTreeMap::new(),
39        }
40    }
41
42    /// Load the cache from `path`, returning an empty cache when the
43    /// file is missing or unreadable. An unparsable cache file or one
44    /// with a `schema_version` that we don't know how to interpret is
45    /// silently treated as empty so a corrupted or future-format cache
46    /// forces a full re-render instead of failing the run or silently
47    /// trusting cache entries from a different version.
48    pub fn load_or_empty(path: &Path) -> Self {
49        let Ok(body) = fs::read_to_string(path) else {
50            return Self::empty();
51        };
52        let Ok(parsed) = serde_json::from_str::<Self>(&body) else {
53            return Self::empty();
54        };
55        if parsed.schema_version != CACHE_SCHEMA_VERSION {
56            return Self::empty();
57        }
58        parsed
59    }
60
61    pub fn save(&self, path: &Path) -> std::io::Result<()> {
62        let body = serde_json::to_string_pretty(self).expect("RenderCache serializes");
63        fs::write(path, body.as_bytes())
64    }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct CacheEntry {
69    pub hash: String,
70    /// Every file path (relative to `build/<product>/`) that this skill
71    /// wrote during the recorded render. Sorted lexicographically for
72    /// byte-stable serialization; the SKILL leaf appears in the list
73    /// alongside every sibling (`bin/...`, `scripts/...`,
74    /// `references/...`). The renderer uses this set to remove stale
75    /// files on cache miss without disturbing files owned by sibling
76    /// skills that share the same `dirname(render_to)`.
77    pub outputs: Vec<String>,
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use tempfile::TempDir;
84
85    #[test]
86    fn round_trips_through_disk() {
87        let tmp = TempDir::new().unwrap();
88        let path = tmp.path().join(".render-cache.json");
89        let mut cache = RenderCache::empty();
90        cache.skills.insert(
91            "market.favorites".to_string(),
92            CacheEntry {
93                hash: "sha256:deadbeef".to_string(),
94                outputs: vec!["skills/sample/SKILL.md".to_string()],
95            },
96        );
97        cache.save(&path).unwrap();
98        let loaded = RenderCache::load_or_empty(&path);
99        assert_eq!(loaded, cache);
100    }
101
102    #[test]
103    fn missing_file_loads_as_empty() {
104        let tmp = TempDir::new().unwrap();
105        let path = tmp.path().join("does-not-exist.json");
106        let loaded = RenderCache::load_or_empty(&path);
107        assert!(loaded.skills.is_empty());
108        assert_eq!(loaded.schema_version, CACHE_SCHEMA_VERSION);
109    }
110
111    #[test]
112    fn unparsable_file_loads_as_empty() {
113        let tmp = TempDir::new().unwrap();
114        let path = tmp.path().join(".render-cache.json");
115        fs::write(&path, "this is not json").unwrap();
116        let loaded = RenderCache::load_or_empty(&path);
117        assert!(loaded.skills.is_empty());
118    }
119
120    /// A cache file written by a future agent-runtime with a newer
121    /// `schema_version` should be ignored rather than half-trusted —
122    /// the entries' shape might have changed.
123    #[test]
124    fn schema_version_mismatch_loads_as_empty() {
125        let tmp = TempDir::new().unwrap();
126        let path = tmp.path().join(".render-cache.json");
127        fs::write(
128            &path,
129            r#"{"schema_version": 99, "skills": {"old.skill": {"hash": "sha256:0", "outputs": ["x"]}}}"#,
130        )
131        .unwrap();
132        let loaded = RenderCache::load_or_empty(&path);
133        assert!(loaded.skills.is_empty());
134        assert_eq!(loaded.schema_version, CACHE_SCHEMA_VERSION);
135    }
136
137    #[test]
138    fn save_emits_sorted_keys_for_byte_stability() {
139        let tmp = TempDir::new().unwrap();
140        let path = tmp.path().join(".render-cache.json");
141        let mut cache = RenderCache::empty();
142        cache.skills.insert(
143            "zeta.last".to_string(),
144            CacheEntry {
145                hash: "sha256:1".to_string(),
146                outputs: vec!["z".to_string()],
147            },
148        );
149        cache.skills.insert(
150            "alpha.first".to_string(),
151            CacheEntry {
152                hash: "sha256:2".to_string(),
153                outputs: vec!["a".to_string()],
154            },
155        );
156        cache.save(&path).unwrap();
157        let body = fs::read_to_string(&path).unwrap();
158        // BTreeMap serialization → alpha appears before zeta.
159        let alpha_idx = body.find("alpha.first").unwrap();
160        let zeta_idx = body.find("zeta.last").unwrap();
161        assert!(alpha_idx < zeta_idx);
162    }
163}