mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-08-07 01:33:52 +00:00
feat(cache): add file cache, cache commands, and offline fallback
This commit is contained in:
parent
d2fa081266
commit
c0a3d744b2
3 changed files with 296 additions and 4 deletions
198
src/cache.rs
198
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<T> {
|
||||||
|
fetched_at: DateTime<Utc>,
|
||||||
|
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<DateTime<Utc>>,
|
||||||
|
pub newest: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cache {
|
||||||
|
pub fn new() -> Result<Self, IdxError> {
|
||||||
|
Ok(Self { root: cache_dir()? })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn with_root(root: PathBuf) -> Self {
|
||||||
|
Self { root }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
pub fn get<T: DeserializeOwned>(&self, data_type: &str, symbol: &str) -> Result<Option<T>, IdxError> {
|
||||||
|
let Some(entry): Option<CacheEntry<T>> = 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<T: DeserializeOwned>(&self, data_type: &str, symbol: &str) -> Result<Option<T>, IdxError> {
|
||||||
|
Ok(self.read_entry::<T>(data_type, symbol)?.map(|e| e.data))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put<T: Serialize>(&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<CacheInfo, IdxError> {
|
||||||
|
let mut files = 0usize;
|
||||||
|
let mut total_size = 0u64;
|
||||||
|
let mut oldest: Option<DateTime<Utc>> = None;
|
||||||
|
let mut newest: Option<DateTime<Utc>> = 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::<CacheEntry<serde_json::Value>>(&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, IdxError> {
|
||||||
|
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<F: FnMut(&Path)>(&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<T: DeserializeOwned>(&self, data_type: &str, symbol: &str) -> Result<Option<CacheEntry<T>>, 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<PathBuf, IdxError> {
|
||||||
|
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<T> = 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<T> = 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<T> = cache.get("quote", "BBCA.JK").expect("cache read expired");
|
||||||
|
assert_eq!(expired, None);
|
||||||
|
|
||||||
|
let stale: Option<T> = cache.get_stale("quote", "BBCA.JK").expect("cache read stale");
|
||||||
|
assert_eq!(stale, Some(T { v: 7 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
use clap::{Args, Subcommand};
|
use clap::{Args, Subcommand};
|
||||||
|
|
||||||
|
use crate::cache::Cache;
|
||||||
|
use crate::error::IdxError;
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
pub struct CacheCmd {
|
pub struct CacheCmd {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
|
|
@ -11,3 +14,32 @@ pub enum CacheSubcommand {
|
||||||
Info,
|
Info,
|
||||||
Clear,
|
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(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use clap::{Args, Subcommand};
|
||||||
|
|
||||||
use crate::api::types::{Interval, Period};
|
use crate::api::types::{Interval, Period};
|
||||||
use crate::api::MarketDataProvider;
|
use crate::api::MarketDataProvider;
|
||||||
|
use crate::cache::Cache;
|
||||||
use crate::config::IdxConfig;
|
use crate::config::IdxConfig;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
use crate::output::{render_history, render_quotes};
|
use crate::output::{render_history, render_quotes};
|
||||||
|
|
@ -28,13 +29,48 @@ pub fn handle(
|
||||||
cmd: &StocksCmd,
|
cmd: &StocksCmd,
|
||||||
config: &IdxConfig,
|
config: &IdxConfig,
|
||||||
provider: &dyn MarketDataProvider,
|
provider: &dyn MarketDataProvider,
|
||||||
|
offline: bool,
|
||||||
|
no_cache: bool,
|
||||||
) -> Result<(), IdxError> {
|
) -> Result<(), IdxError> {
|
||||||
|
let cache = Cache::new()?;
|
||||||
|
|
||||||
match &cmd.command {
|
match &cmd.command {
|
||||||
StocksSubcommand::Quote { symbols } => {
|
StocksSubcommand::Quote { symbols } => {
|
||||||
let mut quotes = Vec::new();
|
let mut quotes = Vec::new();
|
||||||
for sym in symbols.iter().flat_map(|s| s.split(',')) {
|
for sym in symbols.iter().flat_map(|s| s.split(',')) {
|
||||||
let resolved = crate::api::resolve_symbol(sym, &config.exchange);
|
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)
|
render_quotes("es, &config.output, config.no_color)
|
||||||
}
|
}
|
||||||
|
|
@ -44,8 +80,36 @@ pub fn handle(
|
||||||
interval,
|
interval,
|
||||||
} => {
|
} => {
|
||||||
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
let resolved = crate::api::resolve_symbol(symbol, &config.exchange);
|
||||||
let history = provider.history(&resolved, period, interval)?;
|
let key = format!("{}-{}", period.as_str(), interval.as_str());
|
||||||
render_history(&resolved, &history, &config.output)
|
if !no_cache
|
||||||
|
&& let Some(history) = cache.get::<Vec<crate::api::types::Ohlc>>("history", &format!("{resolved}-{key}"))?
|
||||||
|
{
|
||||||
|
return render_history(&resolved, &history, &config.output);
|
||||||
|
}
|
||||||
|
if offline {
|
||||||
|
let stale = cache
|
||||||
|
.get_stale::<Vec<crate::api::types::Ohlc>>("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::<Vec<crate::api::types::Ohlc>>("history", &format!("{resolved}-{key}"))?
|
||||||
|
{
|
||||||
|
eprintln!("warning: network failed, serving stale cache for {resolved}");
|
||||||
|
return render_history(&resolved, &stale, &config.output);
|
||||||
|
}
|
||||||
|
Err(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue