diff --git a/src/cache.rs b/src/cache.rs index 9f7dc21..0d9ff43 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -52,11 +52,7 @@ impl Cache { let Some(entry): Option> = 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::(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( + &self, + data_type: &str, + symbol: &str, + ) -> Result, IdxError> { + let Some(entry) = self.read_entry::(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( &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, 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 { if let Ok(dir) = std::env::var("XDG_CACHE_HOME") && !dir.is_empty() diff --git a/src/cli/cache.rs b/src/cli/cache.rs index 9ec9351..3a8ae31 100644 --- a/src/cli/cache.rs +++ b/src/cli/cache.rs @@ -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())); diff --git a/src/cli/ownership.rs b/src/cli/ownership.rs index f9fec7e..a04cb7c 100644 --- a/src/cli/ownership.rs +++ b/src/cli/ownership.rs @@ -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})." ); diff --git a/src/cli/stocks.rs b/src/cli/stocks.rs index 65d364a..37d4fdc 100644 --- a/src/cli/stocks.rs +++ b/src/cli/stocks.rs @@ -128,6 +128,7 @@ pub enum StocksSubcommand { )] Quote { /// One or more symbols, comma-separated or space-separated. + #[arg(required = true)] symbols: Vec, }, #[command( @@ -231,6 +232,7 @@ pub enum StocksSubcommand { )] Compare { /// One or more symbols, comma-separated or space-separated. + #[arg(required = true)] symbols: Vec, }, } @@ -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::>( + .get_offline::>( &history_bucket, &format!("{resolved}-{key}"), )? @@ -388,7 +390,7 @@ pub fn handle( } if offline { let stale = cache - .get_stale::(&technical_bucket, &resolved)? + .get_offline::(&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::(cache_spec.bucket, cache_spec.key)? + .get_offline::(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::(&cache_spec.bucket, resolved)? + .get_offline::(&cache_spec.bucket, resolved)? .ok_or_else(|| IdxError::CacheMiss(format!("{}/{resolved}", cache_spec.bucket))); } diff --git a/src/main.rs b/src/main.rs index 54ba5b6..499195f 100644 --- a/src/main.rs +++ b/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); } diff --git a/tests/cli.rs b/tests/cli.rs index 7536f44..63b8be3 100644 --- a/tests/cli.rs +++ b/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");