1use devboy_core::asset::AssetContext;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::io::Write as _;
16use std::path::{Path, PathBuf};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use crate::error::{AssetError, Result};
20
21pub const INDEX_FILENAME: &str = "index.json";
23
24pub const INDEX_VERSION: u32 = 1;
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
29pub struct CachedAsset {
30 pub id: String,
32 pub filename: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub mime_type: Option<String>,
36 pub size: u64,
37 pub local_path: PathBuf,
39 pub context: AssetContext,
41 pub checksum_sha256: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub remote_url: Option<String>,
46 pub downloaded_at_ms: u64,
48 pub last_accessed_ms: u64,
50}
51
52#[derive(Debug, Clone)]
57pub struct NewCachedAsset {
58 pub id: String,
60 pub filename: String,
61 pub mime_type: Option<String>,
63 pub size: u64,
64 pub local_path: PathBuf,
66 pub context: AssetContext,
68 pub checksum_sha256: String,
70 pub remote_url: Option<String>,
72}
73
74impl CachedAsset {
75 pub fn new(params: NewCachedAsset) -> Self {
78 let now = now_ms();
79 Self {
80 id: params.id,
81 filename: params.filename,
82 mime_type: params.mime_type,
83 size: params.size,
84 local_path: params.local_path,
85 context: params.context,
86 checksum_sha256: params.checksum_sha256,
87 remote_url: params.remote_url,
88 downloaded_at_ms: now,
89 last_accessed_ms: now,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct AssetIndex {
97 pub version: u32,
99 #[serde(default)]
101 pub assets: HashMap<String, CachedAsset>,
102}
103
104impl Default for AssetIndex {
105 fn default() -> Self {
106 Self {
107 version: INDEX_VERSION,
108 assets: HashMap::new(),
109 }
110 }
111}
112
113impl AssetIndex {
114 pub fn empty() -> Self {
116 Self::default()
117 }
118
119 pub fn load(cache_dir: &Path) -> Result<Self> {
127 let path = cache_dir.join(INDEX_FILENAME);
128 if !path.exists() {
129 return Ok(Self::empty());
130 }
131
132 let bytes = std::fs::read(&path)?;
133 match serde_json::from_slice::<Self>(&bytes) {
134 Ok(mut index) => {
135 if index.version != INDEX_VERSION {
136 tracing::warn!(
137 expected = INDEX_VERSION,
138 found = index.version,
139 "asset index version mismatch, purging cache and rebuilding"
140 );
141 purge_cache_blobs(cache_dir);
142 index = Self::empty();
143 }
144 Ok(index)
145 }
146 Err(err) => {
147 tracing::warn!(
148 ?err,
149 "failed to parse asset index, purging cache and starting fresh"
150 );
151 purge_cache_blobs(cache_dir);
152 Ok(Self::empty())
153 }
154 }
155 }
156
157 pub fn save(&self, cache_dir: &Path) -> Result<()> {
160 std::fs::create_dir_all(cache_dir)?;
161 let path = cache_dir.join(INDEX_FILENAME);
162
163 let bytes = serde_json::to_vec_pretty(self)?;
164
165 let mut tmp = tempfile::NamedTempFile::new_in(cache_dir)
168 .map_err(|e| AssetError::cache_dir(format!("temp file: {e}")))?;
169 tmp.write_all(&bytes)?;
170 tmp.flush()?;
171 tmp.persist(&path)
172 .map_err(|e| AssetError::cache_dir(format!("persist index: {e}")))?;
173 Ok(())
174 }
175
176 pub fn upsert(&mut self, asset: CachedAsset) {
178 self.assets.insert(asset.id.clone(), asset);
179 }
180
181 pub fn remove(&mut self, id: &str) -> Option<CachedAsset> {
183 self.assets.remove(id)
184 }
185
186 pub fn get(&self, id: &str) -> Option<&CachedAsset> {
188 self.assets.get(id)
189 }
190
191 pub fn get_mut(&mut self, id: &str) -> Option<&mut CachedAsset> {
193 self.assets.get_mut(id)
194 }
195
196 pub fn touch(&mut self, id: &str) -> bool {
198 if let Some(asset) = self.assets.get_mut(id) {
199 asset.last_accessed_ms = now_ms();
200 true
201 } else {
202 false
203 }
204 }
205
206 pub fn total_size(&self) -> u64 {
208 self.assets.values().map(|a| a.size).sum()
209 }
210
211 pub fn len(&self) -> usize {
213 self.assets.len()
214 }
215
216 pub fn is_empty(&self) -> bool {
218 self.assets.is_empty()
219 }
220}
221
222fn purge_cache_blobs(cache_dir: &Path) {
230 let entries = match std::fs::read_dir(cache_dir) {
231 Ok(entries) => entries,
232 Err(e) => {
233 tracing::warn!(?e, "failed to list cache directory for purge");
234 return;
235 }
236 };
237 for entry in entries.flatten() {
238 let path = entry.path();
239 if path.file_name().is_some_and(|n| n == INDEX_FILENAME) {
242 continue;
243 }
244 let is_real_dir = match std::fs::symlink_metadata(&path) {
249 Ok(meta) => meta.is_dir(),
250 Err(e) => {
251 tracing::warn!(?e, path = ?path, "failed to stat cached entry");
252 continue;
253 }
254 };
255 let result = if is_real_dir {
256 std::fs::remove_dir_all(&path)
257 } else {
258 std::fs::remove_file(&path)
259 };
260 if let Err(e) = result {
261 tracing::warn!(?e, path = ?path, "failed to purge cached file");
262 }
263 }
264}
265
266pub fn now_ms() -> u64 {
268 SystemTime::now()
269 .duration_since(UNIX_EPOCH)
270 .map(|d| d.as_millis() as u64)
271 .unwrap_or(0)
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use devboy_core::asset::AssetContext;
278 use tempfile::tempdir;
279
280 fn make_asset(id: &str, size: u64) -> CachedAsset {
281 CachedAsset::new(NewCachedAsset {
282 id: id.into(),
283 filename: format!("{id}.txt"),
284 mime_type: Some("text/plain".into()),
285 size,
286 local_path: PathBuf::from(format!("files/{id}.txt")),
287 context: AssetContext::Issue {
288 key: "DEV-1".into(),
289 },
290 checksum_sha256: "abcd".into(),
291 remote_url: None,
292 })
293 }
294
295 #[test]
296 fn upsert_get_remove() {
297 let mut index = AssetIndex::empty();
298 index.upsert(make_asset("a1", 10));
299 index.upsert(make_asset("a2", 20));
300 assert_eq!(index.len(), 2);
301 assert_eq!(index.total_size(), 30);
302
303 assert_eq!(index.get("a1").unwrap().size, 10);
304 let removed = index.remove("a1").unwrap();
305 assert_eq!(removed.id, "a1");
306 assert_eq!(index.len(), 1);
307 assert!(index.get("a1").is_none());
308 }
309
310 #[test]
311 fn touch_updates_last_accessed() {
312 let mut index = AssetIndex::empty();
313 index.upsert(make_asset("a1", 10));
314 let original = index.get("a1").unwrap().last_accessed_ms;
315
316 std::thread::sleep(std::time::Duration::from_millis(2));
318 assert!(index.touch("a1"));
319 assert!(index.get("a1").unwrap().last_accessed_ms > original);
320 assert!(!index.touch("missing"));
321 }
322
323 #[test]
324 fn load_missing_returns_empty() {
325 let tmp = tempdir().unwrap();
326 let index = AssetIndex::load(tmp.path()).unwrap();
327 assert!(index.is_empty());
328 assert_eq!(index.version, INDEX_VERSION);
329 }
330
331 #[test]
332 fn save_and_reload_roundtrip() {
333 let tmp = tempdir().unwrap();
334 let mut index = AssetIndex::empty();
335 index.upsert(make_asset("a1", 42));
336 index.save(tmp.path()).unwrap();
337
338 let reloaded = AssetIndex::load(tmp.path()).unwrap();
339 assert_eq!(reloaded.len(), 1);
340 assert_eq!(reloaded.get("a1").unwrap().size, 42);
341 }
342
343 #[test]
344 fn corrupt_index_falls_back_to_empty() {
345 let tmp = tempdir().unwrap();
346 std::fs::write(tmp.path().join(INDEX_FILENAME), b"not json").unwrap();
347 let index = AssetIndex::load(tmp.path()).unwrap();
348 assert!(index.is_empty(), "corrupt index should fall back to empty");
349 }
350
351 #[test]
352 fn version_mismatch_falls_back_to_empty() {
353 let tmp = tempdir().unwrap();
354 std::fs::write(
355 tmp.path().join(INDEX_FILENAME),
356 br#"{"version":999,"assets":{}}"#,
357 )
358 .unwrap();
359 let index = AssetIndex::load(tmp.path()).unwrap();
360 assert_eq!(index.version, INDEX_VERSION);
361 assert!(index.is_empty());
362 }
363
364 #[test]
365 fn save_is_atomic_under_overwrite() {
366 let tmp = tempdir().unwrap();
367 let mut index = AssetIndex::empty();
368 index.upsert(make_asset("a1", 1));
369 index.save(tmp.path()).unwrap();
370
371 index.upsert(make_asset("a2", 2));
373 index.save(tmp.path()).unwrap();
374
375 let reloaded = AssetIndex::load(tmp.path()).unwrap();
376 assert_eq!(reloaded.len(), 2);
377
378 let stragglers: Vec<_> = std::fs::read_dir(tmp.path())
380 .unwrap()
381 .filter_map(|e| e.ok())
382 .filter(|e| {
383 let name = e.file_name();
384 let name = name.to_string_lossy();
385 name != INDEX_FILENAME
386 })
387 .collect();
388 assert!(stragglers.is_empty(), "unexpected files: {stragglers:?}");
389 }
390}