use std::collections::BTreeMap;
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
use directories::ProjectDirs;
use ready_set_sdk::fs as sdk_fs;
use ready_set_sdk::manifest::Manifest;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
const SCHEMA_VERSION: u32 = 1;
const TTL_SECONDS: i64 = 86_400;
const HEAD_BYTES: usize = 4096;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginCache {
pub schema_version: u32,
pub entries: BTreeMap<String, CacheEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry {
pub manifest: Manifest,
pub cached_at: String,
}
impl Default for PluginCache {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
entries: BTreeMap::new(),
}
}
}
impl PluginCache {
#[must_use]
pub fn default_path() -> Option<PathBuf> {
ProjectDirs::from("dev", "ready-set", "ready-set")
.map(|d| d.cache_dir().join("plugins.json"))
}
#[must_use]
pub fn load(path: &Path) -> Self {
let Ok(raw) = std::fs::read(path) else {
return Self::default();
};
let Ok(parsed) = serde_json::from_slice::<Self>(&raw) else {
return Self::default();
};
if parsed.schema_version != SCHEMA_VERSION {
return Self::default();
}
parsed
}
pub fn save(&self, path: &Path) -> ready_set_sdk::Result<()> {
let bytes = serde_json::to_vec(self)?;
sdk_fs::atomic_write(path, &bytes)
}
#[must_use]
pub fn get(&self, key: &CacheKey) -> Option<&Manifest> {
let entry = self.entries.get(&key.encode())?;
let cached = OffsetDateTime::parse(&entry.cached_at, &Rfc3339).ok()?;
let age = (OffsetDateTime::now_utc() - cached).whole_seconds();
if (0..TTL_SECONDS).contains(&age) {
Some(&entry.manifest)
} else {
None
}
}
pub fn insert(&mut self, key: &CacheKey, manifest: Manifest) -> ready_set_sdk::Result<()> {
let cached_at = OffsetDateTime::now_utc()
.format(&Rfc3339)
.map_err(|e| ready_set_sdk::Error::other(format!("rfc3339 format: {e}")))?;
self.entries.insert(
key.encode(),
CacheEntry {
manifest,
cached_at,
},
);
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CacheKey {
pub canonical_path: PathBuf,
pub size_bytes: u64,
pub head4k_sha256: String,
}
impl CacheKey {
pub fn for_binary(path: &Path) -> ready_set_sdk::Result<Self> {
let canonical_path = std::fs::canonicalize(path)?;
let metadata = std::fs::metadata(&canonical_path)?;
let size_bytes = metadata.len();
let mut f = File::open(&canonical_path)?;
let mut buf = vec![0_u8; HEAD_BYTES];
let read = f.read(&mut buf)?;
let mut hasher = Sha256::new();
hasher.update(&buf[..read]);
let head4k_sha256 = encode_hex_lower(&hasher.finalize());
Ok(Self {
canonical_path,
size_bytes,
head4k_sha256,
})
}
fn encode(&self) -> String {
format!(
"{}:{}:{}",
self.canonical_path.display(),
self.size_bytes,
self.head4k_sha256
)
}
}
fn encode_hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use ready_set_sdk::describe::{Platform, Stability};
fn fixture_manifest() -> Manifest {
Manifest {
description: "x".into(),
version: "0.1.0".parse().unwrap(),
stability: Stability::Stable,
min_dispatcher_version: "0.1.0".parse().unwrap(),
platforms: vec![Platform::Linux],
requires_cargo_workspace: false,
capabilities: Vec::new(),
}
}
#[test]
fn schema_mismatch_yields_empty_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plugins.json");
std::fs::write(&path, br#"{"schema_version":99,"entries":{}}"#).unwrap();
let loaded = PluginCache::load(&path);
assert_eq!(loaded.schema_version, SCHEMA_VERSION);
assert!(loaded.entries.is_empty());
}
#[test]
fn corrupt_file_yields_empty_cache() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plugins.json");
std::fs::write(&path, b"not json").unwrap();
let loaded = PluginCache::load(&path);
assert!(loaded.entries.is_empty());
}
#[test]
fn round_trip_save_and_load() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plugins.json");
let bin = dir.path().join("ready-set-foo");
std::fs::write(&bin, b"#!/bin/sh\necho hi\n").unwrap();
let key = CacheKey::for_binary(&bin).unwrap();
let mut cache = PluginCache::default();
cache.insert(&key, fixture_manifest()).unwrap();
cache.save(&path).unwrap();
let loaded = PluginCache::load(&path);
assert!(loaded.get(&key).is_some());
}
}