use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::api::dto::Tier;
use crate::core::config::{atomic_write, scan_cache_path};
use crate::core::error::{SsError, ERR_STATE_CORRUPT};
const MAX_AGE_DAYS: i64 = 90;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedScan {
pub content_hash: String,
pub kind: String,
pub name: String,
pub catalog_slug: String,
pub score: u8,
pub tier: Tier,
pub scanned_at: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub report_url: Option<String>,
}
pub fn load() -> Result<Vec<CachedScan>, SsError> {
load_from(&scan_cache_path())
}
fn load_from(path: &Path) -> Result<Vec<CachedScan>, SsError> {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => {
return Err(SsError::new(
ERR_STATE_CORRUPT,
format!("Failed to read {}: {e}", path.display()),
))
}
};
if raw.trim().is_empty() {
return Ok(Vec::new());
}
serde_json::from_str(&raw).map_err(|e| {
SsError::new(
ERR_STATE_CORRUPT,
format!("scan_cache.json is corrupt: {e}"),
)
.with_suggestion(
"Delete ~/.saferskills/scan_cache.json — it is rebuilt by `saferskills capability`.",
)
})
}
pub fn save(entries: &[CachedScan]) -> Result<(), SsError> {
save_to(&scan_cache_path(), entries)
}
fn save_to(path: &Path, entries: &[CachedScan]) -> Result<(), SsError> {
let json = serde_json::to_vec_pretty(entries).map_err(|e| {
SsError::new(
ERR_STATE_CORRUPT,
format!("Failed to serialize scan cache: {e}"),
)
})?;
atomic_write(path, &json)
}
pub fn upsert(entries: Vec<CachedScan>) -> Result<(), SsError> {
let merged = merge_entries(load()?, entries, chrono::Utc::now());
save(&merged)
}
fn merge_entries(
existing: Vec<CachedScan>,
new: Vec<CachedScan>,
now: chrono::DateTime<chrono::Utc>,
) -> Vec<CachedScan> {
let mut by_hash: HashMap<String, CachedScan> = HashMap::new();
for e in existing.into_iter().chain(new) {
match by_hash.get(&e.content_hash) {
Some(prev) if prev.scanned_at > e.scanned_at => {}
_ => {
by_hash.insert(e.content_hash.clone(), e);
}
}
}
let cutoff = now - chrono::Duration::days(MAX_AGE_DAYS);
let mut kept: Vec<CachedScan> = by_hash
.into_values()
.filter(|e| e.scanned_at >= cutoff)
.collect();
kept.sort_by(|a, b| a.content_hash.cmp(&b.content_hash));
kept
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(hash: &str, score: u8, age_days: i64) -> CachedScan {
CachedScan {
content_hash: hash.into(),
kind: "skill".into(),
name: "demo".into(),
catalog_slug: "upload--abcd1234--skill-demo".into(),
score,
tier: Tier::Green,
scanned_at: chrono::DateTime::from_timestamp(0, 0).unwrap()
+ chrono::Duration::days(age_days),
report_url: None,
}
}
#[test]
fn load_absent_is_empty() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("scan_cache.json");
assert!(load_from(&path).unwrap().is_empty());
}
#[test]
fn save_then_load_roundtrips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("scan_cache.json");
let want = vec![entry("a", 91, 0)];
save_to(&path, &want).unwrap();
let got = load_from(&path).unwrap();
assert_eq!(got.len(), 1);
assert_eq!(got[0].content_hash, "a");
assert_eq!(got[0].score, 91);
}
#[test]
fn corrupt_cache_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("scan_cache.json");
std::fs::write(&path, b"{not json").unwrap();
let err = load_from(&path).unwrap_err();
assert_eq!(err.code, ERR_STATE_CORRUPT);
}
#[test]
fn merge_keeps_newest_per_hash() {
let now = chrono::DateTime::from_timestamp(0, 0).unwrap() + chrono::Duration::days(10);
let merged = merge_entries(vec![entry("h", 40, 1)], vec![entry("h", 88, 5)], now);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].score, 88);
}
#[test]
fn merge_drops_aged_out_entries() {
let now = chrono::DateTime::from_timestamp(0, 0).unwrap() + chrono::Duration::days(200);
let merged = merge_entries(
vec![entry("old", 50, 0), entry("fresh", 70, 150)],
vec![],
now,
);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].content_hash, "fresh");
}
#[test]
fn merge_is_sorted_by_hash() {
let now = chrono::DateTime::from_timestamp(0, 0).unwrap() + chrono::Duration::days(1);
let merged = merge_entries(
vec![entry("c", 1, 0), entry("a", 2, 0), entry("b", 3, 0)],
vec![],
now,
);
let hashes: Vec<&str> = merged.iter().map(|e| e.content_hash.as_str()).collect();
assert_eq!(hashes, vec!["a", "b", "c"]);
}
}