use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use rust_decimal::Decimal;
use rustledger_core::NaiveDate;
use serde::{Deserialize, Serialize};
use super::PriceResponse;
const PRUNE_AGE_SECS: u64 = 7 * 24 * 3600;
pub struct PriceCache {
path: PathBuf,
ttl: Duration,
entries: HashMap<String, CachedPrice>,
dirty: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedPrice {
price: String,
currency: String,
date: String,
source: String,
cached_at: u64,
}
impl PriceCache {
pub fn load(ttl_secs: u64) -> Self {
let path = cache_file_path();
let ttl = Duration::from_secs(ttl_secs);
let entries = if path.exists() {
match std::fs::read_to_string(&path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
Err(_) => HashMap::new(),
}
} else {
HashMap::new()
};
Self {
path,
ttl,
entries,
dirty: false,
}
}
pub fn get(&self, key: &str) -> Option<PriceResponse> {
let entry = self.entries.get(key)?;
let is_latest = key.ends_with(":latest");
if is_latest {
let now = now_secs();
if self.ttl.is_zero() || now.saturating_sub(entry.cached_at) > self.ttl.as_secs() {
return None; }
}
let price: Decimal = entry.price.parse().ok()?;
let date = entry.date.parse::<NaiveDate>().ok()?;
Some(PriceResponse {
price,
currency: entry.currency.clone(),
date,
source: entry.source.clone(),
})
}
pub fn insert(&mut self, key: &str, response: &PriceResponse) {
self.entries.insert(
key.to_string(),
CachedPrice {
price: response.price.to_string(),
currency: response.currency.clone(),
date: response.date.to_string(),
source: response.source.clone(),
cached_at: now_secs(),
},
);
self.dirty = true;
}
pub fn save(&mut self) {
if !self.dirty {
return;
}
let now = now_secs();
self.entries
.retain(|_, v| now.saturating_sub(v.cached_at) < PRUNE_AGE_SECS);
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string_pretty(&self.entries)
&& std::fs::write(&self.path, json).is_ok()
{
self.dirty = false;
}
}
pub fn clear(&mut self) {
self.entries.clear();
self.dirty = false;
let _ = std::fs::remove_file(&self.path);
}
}
pub fn cache_key(source: &str, ticker: &str, currency: &str, date: Option<NaiveDate>) -> String {
let date_part = match date {
Some(d) => d.to_string(),
None => "latest".to_string(),
};
format!("{source}:{ticker}:{currency}:{date_part}")
}
fn cache_file_path() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("rledger")
.join("prices.json")
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_key_with_date() {
let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
assert_eq!(
cache_key("yahoo", "AAPL", "USD", Some(date)),
"yahoo:AAPL:USD:2024-01-15"
);
}
#[test]
fn test_cache_key_without_date() {
assert_eq!(
cache_key("yahoo", "AAPL", "USD", None),
"yahoo:AAPL:USD:latest"
);
}
#[test]
fn test_historical_price_never_expires() {
let mut cache = PriceCache {
path: PathBuf::from("/tmp/test-price-cache-hist.json"),
ttl: Duration::from_secs(0), entries: HashMap::new(),
dirty: false,
};
let response = PriceResponse {
price: Decimal::new(15000, 2),
currency: "USD".to_string(),
date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
source: "yahoo".to_string(),
};
cache.insert("yahoo:AAPL:USD:2024-01-15", &response);
assert!(cache.get("yahoo:AAPL:USD:2024-01-15").is_some());
}
#[test]
fn test_insert_and_get() {
let mut cache = PriceCache {
path: PathBuf::from("/tmp/test-price-cache.json"),
ttl: Duration::from_hours(1),
entries: HashMap::new(),
dirty: false,
};
let response = PriceResponse {
price: Decimal::new(15000, 2), currency: "USD".to_string(),
date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
source: "yahoo".to_string(),
};
cache.insert("yahoo:AAPL:USD:latest", &response);
assert!(cache.dirty);
let cached = cache.get("yahoo:AAPL:USD:latest");
assert!(cached.is_some());
let cached = cached.unwrap();
assert_eq!(cached.price, response.price);
assert_eq!(cached.currency, "USD");
assert_eq!(cached.source, "yahoo");
}
#[test]
fn test_get_expired_returns_none() {
let mut cache = PriceCache {
path: PathBuf::from("/tmp/test-price-cache.json"),
ttl: Duration::from_secs(0), entries: HashMap::new(),
dirty: false,
};
let response = PriceResponse {
price: Decimal::new(15000, 2),
currency: "USD".to_string(),
date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
source: "yahoo".to_string(),
};
cache.insert("yahoo:AAPL:USD:latest", &response);
assert!(cache.get("yahoo:AAPL:USD:latest").is_none());
}
#[test]
fn test_get_missing_returns_none() {
let cache = PriceCache {
path: PathBuf::from("/tmp/test-price-cache.json"),
ttl: Duration::from_hours(1),
entries: HashMap::new(),
dirty: false,
};
assert!(cache.get("nonexistent").is_none());
}
#[test]
fn test_save_and_load_round_trip() {
let path = std::env::temp_dir().join("rustledger-test-cache-roundtrip.json");
let _ = std::fs::remove_file(&path);
let response = PriceResponse {
price: Decimal::new(15000, 2),
currency: "USD".to_string(),
date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
source: "yahoo".to_string(),
};
{
let mut cache = PriceCache {
path: path.clone(),
ttl: Duration::from_hours(1),
entries: HashMap::new(),
dirty: false,
};
cache.insert("yahoo:AAPL:USD:latest", &response);
cache.save();
assert!(!cache.dirty, "dirty should be cleared after save");
}
{
let cache = PriceCache {
path: path.clone(),
ttl: Duration::from_hours(1),
entries: serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(),
dirty: false,
};
let cached = cache.get("yahoo:AAPL:USD:latest");
assert!(cached.is_some(), "should find cached entry after load");
assert_eq!(cached.unwrap().price, response.price);
}
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_clear() {
let mut cache = PriceCache {
path: PathBuf::from("/tmp/test-price-cache-clear.json"),
ttl: Duration::from_hours(1),
entries: HashMap::new(),
dirty: false,
};
let response = PriceResponse {
price: Decimal::new(15000, 2),
currency: "USD".to_string(),
date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
source: "yahoo".to_string(),
};
cache.insert("key", &response);
cache.clear();
assert!(cache.entries.is_empty());
assert!(cache.get("key").is_none());
}
}