use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; use chrono::{DateTime, Utc}; use directories::ProjectDirs; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use crate::error::IdxError; use crate::runtime; const CURRENT_SCHEMA_VERSION: u32 = 1; #[derive(Debug, Clone)] pub struct Cache { root: PathBuf, } #[derive(Debug, Serialize, Deserialize)] struct CacheEntry { fetched_at: DateTime, ttl_secs: u64, schema_version: u32, data: T, } #[derive(Debug)] pub struct CacheInfo { pub path: PathBuf, pub files: usize, pub total_size: u64, pub oldest: Option>, pub newest: Option>, } impl Cache { pub fn new() -> Result { Ok(Self { root: cache_dir()? }) } #[cfg(test)] pub fn with_root(root: PathBuf) -> Self { Self { root } } pub fn get( &self, data_type: &str, symbol: &str, ) -> Result, IdxError> { let Some(entry): Option> = self.read_entry(data_type, symbol)? else { return Ok(None); }; if is_expired(entry.fetched_at, entry.ttl_secs) { return Ok(None); } Ok(Some(entry.data)) } pub fn get_stale( &self, data_type: &str, symbol: &str, ) -> Result, IdxError> { Ok(self.read_entry::(data_type, symbol)?.map(|e| e.data)) } /// Like [`Cache::get_stale`], but warns when the entry served is past its TTL. /// Used by `--offline`, which otherwise serves arbitrarily old data silently. pub fn get_offline( &self, data_type: &str, symbol: &str, ) -> Result, IdxError> { let Some(entry) = self.read_entry::(data_type, symbol)? else { return Ok(None); }; if is_expired(entry.fetched_at, entry.ttl_secs) { runtime::warn(format!( "offline: serving expired cache for {symbol} (fetched {})", entry.fetched_at.to_rfc3339() )); } Ok(Some(entry.data)) } pub fn put( &self, data_type: &str, symbol: &str, data: &T, ttl_secs: u64, ) -> Result<(), IdxError> { let path = self.entry_path(data_type, symbol); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| IdxError::Io(e.to_string()))?; } let entry = CacheEntry { fetched_at: Utc::now(), ttl_secs, schema_version: CURRENT_SCHEMA_VERSION, data, }; let raw = serde_json::to_string_pretty(&entry) .map_err(|e| IdxError::ParseError(e.to_string()))?; fs::write(path, raw).map_err(|e| IdxError::Io(e.to_string())) } pub fn info(&self) -> Result { let mut files = 0usize; let mut total_size = 0u64; let mut oldest: Option> = None; let mut newest: Option> = None; if self.root.exists() { Self::walk(&self.root, &mut |p| { if let Ok(meta) = fs::metadata(p) && meta.is_file() { files += 1; total_size += meta.len(); if let Ok(raw) = fs::read_to_string(p) && let Ok(entry) = serde_json::from_str::>(&raw) { oldest = Some(oldest.map_or(entry.fetched_at, |o| o.min(entry.fetched_at))); newest = Some(newest.map_or(entry.fetched_at, |n| n.max(entry.fetched_at))); } } })?; } Ok(CacheInfo { path: self.root.clone(), files, total_size, oldest, newest, }) } pub fn clear(&self) -> Result<(usize, Vec), IdxError> { if !self.root.exists() { return Ok((0, Vec::new())); } let mut removed = 0usize; let mut failed = Vec::new(); Self::walk(&self.root, &mut |p| { if p.is_file() { match fs::remove_file(p) { Ok(_) => removed += 1, Err(_) => failed.push(p.to_path_buf()), } } })?; Ok((removed, failed)) } fn walk(dir: &Path, f: &mut F) -> Result<(), IdxError> { for entry in fs::read_dir(dir).map_err(|e| IdxError::Io(e.to_string()))? { let entry = entry.map_err(|e| IdxError::Io(e.to_string()))?; let path = entry.path(); if path.is_dir() { Self::walk(&path, f)?; } else { f(&path); } } Ok(()) } fn read_entry( &self, data_type: &str, symbol: &str, ) -> Result>, IdxError> { let path = self.entry_path(data_type, symbol); if !path.exists() { return Ok(None); } let raw = match fs::read_to_string(&path) { Ok(s) => s, Err(e) => { if e.kind() == std::io::ErrorKind::NotFound { return Ok(None); } return Err(IdxError::Io(e.to_string())); } }; let entry: CacheEntry = match serde_json::from_str(&raw) { Ok(e) => e, Err(e) => { runtime::warn(format!( "corrupted cache entry for {data_type}/{symbol}, treating as miss: {e}" )); let _ = fs::remove_file(&path); return Ok(None); } }; if entry.schema_version != CURRENT_SCHEMA_VERSION { runtime::info(format!( "cache schema mismatch for {} (got {}, expected {}), treating as miss", path.display(), entry.schema_version, CURRENT_SCHEMA_VERSION )); let _ = fs::remove_file(&path); return Ok(None); } Ok(Some(entry)) } fn entry_path(&self, data_type: &str, symbol: &str) -> PathBuf { self.root.join(data_type).join(format!("{symbol}.json")) } } fn is_expired(fetched_at: DateTime, ttl_secs: u64) -> bool { let ttl = chrono::Duration::from_std(Duration::from_secs(ttl_secs)).unwrap_or(chrono::Duration::MAX); Utc::now().signed_duration_since(fetched_at) >= ttl } pub fn cache_dir() -> Result { if let Ok(dir) = std::env::var("XDG_CACHE_HOME") && !dir.is_empty() { let path = PathBuf::from(dir); if path.is_absolute() { return Ok(path.join("idx")); } } ProjectDirs::from("", "", "idx") .map(|d| d.cache_dir().to_path_buf()) .ok_or_else(|| IdxError::ConfigError("unable to resolve cache dir".to_string())) } #[cfg(test)] mod tests { use std::fs; use std::sync::atomic::{AtomicUsize, Ordering}; use serde::{Deserialize, Serialize}; use super::{Cache, CacheEntry}; static TMP_COUNTER: AtomicUsize = AtomicUsize::new(0); #[derive(Debug, Serialize, Deserialize, PartialEq)] struct T { v: i32, } fn tmp() -> std::path::PathBuf { let suffix = TMP_COUNTER.fetch_add(1, Ordering::Relaxed); let p = std::env::temp_dir().join(format!("idx-cache-test-{}-{suffix}", std::process::id())); let _ = fs::remove_dir_all(&p); fs::create_dir_all(&p).expect("create tmp cache dir"); p } #[test] fn write_read_expire_and_stale() { let root = tmp(); let cache = Cache::with_root(root.clone()); cache .put("quote", "BBCA.JK", &T { v: 7 }, 300) .expect("cache write"); let fresh: Option = cache.get("quote", "BBCA.JK").expect("cache read fresh"); assert_eq!(fresh, Some(T { v: 7 })); let path = root.join("quote/BBCA.JK.json"); let mut entry: CacheEntry = serde_json::from_str(&fs::read_to_string(&path).expect("read cache file")) .expect("parse cache entry"); entry.fetched_at = chrono::Utc::now() - chrono::Duration::seconds(1000); fs::write( &path, serde_json::to_string(&entry).expect("serialize entry"), ) .expect("write old entry"); let expired: Option = cache.get("quote", "BBCA.JK").expect("cache read expired"); assert_eq!(expired, None); let stale: Option = cache .get_stale("quote", "BBCA.JK") .expect("cache read stale"); assert_eq!(stale, Some(T { v: 7 })); } #[test] fn corrupted_cache_entry_returns_none_and_deletes_file() { let root = tmp(); let cache = Cache::with_root(root.clone()); // Write invalid JSON to a cache file let path = root.join("quote/CORRUPT.JK.json"); if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dir"); } fs::write(&path, "this is not valid json {{{").expect("write corrupted cache"); assert!(path.exists(), "corrupted file should exist before get()"); // get() should return Ok(None), not an error let result: Option = cache .get("quote", "CORRUPT.JK") .expect("get should not error"); assert_eq!(result, None, "corrupted entry should be treated as miss"); // The corrupted file should be deleted assert!(!path.exists(), "corrupted file should be deleted"); } #[test] fn corrupted_cache_entry_get_stale_returns_none_and_deletes_file() { let root = tmp(); let cache = Cache::with_root(root.clone()); // Write invalid JSON to a cache file let path = root.join("quote/STALE_CORRUPT.JK.json"); if let Some(parent) = path.parent() { fs::create_dir_all(parent).expect("create parent dir"); } fs::write(&path, "{ not valid json at all").expect("write corrupted cache"); assert!( path.exists(), "corrupted file should exist before get_stale()" ); // get_stale() should return Ok(None), not an error let result: Option = cache .get_stale("quote", "STALE_CORRUPT.JK") .expect("get_stale should not error"); assert_eq!( result, None, "corrupted entry should be treated as miss in get_stale" ); // The corrupted file should be deleted assert!(!path.exists(), "corrupted file should be deleted"); } }