From 299d315a16a91af098e29772115cf815096295a9 Mon Sep 17 00:00:00 2001 From: Ciphercat <78522797+0xrsydn@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:13:27 +0000 Subject: [PATCH 1/4] fix(cache): treat corrupted cache entries as cache misses Issue #9: Previously, corrupted JSON in cache files would cause commands to fail with a ParseError. Now: - IO errors and parse errors are caught in read_entry() - A warning is printed to stderr with the data_type/symbol - The corrupted file is deleted - Ok(None) is returned (treated as cache miss) This applies to both get() and get_stale() since they share read_entry(). Added tests: - corrupted_cache_entry_returns_none_and_deletes_file - corrupted_cache_entry_get_stale_returns_none_and_deletes_file Both verify the corrupted file is cleaned up after the graceful failure. --- src/cache.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 59432bf..737cf9b 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -163,9 +163,28 @@ impl Cache { if !path.exists() { return Ok(None); } - let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?; - let entry: CacheEntry = - serde_json::from_str(&raw).map_err(|e| IdxError::ParseError(e.to_string()))?; + let raw = match fs::read_to_string(&path) { + Ok(s) => s, + Err(e) => { + eprintln!( + "warning: corrupted cache entry for {}/{}, treating as miss: {}", + data_type, symbol, e + ); + let _ = fs::remove_file(&path); + return Ok(None); + } + }; + let entry: CacheEntry = match serde_json::from_str(&raw) { + Ok(e) => e, + Err(e) => { + eprintln!( + "warning: corrupted cache entry for {}/{}, treating as miss: {}", + data_type, symbol, e + ); + let _ = fs::remove_file(&path); + return Ok(None); + } + }; if entry.schema_version != CURRENT_SCHEMA_VERSION { eprintln!( "debug: cache schema mismatch for {} (got {}, expected {})", @@ -240,4 +259,53 @@ mod tests { .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"); + } } From e20b41642f95b79ec798f7e616fb12978cc0d143 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 17 Mar 2026 08:52:00 +0700 Subject: [PATCH 2/4] style: format cache tests and fix clippy import --- src/api/msn/parse.rs | 1 - src/cache.rs | 9 +++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/api/msn/parse.rs b/src/api/msn/parse.rs index 321c6d7..0a7f19b 100644 --- a/src/api/msn/parse.rs +++ b/src/api/msn/parse.rs @@ -30,7 +30,6 @@ pub(crate) fn parse_fundamentals_from_str( #[cfg(test)] mod tests { use super::{parse_fundamentals_from_str, parse_quote_from_str}; - use crate::api::types::Period; #[test] fn parses_quote_fixture_json() { diff --git a/src/cache.rs b/src/cache.rs index 737cf9b..db35df6 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -275,7 +275,9 @@ mod tests { 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"); + 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 @@ -294,7 +296,10 @@ mod tests { } fs::write(&path, "{ not valid json at all").expect("write corrupted cache"); - assert!(path.exists(), "corrupted file should exist before get_stale()"); + assert!( + path.exists(), + "corrupted file should exist before get_stale()" + ); // get_stale() should return Ok(None), not an error let result: Option = cache From 018b8aeb55f7e6f8ad8b2fda17dd918f531d2978 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 17 Mar 2026 08:52:11 +0700 Subject: [PATCH 3/4] fix: stabilize cache behavior and local xdg paths --- src/cache.rs | 14 ++++++++++++-- src/config.rs | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index db35df6..2dd19e0 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -53,7 +53,7 @@ impl Cache { }; let age = Utc::now().signed_duration_since(entry.fetched_at); if age - > chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs)) + >= chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs)) .map_err(|e| IdxError::CacheMiss(e.to_string()))? { return Ok(None); @@ -204,6 +204,11 @@ impl Cache { } pub fn cache_dir() -> Result { + if let Ok(dir) = std::env::var("XDG_CACHE_HOME") + && !dir.is_empty() + { + return Ok(PathBuf::from(dir).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())) @@ -212,18 +217,23 @@ pub fn cache_dir() -> Result { #[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 p = std::env::temp_dir().join(format!("idx-cache-test-{}", std::process::id())); + 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 diff --git a/src/config.rs b/src/config.rs index 6339d49..75b029a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -197,6 +197,11 @@ pub fn default_config_toml() -> String { } pub fn config_path() -> Result { + if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") + && !dir.is_empty() + { + return Ok(PathBuf::from(dir).join("idx").join("config.toml")); + } ProjectDirs::from("", "", "idx") .map(|d| d.config_dir().join("config.toml")) .ok_or_else(|| IdxError::ConfigError("unable to resolve config dir".to_string())) From 3533670d924f0412723e8f8a8e7d87c3b9c7f880 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Tue, 17 Mar 2026 09:02:06 +0700 Subject: [PATCH 4/4] fix: narrow cache miss recovery behavior --- src/cache.rs | 15 ++++++++------- src/config.rs | 5 ++++- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/cache.rs b/src/cache.rs index 2dd19e0..875ff6a 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -166,12 +166,10 @@ impl Cache { let raw = match fs::read_to_string(&path) { Ok(s) => s, Err(e) => { - eprintln!( - "warning: corrupted cache entry for {}/{}, treating as miss: {}", - data_type, symbol, e - ); - let _ = fs::remove_file(&path); - return Ok(None); + 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) { @@ -207,7 +205,10 @@ pub fn cache_dir() -> Result { if let Ok(dir) = std::env::var("XDG_CACHE_HOME") && !dir.is_empty() { - return Ok(PathBuf::from(dir).join("idx")); + 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()) diff --git a/src/config.rs b/src/config.rs index 75b029a..0784c96 100644 --- a/src/config.rs +++ b/src/config.rs @@ -200,7 +200,10 @@ pub fn config_path() -> Result { if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") && !dir.is_empty() { - return Ok(PathBuf::from(dir).join("idx").join("config.toml")); + let path = PathBuf::from(dir); + if path.is_absolute() { + return Ok(path.join("idx").join("config.toml")); + } } ProjectDirs::from("", "", "idx") .map(|d| d.config_dir().join("config.toml"))