mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
Merge pull request #34: JSON contract fixes
This commit is contained in:
commit
c86326838d
7 changed files with 131 additions and 18 deletions
|
|
@ -1,4 +1,5 @@
|
|||
#[allow(dead_code, clippy::enum_variant_names)]
|
||||
#[cfg(feature = "ownership")]
|
||||
pub mod bing;
|
||||
mod client;
|
||||
mod map;
|
||||
|
|
|
|||
37
src/cache.rs
37
src/cache.rs
|
|
@ -52,11 +52,7 @@ impl Cache {
|
|||
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()))?
|
||||
{
|
||||
if is_expired(entry.fetched_at, entry.ttl_secs) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(entry.data))
|
||||
|
|
@ -70,6 +66,25 @@ impl Cache {
|
|||
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,
|
||||
|
|
@ -184,12 +199,12 @@ impl Cache {
|
|||
}
|
||||
};
|
||||
if entry.schema_version != CURRENT_SCHEMA_VERSION {
|
||||
eprintln!(
|
||||
"debug: cache schema mismatch for {} (got {}, expected {})",
|
||||
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);
|
||||
}
|
||||
|
|
@ -201,6 +216,12 @@ impl Cache {
|
|||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ use clap::{Args, Subcommand};
|
|||
|
||||
use crate::cache::Cache;
|
||||
use crate::error::IdxError;
|
||||
use crate::output::{OutputFormat, json};
|
||||
use crate::runtime;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
|
|
@ -19,11 +20,20 @@ pub enum CacheSubcommand {
|
|||
Clear,
|
||||
}
|
||||
|
||||
pub fn handle(cmd: &CacheCmd) -> Result<(), IdxError> {
|
||||
pub fn handle(cmd: &CacheCmd, format: &OutputFormat) -> Result<(), IdxError> {
|
||||
let cache = Cache::new()?;
|
||||
match &cmd.command {
|
||||
CacheSubcommand::Info => {
|
||||
let info = cache.info()?;
|
||||
if matches!(format, OutputFormat::Json) {
|
||||
return json::print_json(&serde_json::json!({
|
||||
"path": info.path,
|
||||
"files": info.files,
|
||||
"size_bytes": info.total_size,
|
||||
"oldest": info.oldest,
|
||||
"newest": info.newest,
|
||||
}));
|
||||
}
|
||||
println!("path: {}", info.path.display());
|
||||
println!("files: {}", info.files);
|
||||
println!("size_bytes: {}", info.total_size);
|
||||
|
|
@ -42,6 +52,12 @@ pub fn handle(cmd: &CacheCmd) -> Result<(), IdxError> {
|
|||
}
|
||||
CacheSubcommand::Clear => {
|
||||
let (removed, failed) = cache.clear()?;
|
||||
if matches!(format, OutputFormat::Json) {
|
||||
return json::print_json(&serde_json::json!({
|
||||
"removed": removed,
|
||||
"failed": failed,
|
||||
}));
|
||||
}
|
||||
println!("cleared {removed} files");
|
||||
if !failed.is_empty() {
|
||||
runtime::warn(format!("failed to remove {} file(s)", failed.len()));
|
||||
|
|
|
|||
|
|
@ -719,6 +719,13 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError
|
|||
}
|
||||
ResolveCommand::Map { alias, entity } => {
|
||||
db::manual_map(&conn, alias, entity)?;
|
||||
if matches!(config.output, OutputFormat::Json) {
|
||||
return json::print_json(&serde_json::json!({
|
||||
"action": "mapped",
|
||||
"alias": alias,
|
||||
"entity": entity,
|
||||
}));
|
||||
}
|
||||
println!("Mapped alias '{alias}' -> '{entity}'.");
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -747,6 +754,17 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError
|
|||
|
||||
db::merge_entities(&conn, *keep, *merge)?;
|
||||
|
||||
if matches!(config.output, OutputFormat::Json) {
|
||||
return json::print_json(&serde_json::json!({
|
||||
"action": "merged",
|
||||
"keep": keep,
|
||||
"merge": merge,
|
||||
"alias_updates": alias_updates,
|
||||
"ksei_holdings_updates": ksei_updates,
|
||||
"bing_holdings_updates": bing_updates,
|
||||
}));
|
||||
}
|
||||
|
||||
println!(
|
||||
"Merged entity {merge} into {keep} (aliases: {alias_updates}, ksei_holdings: {ksei_updates}, bing_holdings: {bing_updates})."
|
||||
);
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ pub enum StocksSubcommand {
|
|||
)]
|
||||
Quote {
|
||||
/// One or more symbols, comma-separated or space-separated.
|
||||
#[arg(required = true)]
|
||||
symbols: Vec<String>,
|
||||
},
|
||||
#[command(
|
||||
|
|
@ -231,6 +232,7 @@ pub enum StocksSubcommand {
|
|||
)]
|
||||
Compare {
|
||||
/// One or more symbols, comma-separated or space-separated.
|
||||
#[arg(required = true)]
|
||||
symbols: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
|
@ -263,7 +265,7 @@ pub fn handle(
|
|||
}
|
||||
if offline {
|
||||
let stale = cache
|
||||
.get_stale("e_bucket, &resolved)?
|
||||
.get_offline("e_bucket, &resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("{quote_bucket}/{resolved}")))?;
|
||||
quotes.push(stale);
|
||||
continue;
|
||||
|
|
@ -324,7 +326,7 @@ pub fn handle(
|
|||
}
|
||||
if offline {
|
||||
let stale = cache
|
||||
.get_stale::<Vec<crate::api::types::Ohlc>>(
|
||||
.get_offline::<Vec<crate::api::types::Ohlc>>(
|
||||
&history_bucket,
|
||||
&format!("{resolved}-{key}"),
|
||||
)?
|
||||
|
|
@ -388,7 +390,7 @@ pub fn handle(
|
|||
}
|
||||
if offline {
|
||||
let stale = cache
|
||||
.get_stale::<TechnicalReport>(&technical_bucket, &resolved)?
|
||||
.get_offline::<TechnicalReport>(&technical_bucket, &resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("{technical_bucket}/{resolved}")))?;
|
||||
return render_technical(&stale, &config.output, config.no_color);
|
||||
}
|
||||
|
|
@ -701,7 +703,7 @@ where
|
|||
}
|
||||
|
||||
return cache
|
||||
.get_stale::<T>(cache_spec.bucket, cache_spec.key)?
|
||||
.get_offline::<T>(cache_spec.bucket, cache_spec.key)?
|
||||
.ok_or_else(|| {
|
||||
IdxError::CacheMiss(format!("{}/{}", cache_spec.bucket, cache_spec.key))
|
||||
});
|
||||
|
|
@ -753,7 +755,7 @@ where
|
|||
|
||||
if offline {
|
||||
return cache
|
||||
.get_stale::<T>(&cache_spec.bucket, resolved)?
|
||||
.get_offline::<T>(&cache_spec.bucket, resolved)?
|
||||
.ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.bucket)));
|
||||
}
|
||||
|
||||
|
|
|
|||
13
src/main.rs
13
src/main.rs
|
|
@ -64,9 +64,14 @@ fn run() -> Result<(), IdxError> {
|
|||
};
|
||||
|
||||
match &cli.command {
|
||||
Commands::Version => {
|
||||
println!("{}", env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
Commands::Version => match config.output {
|
||||
OutputFormat::Json => {
|
||||
output::json::print_json(&serde_json::json!({
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
}))?;
|
||||
}
|
||||
OutputFormat::Table => println!("{}", env!("CARGO_PKG_VERSION")),
|
||||
},
|
||||
Commands::Completions { shell } => {
|
||||
let mut cmd = Cli::command();
|
||||
let name = cmd.get_name().to_owned();
|
||||
|
|
@ -97,7 +102,7 @@ fn run() -> Result<(), IdxError> {
|
|||
}
|
||||
}
|
||||
Commands::Cache(cache) => {
|
||||
if let Err(err) = cli::cache::handle(cache) {
|
||||
if let Err(err) = cli::cache::handle(cache, &config.output) {
|
||||
emit_error(&err, &config.output);
|
||||
return Err(err);
|
||||
}
|
||||
|
|
|
|||
50
tests/cli.rs
50
tests/cli.rs
|
|
@ -1084,6 +1084,56 @@ fn serves_stale_cache_on_provider_failure_with_warning() {
|
|||
.stderr(predicate::str::contains("warning: network failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_warns_when_serving_expired_cache() {
|
||||
let root = test_env_dir("offline-expired");
|
||||
|
||||
bin_with_root(&root)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.env("IDX_CACHE_QUOTE_TTL", "0")
|
||||
.args(["stocks", "quote", "BBCA"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
bin_with_root(&root)
|
||||
.env("IDX_USE_MOCK_PROVIDER", "1")
|
||||
.args(["--offline", "stocks", "quote", "BBCA"])
|
||||
.assert()
|
||||
.success()
|
||||
.stderr(predicate::str::contains(
|
||||
"warning: offline: serving expired cache for BBCA.JK",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_and_compare_require_symbols() {
|
||||
test_bin("quote-no-symbols")
|
||||
.args(["stocks", "quote"])
|
||||
.assert()
|
||||
.failure();
|
||||
test_bin("compare-no-symbols")
|
||||
.args(["stocks", "compare"])
|
||||
.assert()
|
||||
.failure();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_and_cache_emit_json_in_json_mode() {
|
||||
let root = test_env_dir("json-meta");
|
||||
|
||||
let version = run_success_stdout(bin_with_root(&root).args(["-o", "json", "version"]));
|
||||
let version: Value = serde_json::from_str(&version).expect("version json");
|
||||
assert_eq!(version["version"], env!("CARGO_PKG_VERSION"));
|
||||
|
||||
let info = run_success_stdout(bin_with_root(&root).args(["-o", "json", "cache", "info"]));
|
||||
let info: Value = serde_json::from_str(&info).expect("cache info json");
|
||||
assert!(info["files"].is_u64());
|
||||
|
||||
let cleared = run_success_stdout(bin_with_root(&root).args(["-o", "json", "cache", "clear"]));
|
||||
let cleared: Value = serde_json::from_str(&cleared).expect("cache clear json");
|
||||
assert!(cleared["removed"].is_u64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_set_and_get_provider_round_trip() {
|
||||
let root = test_env_dir("config-provider");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue