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.
This commit is contained in:
Ciphercat 2026-03-12 17:13:27 +00:00
commit 299d315a16

View file

@ -163,9 +163,28 @@ impl Cache {
if !path.exists() { if !path.exists() {
return Ok(None); return Ok(None);
} }
let raw = fs::read_to_string(&path).map_err(|e| IdxError::Io(e.to_string()))?; let raw = match fs::read_to_string(&path) {
let entry: CacheEntry<T> = Ok(s) => s,
serde_json::from_str(&raw).map_err(|e| IdxError::ParseError(e.to_string()))?; 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<T> = 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 { if entry.schema_version != CURRENT_SCHEMA_VERSION {
eprintln!( eprintln!(
"debug: cache schema mismatch for {} (got {}, expected {})", "debug: cache schema mismatch for {} (got {}, expected {})",
@ -240,4 +259,53 @@ mod tests {
.expect("cache read stale"); .expect("cache read stale");
assert_eq!(stale, Some(T { v: 7 })); 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<T> = 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<T> = 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");
}
} }