From c0a3d744b295fafdafa5d01f89358f62c07831a4 Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:28:32 +0000 Subject: [PATCH] feat(cache): add file cache, cache commands, and offline fallback --- src/cache.rs | 198 +++++++++++++++++++++++++++++++++++++++++++++- src/cli/cache.rs | 32 ++++++++ src/cli/stocks.rs | 70 +++++++++++++++- 3 files changed, 296 insertions(+), 4 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 6df817e..5c4c0a2 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -1 +1,197 @@ -// Cache module placeholder for MVP foundation. +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; + +const 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); + }; + let age = Utc::now().signed_duration_since(entry.fetched_at); + if age > chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs)).map_err(|e| IdxError::CacheMiss(e.to_string()))? { + 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)) + } + + 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: 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 { + if !self.root.exists() { + return Ok(0); + } + let mut removed = 0usize; + self.walk(&self.root, &mut |p| { + if p.is_file() && fs::remove_file(p).is_ok() { + removed += 1; + } + })?; + Ok(removed) + } + + fn walk(&self, 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 = fs::read_to_string(path).map_err(|e| IdxError::Io(e.to_string()))?; + let entry = serde_json::from_str(&raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + Ok(Some(entry)) + } + + fn entry_path(&self, data_type: &str, symbol: &str) -> PathBuf { + self.root.join(data_type).join(format!("{symbol}.json")) + } +} + +pub fn cache_dir() -> Result { + 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 serde::{Deserialize, Serialize}; + + use super::{Cache, CacheEntry}; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct T { + v: i32, + } + + fn tmp() -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("idx-cache-test-{}", 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 })); + } +} diff --git a/src/cli/cache.rs b/src/cli/cache.rs index cadcbd5..f23df65 100644 --- a/src/cli/cache.rs +++ b/src/cli/cache.rs @@ -1,5 +1,8 @@ use clap::{Args, Subcommand}; +use crate::cache::Cache; +use crate::error::IdxError; + #[derive(Debug, Args)] pub struct CacheCmd { #[command(subcommand)] @@ -11,3 +14,32 @@ pub enum CacheSubcommand { Info, Clear, } + +pub fn handle(cmd: &CacheCmd) -> Result<(), IdxError> { + let cache = Cache::new()?; + match &cmd.command { + CacheSubcommand::Info => { + let info = cache.info()?; + println!("path: {}", info.path.display()); + println!("files: {}", info.files); + println!("size_bytes: {}", info.total_size); + println!( + "oldest: {}", + info.oldest + .map(|v| v.to_rfc3339()) + .unwrap_or_else(|| "-".to_string()) + ); + println!( + "newest: {}", + info.newest + .map(|v| v.to_rfc3339()) + .unwrap_or_else(|| "-".to_string()) + ); + } + CacheSubcommand::Clear => { + let removed = cache.clear()?; + println!("cleared {removed} files"); + } + } + Ok(()) +} diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 0ef2ff3..3652aed 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -2,6 +2,7 @@ use clap::{Args, Subcommand}; use crate::api::types::{Interval, Period}; use crate::api::MarketDataProvider; +use crate::cache::Cache; use crate::config::IdxConfig; use crate::error::IdxError; use crate::output::{render_history, render_quotes}; @@ -28,13 +29,48 @@ pub fn handle( cmd: &StocksCmd, config: &IdxConfig, provider: &dyn MarketDataProvider, + offline: bool, + no_cache: bool, ) -> Result<(), IdxError> { + let cache = Cache::new()?; + match &cmd.command { StocksSubcommand::Quote { symbols } => { let mut quotes = Vec::new(); for sym in symbols.iter().flat_map(|s| s.split(',')) { let resolved = crate::api::resolve_symbol(sym, &config.exchange); - quotes.push(provider.quote(&resolved)?); + if !no_cache + && let Some(q) = cache.get("quote", &resolved)? + { + quotes.push(q); + continue; + } + if offline { + let stale = cache + .get_stale("quote", &resolved)? + .ok_or_else(|| IdxError::CacheMiss(format!("quote/{resolved}")))?; + quotes.push(stale); + continue; + } + + match provider.quote(&resolved) { + Ok(q) => { + if !no_cache { + cache.put("quote", &resolved, &q, config.quote_ttl)?; + } + quotes.push(q); + } + Err(err) => { + if !no_cache + && let Some(stale) = cache.get_stale("quote", &resolved)? + { + eprintln!("warning: network failed, serving stale cache for {resolved}"); + quotes.push(stale); + continue; + } + return Err(err); + } + } } render_quotes("es, &config.output, config.no_color) } @@ -44,8 +80,36 @@ pub fn handle( interval, } => { let resolved = crate::api::resolve_symbol(symbol, &config.exchange); - let history = provider.history(&resolved, period, interval)?; - render_history(&resolved, &history, &config.output) + let key = format!("{}-{}", period.as_str(), interval.as_str()); + if !no_cache + && let Some(history) = cache.get::>("history", &format!("{resolved}-{key}"))? + { + return render_history(&resolved, &history, &config.output); + } + if offline { + let stale = cache + .get_stale::>("history", &format!("{resolved}-{key}"))? + .ok_or_else(|| IdxError::CacheMiss(format!("history/{resolved}-{key}")))?; + return render_history(&resolved, &stale, &config.output); + } + + match provider.history(&resolved, period, interval) { + Ok(history) => { + if !no_cache { + cache.put("history", &format!("{resolved}-{key}"), &history, config.quote_ttl)?; + } + render_history(&resolved, &history, &config.output) + } + Err(err) => { + if !no_cache + && let Some(stale) = cache.get_stale::>("history", &format!("{resolved}-{key}"))? + { + eprintln!("warning: network failed, serving stale cache for {resolved}"); + return render_history(&resolved, &stale, &config.output); + } + Err(err) + } + } } } }