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)]
|
#[allow(dead_code, clippy::enum_variant_names)]
|
||||||
|
#[cfg(feature = "ownership")]
|
||||||
pub mod bing;
|
pub mod bing;
|
||||||
mod client;
|
mod client;
|
||||||
mod map;
|
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 {
|
let Some(entry): Option<CacheEntry<T>> = self.read_entry(data_type, symbol)? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let age = Utc::now().signed_duration_since(entry.fetched_at);
|
if is_expired(entry.fetched_at, entry.ttl_secs) {
|
||||||
if age
|
|
||||||
>= chrono::Duration::from_std(Duration::from_secs(entry.ttl_secs))
|
|
||||||
.map_err(|e| IdxError::CacheMiss(e.to_string()))?
|
|
||||||
{
|
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
Ok(Some(entry.data))
|
Ok(Some(entry.data))
|
||||||
|
|
@ -70,6 +66,25 @@ impl Cache {
|
||||||
Ok(self.read_entry::<T>(data_type, symbol)?.map(|e| e.data))
|
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>(
|
pub fn put<T: Serialize>(
|
||||||
&self,
|
&self,
|
||||||
data_type: &str,
|
data_type: &str,
|
||||||
|
|
@ -184,12 +199,12 @@ impl Cache {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if entry.schema_version != CURRENT_SCHEMA_VERSION {
|
if entry.schema_version != CURRENT_SCHEMA_VERSION {
|
||||||
eprintln!(
|
runtime::info(format!(
|
||||||
"debug: cache schema mismatch for {} (got {}, expected {})",
|
"cache schema mismatch for {} (got {}, expected {}), treating as miss",
|
||||||
path.display(),
|
path.display(),
|
||||||
entry.schema_version,
|
entry.schema_version,
|
||||||
CURRENT_SCHEMA_VERSION
|
CURRENT_SCHEMA_VERSION
|
||||||
);
|
));
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
return Ok(None);
|
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> {
|
pub fn cache_dir() -> Result<PathBuf, IdxError> {
|
||||||
if let Ok(dir) = std::env::var("XDG_CACHE_HOME")
|
if let Ok(dir) = std::env::var("XDG_CACHE_HOME")
|
||||||
&& !dir.is_empty()
|
&& !dir.is_empty()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ use clap::{Args, Subcommand};
|
||||||
|
|
||||||
use crate::cache::Cache;
|
use crate::cache::Cache;
|
||||||
use crate::error::IdxError;
|
use crate::error::IdxError;
|
||||||
|
use crate::output::{OutputFormat, json};
|
||||||
use crate::runtime;
|
use crate::runtime;
|
||||||
|
|
||||||
#[derive(Debug, Args)]
|
#[derive(Debug, Args)]
|
||||||
|
|
@ -19,11 +20,20 @@ pub enum CacheSubcommand {
|
||||||
Clear,
|
Clear,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle(cmd: &CacheCmd) -> Result<(), IdxError> {
|
pub fn handle(cmd: &CacheCmd, format: &OutputFormat) -> Result<(), IdxError> {
|
||||||
let cache = Cache::new()?;
|
let cache = Cache::new()?;
|
||||||
match &cmd.command {
|
match &cmd.command {
|
||||||
CacheSubcommand::Info => {
|
CacheSubcommand::Info => {
|
||||||
let info = cache.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!("path: {}", info.path.display());
|
||||||
println!("files: {}", info.files);
|
println!("files: {}", info.files);
|
||||||
println!("size_bytes: {}", info.total_size);
|
println!("size_bytes: {}", info.total_size);
|
||||||
|
|
@ -42,6 +52,12 @@ pub fn handle(cmd: &CacheCmd) -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
CacheSubcommand::Clear => {
|
CacheSubcommand::Clear => {
|
||||||
let (removed, failed) = cache.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");
|
println!("cleared {removed} files");
|
||||||
if !failed.is_empty() {
|
if !failed.is_empty() {
|
||||||
runtime::warn(format!("failed to remove {} file(s)", failed.len()));
|
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 } => {
|
ResolveCommand::Map { alias, entity } => {
|
||||||
db::manual_map(&conn, 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}'.");
|
println!("Mapped alias '{alias}' -> '{entity}'.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -747,6 +754,17 @@ fn handle_resolve(args: &ResolveArgs, config: &IdxConfig) -> Result<(), IdxError
|
||||||
|
|
||||||
db::merge_entities(&conn, *keep, *merge)?;
|
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!(
|
println!(
|
||||||
"Merged entity {merge} into {keep} (aliases: {alias_updates}, ksei_holdings: {ksei_updates}, bing_holdings: {bing_updates})."
|
"Merged entity {merge} into {keep} (aliases: {alias_updates}, ksei_holdings: {ksei_updates}, bing_holdings: {bing_updates})."
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,7 @@ pub enum StocksSubcommand {
|
||||||
)]
|
)]
|
||||||
Quote {
|
Quote {
|
||||||
/// One or more symbols, comma-separated or space-separated.
|
/// One or more symbols, comma-separated or space-separated.
|
||||||
|
#[arg(required = true)]
|
||||||
symbols: Vec<String>,
|
symbols: Vec<String>,
|
||||||
},
|
},
|
||||||
#[command(
|
#[command(
|
||||||
|
|
@ -231,6 +232,7 @@ pub enum StocksSubcommand {
|
||||||
)]
|
)]
|
||||||
Compare {
|
Compare {
|
||||||
/// One or more symbols, comma-separated or space-separated.
|
/// One or more symbols, comma-separated or space-separated.
|
||||||
|
#[arg(required = true)]
|
||||||
symbols: Vec<String>,
|
symbols: Vec<String>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -263,7 +265,7 @@ pub fn handle(
|
||||||
}
|
}
|
||||||
if offline {
|
if offline {
|
||||||
let stale = cache
|
let stale = cache
|
||||||
.get_stale("e_bucket, &resolved)?
|
.get_offline("e_bucket, &resolved)?
|
||||||
.ok_or_else(|| IdxError::CacheMiss(format!("{quote_bucket}/{resolved}")))?;
|
.ok_or_else(|| IdxError::CacheMiss(format!("{quote_bucket}/{resolved}")))?;
|
||||||
quotes.push(stale);
|
quotes.push(stale);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -324,7 +326,7 @@ pub fn handle(
|
||||||
}
|
}
|
||||||
if offline {
|
if offline {
|
||||||
let stale = cache
|
let stale = cache
|
||||||
.get_stale::<Vec<crate::api::types::Ohlc>>(
|
.get_offline::<Vec<crate::api::types::Ohlc>>(
|
||||||
&history_bucket,
|
&history_bucket,
|
||||||
&format!("{resolved}-{key}"),
|
&format!("{resolved}-{key}"),
|
||||||
)?
|
)?
|
||||||
|
|
@ -388,7 +390,7 @@ pub fn handle(
|
||||||
}
|
}
|
||||||
if offline {
|
if offline {
|
||||||
let stale = cache
|
let stale = cache
|
||||||
.get_stale::<TechnicalReport>(&technical_bucket, &resolved)?
|
.get_offline::<TechnicalReport>(&technical_bucket, &resolved)?
|
||||||
.ok_or_else(|| IdxError::CacheMiss(format!("{technical_bucket}/{resolved}")))?;
|
.ok_or_else(|| IdxError::CacheMiss(format!("{technical_bucket}/{resolved}")))?;
|
||||||
return render_technical(&stale, &config.output, config.no_color);
|
return render_technical(&stale, &config.output, config.no_color);
|
||||||
}
|
}
|
||||||
|
|
@ -701,7 +703,7 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
return cache
|
return cache
|
||||||
.get_stale::<T>(cache_spec.bucket, cache_spec.key)?
|
.get_offline::<T>(cache_spec.bucket, cache_spec.key)?
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
IdxError::CacheMiss(format!("{}/{}", cache_spec.bucket, cache_spec.key))
|
IdxError::CacheMiss(format!("{}/{}", cache_spec.bucket, cache_spec.key))
|
||||||
});
|
});
|
||||||
|
|
@ -753,7 +755,7 @@ where
|
||||||
|
|
||||||
if offline {
|
if offline {
|
||||||
return cache
|
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)));
|
.ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.bucket)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
11
src/main.rs
11
src/main.rs
|
|
@ -64,9 +64,14 @@ fn run() -> Result<(), IdxError> {
|
||||||
};
|
};
|
||||||
|
|
||||||
match &cli.command {
|
match &cli.command {
|
||||||
Commands::Version => {
|
Commands::Version => match config.output {
|
||||||
println!("{}", env!("CARGO_PKG_VERSION"));
|
OutputFormat::Json => {
|
||||||
|
output::json::print_json(&serde_json::json!({
|
||||||
|
"version": env!("CARGO_PKG_VERSION")
|
||||||
|
}))?;
|
||||||
}
|
}
|
||||||
|
OutputFormat::Table => println!("{}", env!("CARGO_PKG_VERSION")),
|
||||||
|
},
|
||||||
Commands::Completions { shell } => {
|
Commands::Completions { shell } => {
|
||||||
let mut cmd = Cli::command();
|
let mut cmd = Cli::command();
|
||||||
let name = cmd.get_name().to_owned();
|
let name = cmd.get_name().to_owned();
|
||||||
|
|
@ -97,7 +102,7 @@ fn run() -> Result<(), IdxError> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Commands::Cache(cache) => {
|
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);
|
emit_error(&err, &config.output);
|
||||||
return Err(err);
|
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"));
|
.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]
|
#[test]
|
||||||
fn config_set_and_get_provider_round_trip() {
|
fn config_set_and_get_provider_round_trip() {
|
||||||
let root = test_env_dir("config-provider");
|
let root = test_env_dir("config-provider");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue