use crate::cmd::completions::ShellType;
use crate::cmd::price::sources::PriceSource;
use crate::cmd::price::{PriceRequest, PriceSourceRegistry};
use crate::config::{CommodityMapping, PriceConfig};
use anyhow::{Context, Result};
use clap::Parser;
use rustledger_core::NaiveDate;
use rustledger_loader::Loader;
use std::collections::HashMap;
use std::io::{self, Write};
use std::path::PathBuf;
use std::time::Duration;
#[derive(Parser, Debug)]
#[command(name = "price", about = "Fetch current prices for commodities")]
pub struct Args {
#[arg(long, value_name = "SHELL")]
generate_completions: Option<ShellType>,
#[command(flatten)]
pub price_args: PriceArgs,
}
#[derive(Parser, Debug)]
pub struct PriceArgs {
#[arg(short, long)]
file: Option<PathBuf>,
#[arg(value_name = "SYMBOL")]
symbols: Vec<String>,
#[arg(short = 'c', long, default_value = "USD")]
currency: String,
#[arg(short, long)]
date: Option<String>,
#[arg(short = 'b', long)]
beancount: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'm', long, value_delimiter = ',')]
mapping: Vec<String>,
#[arg(short = 's', long)]
source: Option<String>,
#[arg(long, value_name = "CMD")]
source_cmd: Option<String>,
#[arg(long)]
list_sources: bool,
#[arg(long)]
no_cache: bool,
#[arg(long)]
clear_cache: bool,
}
pub fn run(args: &PriceArgs, price_config: &PriceConfig) -> Result<()> {
use crate::cmd::price::cache::{PriceCache, cache_key};
let registry = PriceSourceRegistry::new(price_config);
let cache_ttl = price_config.effective_cache_ttl();
if args.clear_cache {
let mut c = PriceCache::load(cache_ttl);
c.clear();
if args.verbose {
eprintln!("Price cache cleared");
}
}
let cache_enabled = cache_ttl > 0 && !args.no_cache;
let mut cache = if cache_enabled {
Some(PriceCache::load(cache_ttl))
} else {
None
};
if args.list_sources {
return list_sources(®istry);
}
let mut symbols_to_fetch: Vec<String> = args.symbols.clone();
let mut cli_mapping: HashMap<String, CommodityMapping> = HashMap::new();
for mapping in &args.mapping {
if let Some((from, to)) = mapping.split_once(':') {
cli_mapping.insert(from.to_string(), CommodityMapping::Simple(to.to_string()));
}
}
if let Some(ref file) = args.file {
let mut loader = Loader::new();
let ledger = loader.load(file)?;
for spanned in &ledger.directives {
if let rustledger_core::Directive::Commodity(comm) = &spanned.value {
let symbol = comm.currency.as_str();
if symbol
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-')
&& symbol.len() <= 10
&& !symbols_to_fetch.contains(&symbol.to_string())
{
symbols_to_fetch.push(symbol.to_string());
}
}
}
}
if symbols_to_fetch.is_empty() {
eprintln!(
"No symbols to fetch. Provide symbols as arguments or use -f with a beancount file."
);
return Ok(());
}
if args.verbose {
eprintln!("Fetching prices for: {symbols_to_fetch:?}");
}
let date = if let Some(ref d) = args.date {
Some(
d.parse::<NaiveDate>()
.with_context(|| format!("Invalid date: {d}"))?,
)
} else {
None
};
if let Some(cmd) = &args.source_cmd {
return run_with_external_command(args, cmd, &symbols_to_fetch, date, price_config);
}
let mut combined_mapping = price_config.mapping.clone();
for (k, v) in cli_mapping {
combined_mapping.insert(k, v);
}
let stdout = io::stdout();
let mut handle = stdout.lock();
let source_name_for_cache = args
.source
.as_deref()
.unwrap_or(price_config.effective_default_source());
for symbol in &symbols_to_fetch {
let key = cache_key(source_name_for_cache, symbol, &args.currency, date);
if let Some(ref c) = cache
&& let Some(cached) = c.get(&key)
{
if args.verbose {
eprintln!("{symbol}: cached (source: {})", cached.source);
}
write_price(&mut handle, symbol, &cached, args.beancount)?;
continue;
}
let result = if let Some(source_name) = &args.source {
fetch_with_source(®istry, source_name, symbol, &args.currency, date)
} else {
registry.fetch_price(symbol, &args.currency, date, &combined_mapping)
};
match result {
Ok(response) => {
if let Some(ref mut c) = cache {
let actual_key = cache_key(&response.source, symbol, &args.currency, date);
c.insert(&actual_key, &response);
if actual_key != key {
c.insert(&key, &response);
}
}
write_price(&mut handle, symbol, &response, args.beancount)?;
}
Err(e) => {
if args.verbose {
eprintln!("Error fetching {symbol}: {e}");
} else {
eprintln!("; Failed to fetch {symbol}: {e}");
}
}
}
}
if let Some(ref mut c) = cache {
c.save();
}
Ok(())
}
fn fetch_with_source(
registry: &PriceSourceRegistry,
source_name: &str,
ticker: &str,
currency: &str,
date: Option<NaiveDate>,
) -> Result<crate::cmd::price::PriceResponse> {
let source = registry
.get(source_name)
.with_context(|| format!("Unknown source: {source_name}"))?;
let request = PriceRequest {
ticker: ticker.to_string(),
currency: currency.to_string(),
date,
};
source.fetch_price(&request)
}
fn write_price(
handle: &mut impl Write,
symbol: &str,
response: &crate::cmd::price::PriceResponse,
beancount: bool,
) -> Result<()> {
if beancount {
let date_str = response.date.to_string();
writeln!(
handle,
"{date_str} price {symbol} {} {}",
response.price, response.currency
)?;
} else {
writeln!(handle, "{symbol}: {} {}", response.price, response.currency)?;
}
Ok(())
}
fn run_with_external_command(
args: &PriceArgs,
cmd: &str,
symbols: &[String],
date: Option<NaiveDate>,
price_config: &PriceConfig,
) -> Result<()> {
use crate::cmd::price::external::ExternalCommandSource;
let command_parts: Vec<String> =
shell_words::split(cmd).with_context(|| format!("Failed to parse command: {cmd}"))?;
if command_parts.is_empty() {
anyhow::bail!("Empty command provided");
}
let timeout = Duration::from_secs(price_config.effective_timeout());
let source = ExternalCommandSource::new(command_parts, timeout, HashMap::new());
let stdout = io::stdout();
let mut handle = stdout.lock();
for symbol in symbols {
let request = PriceRequest {
ticker: symbol.clone(),
currency: args.currency.clone(),
date,
};
match source.fetch_price(&request) {
Ok(response) => {
if args.beancount {
let date_str = response.date.to_string();
writeln!(
handle,
"{date_str} price {symbol} {} {}",
response.price, response.currency
)?;
} else {
writeln!(handle, "{symbol}: {} {}", response.price, response.currency)?;
}
}
Err(e) => {
if args.verbose {
eprintln!("Error fetching {symbol}: {e}");
} else {
eprintln!("; Failed to fetch {symbol}: {e}");
}
}
}
}
Ok(())
}
fn list_sources(registry: &PriceSourceRegistry) -> Result<()> {
println!("Available price sources:");
println!();
let sources = registry.list_sources();
let default_source = registry.default_source_name();
for name in sources {
if let Some(source) = registry.get(name) {
let default_marker = if name == default_source {
" (default)"
} else {
""
};
let api_key_note = if source.requires_api_key() {
if let Some(env_var) = source.api_key_env_var() {
if std::env::var(env_var).is_ok() {
" [API key set]"
} else {
" [API key required]"
}
} else {
" [API key required]"
}
} else {
""
};
println!(" {name}{default_marker}{api_key_note}");
println!(" {}", source.description());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_price_args_parsing() {
let args = Args::parse_from(["price", "AAPL", "MSFT"]);
assert_eq!(args.price_args.symbols, vec!["AAPL", "MSFT"]);
assert_eq!(args.price_args.currency, "USD");
assert!(!args.price_args.beancount);
}
#[test]
fn test_price_args_with_options() {
let args = Args::parse_from([
"price",
"-c",
"EUR",
"-b",
"-m",
"BTC:BTC-USD,ETH:ETH-USD",
"BTC",
"ETH",
]);
assert_eq!(args.price_args.symbols, vec!["BTC", "ETH"]);
assert_eq!(args.price_args.currency, "EUR");
assert!(args.price_args.beancount);
assert_eq!(args.price_args.mapping.len(), 2);
}
#[test]
fn test_price_args_with_source() {
let args = Args::parse_from(["price", "-s", "coinbase", "BTC"]);
assert_eq!(args.price_args.source, Some("coinbase".to_string()));
assert_eq!(args.price_args.symbols, vec!["BTC"]);
}
#[test]
fn test_price_args_with_source_cmd() {
let args = Args::parse_from(["price", "--source-cmd", "echo 150.00 USD", "AAPL"]);
assert_eq!(
args.price_args.source_cmd,
Some("echo 150.00 USD".to_string())
);
}
#[test]
fn test_price_args_list_sources() {
let args = Args::parse_from(["price", "--list-sources"]);
assert!(args.price_args.list_sources);
}
#[test]
fn test_price_args_no_cache() {
let args = Args::parse_from(["price", "--no-cache", "AAPL"]);
assert!(args.price_args.no_cache);
assert!(!args.price_args.clear_cache);
}
#[test]
fn test_price_args_clear_cache() {
let args = Args::parse_from(["price", "--clear-cache", "AAPL"]);
assert!(args.price_args.clear_cache);
assert!(!args.price_args.no_cache);
}
#[test]
fn test_price_args_clear_and_no_cache_together() {
let args = Args::parse_from(["price", "--clear-cache", "--no-cache", "AAPL"]);
assert!(args.price_args.clear_cache);
assert!(args.price_args.no_cache);
}
}