1use std::collections::BTreeMap;
7use std::fs::File;
8use std::io::Read;
9use std::path::{Path, PathBuf};
10
11use directories::ProjectDirs;
12use ready_set_sdk::fs as sdk_fs;
13use ready_set_sdk::manifest::Manifest;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16use time::OffsetDateTime;
17use time::format_description::well_known::Rfc3339;
18
19const SCHEMA_VERSION: u32 = 1;
20const TTL_SECONDS: i64 = 86_400;
21const HEAD_BYTES: usize = 4096;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct PluginCache {
26 pub schema_version: u32,
28 pub entries: BTreeMap<String, CacheEntry>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct CacheEntry {
35 pub manifest: Manifest,
37 pub cached_at: String,
39}
40
41impl Default for PluginCache {
42 fn default() -> Self {
43 Self {
44 schema_version: SCHEMA_VERSION,
45 entries: BTreeMap::new(),
46 }
47 }
48}
49
50impl PluginCache {
51 #[must_use]
53 pub fn default_path() -> Option<PathBuf> {
54 ProjectDirs::from("dev", "ready-set", "ready-set")
55 .map(|d| d.cache_dir().join("plugins.json"))
56 }
57
58 #[must_use]
61 pub fn load(path: &Path) -> Self {
62 let Ok(raw) = std::fs::read(path) else {
63 return Self::default();
64 };
65 let Ok(parsed) = serde_json::from_slice::<Self>(&raw) else {
66 return Self::default();
67 };
68 if parsed.schema_version != SCHEMA_VERSION {
69 return Self::default();
70 }
71 parsed
72 }
73
74 pub fn save(&self, path: &Path) -> ready_set_sdk::Result<()> {
80 let bytes = serde_json::to_vec(self)?;
81 sdk_fs::atomic_write(path, &bytes)
82 }
83
84 #[must_use]
86 pub fn get(&self, key: &CacheKey) -> Option<&Manifest> {
87 let entry = self.entries.get(&key.encode())?;
88 let cached = OffsetDateTime::parse(&entry.cached_at, &Rfc3339).ok()?;
89 let age = (OffsetDateTime::now_utc() - cached).whole_seconds();
90 if (0..TTL_SECONDS).contains(&age) {
91 Some(&entry.manifest)
92 } else {
93 None
94 }
95 }
96
97 pub fn insert(&mut self, key: &CacheKey, manifest: Manifest) -> ready_set_sdk::Result<()> {
104 let cached_at = OffsetDateTime::now_utc()
105 .format(&Rfc3339)
106 .map_err(|e| ready_set_sdk::Error::other(format!("rfc3339 format: {e}")))?;
107 self.entries.insert(
108 key.encode(),
109 CacheEntry {
110 manifest,
111 cached_at,
112 },
113 );
114 Ok(())
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct CacheKey {
121 pub canonical_path: PathBuf,
123 pub size_bytes: u64,
125 pub head4k_sha256: String,
127}
128
129impl CacheKey {
130 pub fn for_binary(path: &Path) -> ready_set_sdk::Result<Self> {
136 let canonical_path = std::fs::canonicalize(path)?;
137 let metadata = std::fs::metadata(&canonical_path)?;
138 let size_bytes = metadata.len();
139
140 let mut f = File::open(&canonical_path)?;
141 let mut buf = vec![0_u8; HEAD_BYTES];
142 let read = f.read(&mut buf)?;
143 let mut hasher = Sha256::new();
144 hasher.update(&buf[..read]);
145 let head4k_sha256 = encode_hex_lower(&hasher.finalize());
146
147 Ok(Self {
148 canonical_path,
149 size_bytes,
150 head4k_sha256,
151 })
152 }
153
154 fn encode(&self) -> String {
155 format!(
156 "{}:{}:{}",
157 self.canonical_path.display(),
158 self.size_bytes,
159 self.head4k_sha256
160 )
161 }
162}
163
164fn encode_hex_lower(bytes: &[u8]) -> String {
165 const HEX: &[u8; 16] = b"0123456789abcdef";
166 let mut out = String::with_capacity(bytes.len() * 2);
167 for b in bytes {
168 out.push(HEX[(b >> 4) as usize] as char);
169 out.push(HEX[(b & 0x0f) as usize] as char);
170 }
171 out
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use ready_set_sdk::describe::{Platform, Stability};
178
179 fn fixture_manifest() -> Manifest {
180 Manifest {
181 description: "x".into(),
182 version: "0.1.0".parse().unwrap(),
183 stability: Stability::Stable,
184 min_dispatcher_version: "0.1.0".parse().unwrap(),
185 platforms: vec![Platform::Linux],
186 requires_cargo_workspace: false,
187 capabilities: Vec::new(),
188 }
189 }
190
191 #[test]
192 fn schema_mismatch_yields_empty_cache() {
193 let dir = tempfile::tempdir().unwrap();
194 let path = dir.path().join("plugins.json");
195 std::fs::write(&path, br#"{"schema_version":99,"entries":{}}"#).unwrap();
196 let loaded = PluginCache::load(&path);
197 assert_eq!(loaded.schema_version, SCHEMA_VERSION);
198 assert!(loaded.entries.is_empty());
199 }
200
201 #[test]
202 fn corrupt_file_yields_empty_cache() {
203 let dir = tempfile::tempdir().unwrap();
204 let path = dir.path().join("plugins.json");
205 std::fs::write(&path, b"not json").unwrap();
206 let loaded = PluginCache::load(&path);
207 assert!(loaded.entries.is_empty());
208 }
209
210 #[test]
211 fn round_trip_save_and_load() {
212 let dir = tempfile::tempdir().unwrap();
213 let path = dir.path().join("plugins.json");
214 let bin = dir.path().join("ready-set-foo");
215 std::fs::write(&bin, b"#!/bin/sh\necho hi\n").unwrap();
216 let key = CacheKey::for_binary(&bin).unwrap();
217 let mut cache = PluginCache::default();
218 cache.insert(&key, fixture_manifest()).unwrap();
219 cache.save(&path).unwrap();
220 let loaded = PluginCache::load(&path);
221 assert!(loaded.get(&key).is_some());
222 }
223}