use devboy_core::asset::AssetContext;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::error::{AssetError, Result};
pub const INDEX_FILENAME: &str = "index.json";
pub const INDEX_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CachedAsset {
pub id: String,
pub filename: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
pub size: u64,
pub local_path: PathBuf,
pub context: AssetContext,
pub checksum_sha256: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub remote_url: Option<String>,
pub downloaded_at_ms: u64,
pub last_accessed_ms: u64,
}
#[derive(Debug, Clone)]
pub struct NewCachedAsset {
pub id: String,
pub filename: String,
pub mime_type: Option<String>,
pub size: u64,
pub local_path: PathBuf,
pub context: AssetContext,
pub checksum_sha256: String,
pub remote_url: Option<String>,
}
impl CachedAsset {
pub fn new(params: NewCachedAsset) -> Self {
let now = now_ms();
Self {
id: params.id,
filename: params.filename,
mime_type: params.mime_type,
size: params.size,
local_path: params.local_path,
context: params.context,
checksum_sha256: params.checksum_sha256,
remote_url: params.remote_url,
downloaded_at_ms: now,
last_accessed_ms: now,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssetIndex {
pub version: u32,
#[serde(default)]
pub assets: HashMap<String, CachedAsset>,
}
impl Default for AssetIndex {
fn default() -> Self {
Self {
version: INDEX_VERSION,
assets: HashMap::new(),
}
}
}
impl AssetIndex {
pub fn empty() -> Self {
Self::default()
}
pub fn load(cache_dir: &Path) -> Result<Self> {
let path = cache_dir.join(INDEX_FILENAME);
if !path.exists() {
return Ok(Self::empty());
}
let bytes = std::fs::read(&path)?;
match serde_json::from_slice::<Self>(&bytes) {
Ok(mut index) => {
if index.version != INDEX_VERSION {
tracing::warn!(
expected = INDEX_VERSION,
found = index.version,
"asset index version mismatch, purging cache and rebuilding"
);
purge_cache_blobs(cache_dir);
index = Self::empty();
}
Ok(index)
}
Err(err) => {
tracing::warn!(
?err,
"failed to parse asset index, purging cache and starting fresh"
);
purge_cache_blobs(cache_dir);
Ok(Self::empty())
}
}
}
pub fn save(&self, cache_dir: &Path) -> Result<()> {
std::fs::create_dir_all(cache_dir)?;
let path = cache_dir.join(INDEX_FILENAME);
let bytes = serde_json::to_vec_pretty(self)?;
let mut tmp = tempfile::NamedTempFile::new_in(cache_dir)
.map_err(|e| AssetError::cache_dir(format!("temp file: {e}")))?;
tmp.write_all(&bytes)?;
tmp.flush()?;
tmp.persist(&path)
.map_err(|e| AssetError::cache_dir(format!("persist index: {e}")))?;
Ok(())
}
pub fn upsert(&mut self, asset: CachedAsset) {
self.assets.insert(asset.id.clone(), asset);
}
pub fn remove(&mut self, id: &str) -> Option<CachedAsset> {
self.assets.remove(id)
}
pub fn get(&self, id: &str) -> Option<&CachedAsset> {
self.assets.get(id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut CachedAsset> {
self.assets.get_mut(id)
}
pub fn touch(&mut self, id: &str) -> bool {
if let Some(asset) = self.assets.get_mut(id) {
asset.last_accessed_ms = now_ms();
true
} else {
false
}
}
pub fn total_size(&self) -> u64 {
self.assets.values().map(|a| a.size).sum()
}
pub fn len(&self) -> usize {
self.assets.len()
}
pub fn is_empty(&self) -> bool {
self.assets.is_empty()
}
}
fn purge_cache_blobs(cache_dir: &Path) {
let entries = match std::fs::read_dir(cache_dir) {
Ok(entries) => entries,
Err(e) => {
tracing::warn!(?e, "failed to list cache directory for purge");
return;
}
};
for entry in entries.flatten() {
let path = entry.path();
if path.file_name().is_some_and(|n| n == INDEX_FILENAME) {
continue;
}
let is_real_dir = match std::fs::symlink_metadata(&path) {
Ok(meta) => meta.is_dir(),
Err(e) => {
tracing::warn!(?e, path = ?path, "failed to stat cached entry");
continue;
}
};
let result = if is_real_dir {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
if let Err(e) = result {
tracing::warn!(?e, path = ?path, "failed to purge cached file");
}
}
}
pub fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use devboy_core::asset::AssetContext;
use tempfile::tempdir;
fn make_asset(id: &str, size: u64) -> CachedAsset {
CachedAsset::new(NewCachedAsset {
id: id.into(),
filename: format!("{id}.txt"),
mime_type: Some("text/plain".into()),
size,
local_path: PathBuf::from(format!("files/{id}.txt")),
context: AssetContext::Issue {
key: "DEV-1".into(),
},
checksum_sha256: "abcd".into(),
remote_url: None,
})
}
#[test]
fn upsert_get_remove() {
let mut index = AssetIndex::empty();
index.upsert(make_asset("a1", 10));
index.upsert(make_asset("a2", 20));
assert_eq!(index.len(), 2);
assert_eq!(index.total_size(), 30);
assert_eq!(index.get("a1").unwrap().size, 10);
let removed = index.remove("a1").unwrap();
assert_eq!(removed.id, "a1");
assert_eq!(index.len(), 1);
assert!(index.get("a1").is_none());
}
#[test]
fn touch_updates_last_accessed() {
let mut index = AssetIndex::empty();
index.upsert(make_asset("a1", 10));
let original = index.get("a1").unwrap().last_accessed_ms;
std::thread::sleep(std::time::Duration::from_millis(2));
assert!(index.touch("a1"));
assert!(index.get("a1").unwrap().last_accessed_ms > original);
assert!(!index.touch("missing"));
}
#[test]
fn load_missing_returns_empty() {
let tmp = tempdir().unwrap();
let index = AssetIndex::load(tmp.path()).unwrap();
assert!(index.is_empty());
assert_eq!(index.version, INDEX_VERSION);
}
#[test]
fn save_and_reload_roundtrip() {
let tmp = tempdir().unwrap();
let mut index = AssetIndex::empty();
index.upsert(make_asset("a1", 42));
index.save(tmp.path()).unwrap();
let reloaded = AssetIndex::load(tmp.path()).unwrap();
assert_eq!(reloaded.len(), 1);
assert_eq!(reloaded.get("a1").unwrap().size, 42);
}
#[test]
fn corrupt_index_falls_back_to_empty() {
let tmp = tempdir().unwrap();
std::fs::write(tmp.path().join(INDEX_FILENAME), b"not json").unwrap();
let index = AssetIndex::load(tmp.path()).unwrap();
assert!(index.is_empty(), "corrupt index should fall back to empty");
}
#[test]
fn version_mismatch_falls_back_to_empty() {
let tmp = tempdir().unwrap();
std::fs::write(
tmp.path().join(INDEX_FILENAME),
br#"{"version":999,"assets":{}}"#,
)
.unwrap();
let index = AssetIndex::load(tmp.path()).unwrap();
assert_eq!(index.version, INDEX_VERSION);
assert!(index.is_empty());
}
#[test]
fn save_is_atomic_under_overwrite() {
let tmp = tempdir().unwrap();
let mut index = AssetIndex::empty();
index.upsert(make_asset("a1", 1));
index.save(tmp.path()).unwrap();
index.upsert(make_asset("a2", 2));
index.save(tmp.path()).unwrap();
let reloaded = AssetIndex::load(tmp.path()).unwrap();
assert_eq!(reloaded.len(), 2);
let stragglers: Vec<_> = std::fs::read_dir(tmp.path())
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
let name = e.file_name();
let name = name.to_string_lossy();
name != INDEX_FILENAME
})
.collect();
assert!(stragglers.is_empty(), "unexpected files: {stragglers:?}");
}
}