agent_runtime/render/
cache.rs1use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::fs;
13use std::path::Path;
14
15pub const CACHE_FILE: &str = ".render-cache.json";
16pub const AGENTS_CACHE_FILE: &str = ".render-cache-agents.json";
21pub 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 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 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 #[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 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}