mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
refactor: split into idx-core library and idx-cli binary workspace
Phase 2 of docs/DESIGN.md, no behavior change: - crates/idx-core (library): api, analysis, cache, config file handling and provider selection, curl_impersonate, error, ownership, runtime, plus the shared test fixtures. clap derives are behind an optional `clap` feature so the library does not require clap. - crates/idx-cli (binary `idx`): cli/, output/, and IdxConfig (output format and color resolution). Package and binary names are unchanged. - config.rs is split: file read/write and validation move to core; a CLI test keeps core's OUTPUT_VALUES in sync with OutputSetting. - ownership::db::open_db/db_path drop their unused IdxConfig argument. - One workspace version; shared dependency versions; no third-party version changes in Cargo.lock. LICENSE is symlinked into each crate. - flake: workspace version, new source paths, and a per-crate package check (idx-cli is built from its tarball against packaged idx-core, since idx-core is not on crates.io yet). CI cache key, release path filter, and the golden whitespace-hook exclusion follow the new layout. Parity: all 143 golden outputs match byte for byte without regeneration; 157 core + 17 CLI unit tests (173 before plus the new OUTPUT_VALUES sync test), 62 CLI tests pass. Live A/B against main: 16/16 stocks cases identical; ownership discover got IDX 403 on both (exit 4 vs 1 is the phase 1 exit-code change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
22b6dabaeb
commit
15947de51a
238 changed files with 544 additions and 375 deletions
348
src/cache.rs
348
src/cache.rs
|
|
@ -1,348 +0,0 @@
|
|||
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;
|
||||
use crate::runtime;
|
||||
|
||||
const CURRENT_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);
|
||||
};
|
||||
if is_expired(entry.fetched_at, entry.ttl_secs) {
|
||||
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))
|
||||
}
|
||||
|
||||
/// Like [`Cache::get_stale`], but warns when the entry served is past its TTL.
|
||||
/// Used by `--offline`, which otherwise serves arbitrarily old data silently.
|
||||
pub fn get_offline<T: DeserializeOwned>(
|
||||
&self,
|
||||
data_type: &str,
|
||||
symbol: &str,
|
||||
) -> Result<Option<T>, IdxError> {
|
||||
let Some(entry) = self.read_entry::<T>(data_type, symbol)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if is_expired(entry.fetched_at, entry.ttl_secs) {
|
||||
runtime::warn(format!(
|
||||
"offline: serving expired cache for {symbol} (fetched {})",
|
||||
entry.fetched_at.to_rfc3339()
|
||||
));
|
||||
}
|
||||
Ok(Some(entry.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: CURRENT_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, Vec<PathBuf>), IdxError> {
|
||||
if !self.root.exists() {
|
||||
return Ok((0, Vec::new()));
|
||||
}
|
||||
let mut removed = 0usize;
|
||||
let mut failed = Vec::new();
|
||||
Self::walk(&self.root, &mut |p| {
|
||||
if p.is_file() {
|
||||
match fs::remove_file(p) {
|
||||
Ok(_) => removed += 1,
|
||||
Err(_) => failed.push(p.to_path_buf()),
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok((removed, failed))
|
||||
}
|
||||
|
||||
fn walk<F: FnMut(&Path)>(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 = match fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(IdxError::Io(e.to_string()));
|
||||
}
|
||||
};
|
||||
let entry: CacheEntry<T> = match serde_json::from_str(&raw) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
runtime::warn(format!(
|
||||
"corrupted cache entry for {data_type}/{symbol}, treating as miss: {e}"
|
||||
));
|
||||
let _ = fs::remove_file(&path);
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
if entry.schema_version != CURRENT_SCHEMA_VERSION {
|
||||
runtime::info(format!(
|
||||
"cache schema mismatch for {} (got {}, expected {}), treating as miss",
|
||||
path.display(),
|
||||
entry.schema_version,
|
||||
CURRENT_SCHEMA_VERSION
|
||||
));
|
||||
let _ = fs::remove_file(&path);
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(entry))
|
||||
}
|
||||
|
||||
fn entry_path(&self, data_type: &str, symbol: &str) -> PathBuf {
|
||||
self.root.join(data_type).join(format!("{symbol}.json"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(fetched_at: DateTime<Utc>, ttl_secs: u64) -> bool {
|
||||
let ttl =
|
||||
chrono::Duration::from_std(Duration::from_secs(ttl_secs)).unwrap_or(chrono::Duration::MAX);
|
||||
Utc::now().signed_duration_since(fetched_at) >= ttl
|
||||
}
|
||||
|
||||
pub fn cache_dir() -> Result<PathBuf, IdxError> {
|
||||
if let Ok(dir) = std::env::var("XDG_CACHE_HOME")
|
||||
&& !dir.is_empty()
|
||||
{
|
||||
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())
|
||||
.ok_or_else(|| IdxError::ConfigError("unable to resolve cache dir".to_string()))
|
||||
}
|
||||
|
||||
#[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 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
|
||||
}
|
||||
|
||||
#[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 }));
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue