fix(cli): close small JSON-contract gaps for agent consumers

- --offline now warns when it serves a cache entry past its TTL; before,
  arbitrarily old data was returned with no signal at all
- version, cache info and cache clear honour -o json
- ownership resolve map/merge honour -o json
- stocks quote/compare require at least one symbol instead of printing []
- cache schema-mismatch notice goes through runtime::info so --quiet
  silences it, instead of a raw debug eprintln

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Q2PufCUdvVZoVW1Dy53js
This commit is contained in:
Claude 2026-10-02 12:28:16 +00:00
commit 277273bc78
No known key found for this signature in database
6 changed files with 130 additions and 18 deletions

View file

@ -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()

View file

@ -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()));

View file

@ -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})."
); );

View file

@ -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(&quote_bucket, &resolved)? .get_offline(&quote_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)));
} }

View file

@ -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);
} }

View file

@ -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");