use crate::cmd::completions::ShellType;
use crate::cmd::price::discovery::{DiscoveredCommodity, discover_symbols};
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::LoadOptions;
use std::collections::{HashMap, HashSet};
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)]
pub file: Option<PathBuf>,
#[arg(value_name = "SYMBOL")]
pub symbols: Vec<String>,
#[arg(short = 'c', long, default_value = "USD")]
pub currency: String,
#[arg(short, long)]
pub date: Option<String>,
#[arg(short = 'b', long)]
pub beancount: bool,
#[arg(short, long)]
pub verbose: bool,
#[arg(short = 'm', long, value_delimiter = ',')]
pub mapping: Vec<String>,
#[arg(short = 's', long)]
pub source: Option<String>,
#[arg(long, value_name = "CMD")]
pub source_cmd: Option<String>,
#[arg(long)]
pub list_sources: bool,
#[arg(long)]
pub no_cache: bool,
#[arg(long)]
pub clear_cache: bool,
#[arg(long, requires = "file")]
pub inactive: bool,
#[arg(long, requires = "file")]
pub undeclared: bool,
#[arg(long, requires = "file", hide = true)]
pub all_commodities: bool,
#[arg(short = 'n', long)]
pub dry_run: bool,
#[arg(short = 'C', long, requires = "file")]
pub clobber: bool,
}
pub fn run(args: &PriceArgs, price_config: &PriceConfig) -> Result<()> {
let mut stdout = io::stdout().lock();
run_with_writer(args, price_config, &mut stdout)
}
pub fn run_with_writer<W: Write>(
args: &PriceArgs,
price_config: &PriceConfig,
out: &mut W,
) -> 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, out);
}
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 args.all_commodities {
eprintln!(
"warning: `--all-commodities` is deprecated; use `--inactive --undeclared` instead. \
It will be removed in a future release."
);
}
let effective_inactive = args.inactive || args.all_commodities;
let effective_undeclared = args.undeclared || args.all_commodities;
let date: Option<NaiveDate> = if let Some(ref d) = args.date {
Some(
d.parse::<NaiveDate>()
.with_context(|| format!("Invalid date: {d}"))?,
)
} else {
None
};
let (discovered, existing_prices): (
HashMap<String, DiscoveredCommodity>,
HashSet<(String, String, NaiveDate)>,
) = if let Some(ref file) = args.file {
let opts = LoadOptions {
run_plugins: false,
validate: false,
..LoadOptions::default()
};
let ledger = rustledger_loader::load(file, &opts)
.with_context(|| format!("failed to load {} for symbol discovery", file.display()))?;
let discovered = discover_symbols(
&ledger.directives,
&ledger.options,
effective_inactive,
effective_undeclared,
date,
&price_config.mapping,
);
let mut existing = HashSet::new();
for spanned in &ledger.directives {
if let rustledger_core::Directive::Price(p) = &spanned.value {
existing.insert((
p.currency.as_str().to_string(),
p.amount.currency.as_str().to_string(),
p.date,
));
}
}
(discovered, existing)
} else {
(HashMap::new(), HashSet::new())
};
let mut symbols_to_fetch: Vec<String> = discovered.keys().cloned().collect();
for s in &args.symbols {
if !discovered.contains_key(s) {
symbols_to_fetch.push(s.clone());
}
}
symbols_to_fetch.sort();
symbols_to_fetch.dedup();
if symbols_to_fetch.is_empty() {
eprintln!(
"No symbols to fetch. Provide symbols as arguments or use -f with a beancount file."
);
if args.file.is_some() {
if !effective_undeclared {
eprintln!(
"Hint: only commodities with `price:` or `quote_currency:` metadata are \
fetched by default. Pass --undeclared to also include ticker-shaped names."
);
}
if !effective_inactive {
eprintln!(
"Hint: only commodities currently held are fetched by default. \
Pass --inactive to include those with zero balance."
);
}
}
return Ok(());
}
if args.verbose {
eprintln!("Fetching prices for: {symbols_to_fetch:?}");
}
let combined_mapping = build_combined_mapping(&price_config.mapping, &discovered, &cli_mapping);
if args.dry_run {
return dump_fetch_plan(
out,
args,
&symbols_to_fetch,
&discovered,
&price_config.mapping,
&combined_mapping,
&existing_prices,
price_config.effective_default_source(),
price_config.effective_use_default_source(),
date,
);
}
if let Some(cmd) = &args.source_cmd {
return run_with_external_command(
args,
cmd,
&symbols_to_fetch,
date,
price_config,
&discovered,
&existing_prices,
out,
);
}
let source_name_for_cache = args
.source
.as_deref()
.unwrap_or(price_config.effective_default_source());
for symbol in &symbols_to_fetch {
let per_quote_jobs: Vec<(String, Option<CommodityMapping>)> = discovered
.get(symbol)
.filter(|info| !info.quote_specs.is_empty())
.map_or_else(
|| {
let qc = resolve_quote_currency(
symbol,
&discovered,
&price_config.mapping,
&args.currency,
);
vec![(qc, None)]
},
|info| {
info.quote_specs
.iter()
.map(|qs| (qs.quote_currency.clone(), qs.mapping.clone()))
.collect()
},
);
for (effective_currency, per_spec_mapping) in per_quote_jobs {
if !args.clobber {
let fetch_date = date.unwrap_or_else(|| jiff::Zoned::now().date());
if existing_prices.contains(&(
symbol.clone(),
effective_currency.clone(),
fetch_date,
)) {
if args.verbose {
eprintln!(
"{symbol}: skipped (existing price for {fetch_date} {effective_currency}; pass --clobber to refetch)"
);
}
continue;
}
}
let key = cache_key(source_name_for_cache, symbol, &effective_currency, date);
if let Some(ref c) = cache
&& let Some(cached) = c.get(&key)
{
if !args.clobber
&& existing_prices.contains(&(
symbol.clone(),
cached.currency.clone(),
cached.date,
))
{
if args.verbose {
eprintln!(
"{symbol}: skipped from cache (cached date {} {} matches existing directive)",
cached.date, cached.currency
);
}
continue;
}
if args.verbose {
eprintln!("{symbol}: cached (source: {})", cached.source);
}
write_price(out, symbol, &cached, args.beancount)?;
continue;
}
let fetch_mapping: std::borrow::Cow<'_, HashMap<String, CommodityMapping>> =
if let Some(m) = per_spec_mapping {
let mut m1 = combined_mapping.clone();
m1.insert(symbol.clone(), m);
std::borrow::Cow::Owned(m1)
} else {
std::borrow::Cow::Borrowed(&combined_mapping)
};
let result = if let Some(source_name) = &args.source {
fetch_with_source(®istry, source_name, symbol, &effective_currency, date)
} else {
registry.fetch_price(symbol, &effective_currency, date, fetch_mapping.as_ref())
};
match result {
Ok(response) => {
if let Some(ref mut c) = cache {
let actual_key =
cache_key(&response.source, symbol, &effective_currency, date);
c.insert(&actual_key, &response);
if actual_key != key {
c.insert(&key, &response);
}
}
if !args.clobber
&& existing_prices.contains(&(
symbol.clone(),
response.currency.clone(),
response.date,
))
{
if args.verbose {
eprintln!(
"{symbol}: skipped after fetch (response dated {} {} matches existing directive)",
response.date, response.currency
);
}
continue;
}
write_price(out, 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 build_combined_mapping(
config_mapping: &HashMap<String, CommodityMapping>,
discovered: &HashMap<String, DiscoveredCommodity>,
cli_mapping: &HashMap<String, CommodityMapping>,
) -> HashMap<String, CommodityMapping> {
let mut combined = config_mapping.clone();
for (symbol, info) in discovered {
if let Some(m) = &info.mapping {
combined.insert(symbol.clone(), m.clone());
} else {
combined
.entry(symbol.clone())
.or_insert_with(|| CommodityMapping::Simple(symbol.clone()));
}
}
for (k, v) in cli_mapping {
combined.insert(k.clone(), v.clone());
}
combined
}
fn resolve_quote_currency(
symbol: &str,
discovered: &HashMap<String, DiscoveredCommodity>,
mapping: &HashMap<String, CommodityMapping>,
default_currency: &str,
) -> String {
if let Some(c) = discovered
.get(symbol)
.and_then(|d| d.quote_currency.as_deref())
{
return c.to_string();
}
if let Some(CommodityMapping::Detailed(d)) = mapping.get(symbol)
&& let Some(c) = &d.quote_currency
{
return c.clone();
}
default_currency.to_string()
}
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)
}
#[allow(clippy::too_many_arguments)]
fn dump_fetch_plan(
handle: &mut impl Write,
args: &PriceArgs,
symbols: &[String],
discovered: &HashMap<String, DiscoveredCommodity>,
config_mapping: &HashMap<String, CommodityMapping>,
combined_mapping: &HashMap<String, CommodityMapping>,
existing_prices: &HashSet<(String, String, NaiveDate)>,
default_source: &str,
use_default_source: bool,
date: Option<NaiveDate>,
) -> Result<()> {
let date_str = date.map_or_else(|| "today".to_string(), |d| d.to_string());
let fetch_date = date.unwrap_or_else(|| jiff::Zoned::now().date());
for symbol in symbols {
let per_quote_jobs: Vec<(String, Option<CommodityMapping>)> = discovered
.get(symbol)
.filter(|info| !info.quote_specs.is_empty())
.map_or_else(
|| {
let qc =
resolve_quote_currency(symbol, discovered, config_mapping, &args.currency);
vec![(qc, None)]
},
|info| {
info.quote_specs
.iter()
.map(|qs| (qs.quote_currency.clone(), qs.mapping.clone()))
.collect()
},
);
for (currency, per_spec_mapping) in per_quote_jobs {
let attempts_mapping: std::borrow::Cow<'_, HashMap<String, CommodityMapping>> =
if let Some(m) = per_spec_mapping {
let mut m1 = combined_mapping.clone();
m1.insert(symbol.clone(), m);
std::borrow::Cow::Owned(m1)
} else {
std::borrow::Cow::Borrowed(combined_mapping)
};
let mut attempts: Vec<(String, String)> = if args.source_cmd.is_some() {
vec![("source-cmd".to_string(), symbol.clone())]
} else if let Some(s) = &args.source {
vec![(s.clone(), symbol.clone())]
} else {
describe_attempts(symbol, attempts_mapping.as_ref(), default_source)
};
if attempts.is_empty()
&& use_default_source
&& args.source_cmd.is_none()
&& args.source.is_none()
{
attempts.push((default_source.to_string(), symbol.clone()));
}
let attempts_str = if attempts.is_empty() {
"<unmapped>".to_string()
} else {
attempts
.iter()
.map(|(s, t)| format!("{s}({t})"))
.collect::<Vec<_>>()
.join(", ")
};
let skipped = !args.clobber
&& existing_prices.contains(&(symbol.clone(), currency.clone(), fetch_date));
let suffix = if skipped {
" [skip: existing price]"
} else {
""
};
writeln!(
handle,
"{symbol} /{currency} @ {date_str} {attempts_str}{suffix}"
)?;
}
}
Ok(())
}
fn describe_attempts(
symbol: &str,
combined_mapping: &HashMap<String, CommodityMapping>,
default_source: &str,
) -> Vec<(String, String)> {
use crate::config::SourceRef;
let Some(m) = combined_mapping.get(symbol) else {
return Vec::new();
};
match m {
CommodityMapping::Simple(ticker) => {
vec![(default_source.to_string(), ticker.clone())]
}
CommodityMapping::Detailed(d) => {
let parent_ticker = d.ticker.as_deref().unwrap_or(symbol);
match &d.source {
SourceRef::Single(s) => vec![(s.clone(), parent_ticker.to_string())],
SourceRef::Fallback(entries) => entries
.iter()
.map(|e| match e {
crate::config::FallbackEntry::Name(s) => {
(s.clone(), parent_ticker.to_string())
}
crate::config::FallbackEntry::Detailed(fd) => (
fd.source.clone(),
fd.ticker
.clone()
.unwrap_or_else(|| parent_ticker.to_string()),
),
})
.collect(),
}
}
}
}
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<W: Write>(
args: &PriceArgs,
cmd: &str,
symbols: &[String],
date: Option<NaiveDate>,
price_config: &PriceConfig,
discovered: &HashMap<String, DiscoveredCommodity>,
existing_prices: &HashSet<(String, String, NaiveDate)>,
handle: &mut W,
) -> 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());
for symbol in symbols {
let per_quote_currencies: Vec<String> = discovered
.get(symbol)
.filter(|info| !info.quote_specs.is_empty())
.map_or_else(
|| {
vec![resolve_quote_currency(
symbol,
discovered,
&price_config.mapping,
&args.currency,
)]
},
|info| {
info.quote_specs
.iter()
.map(|qs| qs.quote_currency.clone())
.collect()
},
);
for effective_currency in per_quote_currencies {
if !args.clobber {
let fetch_date = date.unwrap_or_else(|| jiff::Zoned::now().date());
if existing_prices.contains(&(
symbol.clone(),
effective_currency.clone(),
fetch_date,
)) {
if args.verbose {
eprintln!(
"{symbol}: skipped (existing price for {fetch_date} {effective_currency}; pass --clobber to refetch)"
);
}
continue;
}
}
let request = PriceRequest {
ticker: symbol.clone(),
currency: effective_currency.clone(),
date,
};
match source.fetch_price(&request) {
Ok(response) => {
if !args.clobber
&& existing_prices.contains(&(
symbol.clone(),
response.currency.clone(),
response.date,
))
{
if args.verbose {
eprintln!(
"{symbol}: skipped after fetch (response dated {} {} matches existing directive)",
response.date, response.currency
);
}
continue;
}
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<W: Write>(registry: &PriceSourceRegistry, out: &mut W) -> Result<()> {
writeln!(out, "Available price sources:")?;
writeln!(out)?;
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 {
""
};
writeln!(out, " {name}{default_marker}{api_key_note}")?;
writeln!(out, " {}", source.description())?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SourceRef;
#[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);
}
#[test]
fn test_price_args_discovery_flags_default_off() {
let args = Args::parse_from(["price", "AAPL"]);
assert!(!args.price_args.inactive);
assert!(!args.price_args.undeclared);
}
#[test]
fn test_price_args_inactive_flag() {
let args = Args::parse_from(["price", "--inactive", "-f", "ledger.beancount"]);
assert!(args.price_args.inactive);
assert!(!args.price_args.undeclared);
}
#[test]
fn test_price_args_undeclared_flag() {
let args = Args::parse_from(["price", "--undeclared", "-f", "ledger.beancount"]);
assert!(args.price_args.undeclared);
assert!(!args.price_args.inactive);
}
#[test]
fn test_price_args_inactive_and_undeclared_combined() {
let args = Args::parse_from([
"price",
"--inactive",
"--undeclared",
"-f",
"ledger.beancount",
]);
assert!(args.price_args.inactive);
assert!(args.price_args.undeclared);
}
#[test]
fn test_price_args_all_commodities_deprecated_alias_still_parses() {
let args = Args::parse_from(["price", "--all-commodities", "-f", "ledger.beancount"]);
assert!(args.price_args.all_commodities);
assert!(!args.price_args.inactive);
assert!(!args.price_args.undeclared);
}
#[test]
fn test_resolve_quote_currency_prefers_discovered_metadata() {
let mut discovered = HashMap::new();
discovered.insert(
"AAPL".to_string(),
DiscoveredCommodity {
quote_currency: Some("EUR".to_string()),
..DiscoveredCommodity::default()
},
);
let mut mapping = HashMap::new();
mapping.insert(
"AAPL".to_string(),
CommodityMapping::Detailed(crate::config::DetailedMapping {
source: crate::config::SourceRef::Single("yahoo".into()),
ticker: None,
quote_currency: Some("GBP".into()),
}),
);
assert_eq!(
resolve_quote_currency("AAPL", &discovered, &mapping, "USD"),
"EUR"
);
}
#[test]
fn test_resolve_quote_currency_falls_back_to_config_mapping() {
let discovered = HashMap::new();
let mut mapping = HashMap::new();
mapping.insert(
"AUD".to_string(),
CommodityMapping::Detailed(crate::config::DetailedMapping {
source: crate::config::SourceRef::Single("ecb".into()),
ticker: None,
quote_currency: Some("EUR".into()),
}),
);
assert_eq!(
resolve_quote_currency("AUD", &discovered, &mapping, "USD"),
"EUR"
);
}
#[test]
fn test_resolve_quote_currency_uses_default_when_unset() {
let discovered = HashMap::new();
let mapping = HashMap::new();
assert_eq!(
resolve_quote_currency("AAPL", &discovered, &mapping, "USD"),
"USD"
);
}
#[test]
fn test_resolve_quote_currency_simple_mapping_does_not_set_currency() {
let discovered = HashMap::new();
let mut mapping = HashMap::new();
mapping.insert("VTI".to_string(), CommodityMapping::Simple("VTI".into()));
assert_eq!(
resolve_quote_currency("VTI", &discovered, &mapping, "USD"),
"USD"
);
}
#[test]
fn test_resolve_quote_currency_uses_raw_config_not_merged_mapping() {
let discovered = HashMap::new();
let mut mapping = HashMap::new();
mapping.insert(
"AUD".to_string(),
CommodityMapping::Detailed(crate::config::DetailedMapping {
source: crate::config::SourceRef::Single("ecb".into()),
ticker: None,
quote_currency: Some("EUR".into()),
}),
);
assert_eq!(
resolve_quote_currency("AUD", &discovered, &mapping, "USD"),
"EUR"
);
}
#[test]
fn test_resolve_quote_currency_detailed_without_quote_currency_uses_default() {
let discovered = HashMap::new();
let mut mapping = HashMap::new();
mapping.insert(
"AAPL".to_string(),
CommodityMapping::Detailed(crate::config::DetailedMapping {
source: crate::config::SourceRef::Single("yahoo".into()),
ticker: None,
quote_currency: None,
}),
);
assert_eq!(
resolve_quote_currency("AAPL", &discovered, &mapping, "USD"),
"USD"
);
}
#[test]
fn build_combined_mapping_preserves_config_for_quote_currency_only_commodity() {
let mut config_mapping = HashMap::new();
config_mapping.insert(
"BTC".to_string(),
CommodityMapping::Detailed(crate::config::DetailedMapping {
source: crate::config::SourceRef::Single("coinbase".to_string()),
ticker: Some("BTC-USD".to_string()),
quote_currency: None,
}),
);
let mut discovered = HashMap::new();
discovered.insert(
"BTC".to_string(),
DiscoveredCommodity {
mapping: None,
quote_currency: Some("USD".to_string()),
..DiscoveredCommodity::default()
},
);
let combined = build_combined_mapping(&config_mapping, &discovered, &HashMap::new());
let entry = combined.get("BTC").expect("BTC must remain in mapping");
match entry {
CommodityMapping::Detailed(d) => {
match &d.source {
crate::config::SourceRef::Single(s) => assert_eq!(
s, "coinbase",
"config-level coinbase mapping must survive discovery synthesis"
),
crate::config::SourceRef::Fallback(_) => {
panic!("expected Single source, got Fallback")
}
}
assert_eq!(d.ticker.as_deref(), Some("BTC-USD"));
}
CommodityMapping::Simple(_) => {
panic!("expected Detailed mapping; synthesis silently overwrote config");
}
}
}
#[test]
fn build_combined_mapping_synthesizes_simple_for_unmapped_discovered_symbol() {
let config_mapping = HashMap::new();
let mut discovered = HashMap::new();
discovered.insert(
"GOVT_EU".to_string(),
DiscoveredCommodity {
mapping: None,
quote_currency: Some("EUR".to_string()),
..DiscoveredCommodity::default()
},
);
let combined = build_combined_mapping(&config_mapping, &discovered, &HashMap::new());
match combined.get("GOVT_EU") {
Some(CommodityMapping::Simple(s)) => assert_eq!(s, "GOVT_EU"),
other => panic!("expected synthesized Simple(\"GOVT_EU\"), got {other:?}"),
}
}
#[test]
fn build_combined_mapping_discovered_metadata_overrides_config() {
let mut config_mapping = HashMap::new();
config_mapping.insert(
"AAPL".to_string(),
CommodityMapping::Simple("AAPL-OLD".to_string()),
);
let mut discovered = HashMap::new();
discovered.insert(
"AAPL".to_string(),
DiscoveredCommodity {
mapping: Some(CommodityMapping::Detailed(crate::config::DetailedMapping {
source: crate::config::SourceRef::Single("yahoo".to_string()),
ticker: Some("AAPL".to_string()),
quote_currency: None,
})),
quote_currency: None,
..DiscoveredCommodity::default()
},
);
let combined = build_combined_mapping(&config_mapping, &discovered, &HashMap::new());
match combined.get("AAPL") {
Some(CommodityMapping::Detailed(d)) => match &d.source {
crate::config::SourceRef::Single(s) => assert_eq!(s, "yahoo"),
crate::config::SourceRef::Fallback(_) => {
panic!("expected Single source, got Fallback")
}
},
other => panic!("expected metadata to override config, got {other:?}"),
}
}
#[test]
fn build_combined_mapping_cli_mapping_wins_over_discovery() {
let config_mapping = HashMap::new();
let mut discovered = HashMap::new();
discovered.insert(
"AAPL".to_string(),
DiscoveredCommodity {
mapping: Some(CommodityMapping::Simple("AAPL-DISCOVERED".to_string())),
quote_currency: None,
..DiscoveredCommodity::default()
},
);
let mut cli_mapping = HashMap::new();
cli_mapping.insert(
"AAPL".to_string(),
CommodityMapping::Simple("AAPL-CLI".to_string()),
);
let combined = build_combined_mapping(&config_mapping, &discovered, &cli_mapping);
match combined.get("AAPL") {
Some(CommodityMapping::Simple(s)) => assert_eq!(s, "AAPL-CLI"),
other => panic!("CLI must win, got {other:?}"),
}
}
#[test]
fn describe_attempts_simple_mapping_uses_configured_default() {
let mut combined = HashMap::new();
combined.insert(
"AAPL".to_string(),
CommodityMapping::Simple("AAPL".to_string()),
);
let attempts = describe_attempts("AAPL", &combined, "yahoo");
assert_eq!(attempts, vec![("yahoo".to_string(), "AAPL".to_string())]);
}
#[test]
fn describe_attempts_walks_fallback_chain_with_per_source_tickers() {
use crate::config::{DetailedMapping, FallbackDetail, FallbackEntry, SourceRef};
let mut combined = HashMap::new();
combined.insert(
"GBP".to_string(),
CommodityMapping::Detailed(DetailedMapping {
source: SourceRef::Fallback(vec![
FallbackEntry::Detailed(FallbackDetail {
source: "ecbrates".to_string(),
ticker: Some("GBP-EUR".to_string()),
}),
FallbackEntry::Detailed(FallbackDetail {
source: "ecb".to_string(),
ticker: Some("GBP".to_string()),
}),
]),
ticker: Some("GBP".to_string()),
quote_currency: Some("EUR".to_string()),
}),
);
let attempts = describe_attempts("GBP", &combined, "yahoo");
assert_eq!(
attempts,
vec![
("ecbrates".to_string(), "GBP-EUR".to_string()),
("ecb".to_string(), "GBP".to_string()),
]
);
}
#[test]
fn describe_attempts_unmapped_returns_empty() {
let attempts = describe_attempts("AAPL", &HashMap::new(), "yahoo");
assert!(attempts.is_empty());
}
fn dump_args(extra: &[&str]) -> PriceArgs {
let mut argv = vec!["price"];
argv.extend_from_slice(extra);
Args::parse_from(argv).price_args
}
#[test]
fn dump_fetch_plan_emits_one_row_per_declared_quote() {
use crate::config::DetailedMapping;
let mut discovered = HashMap::new();
discovered.insert(
"AAPL".to_string(),
DiscoveredCommodity {
mapping: None,
quote_currency: None,
quote_specs: vec![
crate::cmd::price::discovery::QuoteSpec {
quote_currency: "USD".to_string(),
mapping: Some(CommodityMapping::Detailed(DetailedMapping {
source: SourceRef::Single("yahoo".to_string()),
ticker: Some("AAPL".to_string()),
quote_currency: Some("USD".to_string()),
})),
},
crate::cmd::price::discovery::QuoteSpec {
quote_currency: "CAD".to_string(),
mapping: Some(CommodityMapping::Detailed(DetailedMapping {
source: SourceRef::Single("oanda".to_string()),
ticker: Some("AAPL".to_string()),
quote_currency: Some("CAD".to_string()),
})),
},
],
},
);
let combined = build_combined_mapping(&HashMap::new(), &discovered, &HashMap::new());
let mut buf = Vec::new();
let args = dump_args(&["-f", "x.beancount", "-n"]);
dump_fetch_plan(
&mut buf,
&args,
&["AAPL".to_string()],
&discovered,
&HashMap::new(),
&combined,
&HashSet::new(),
"yahoo",
false,
None,
)
.unwrap();
let out = String::from_utf8(buf).unwrap();
let usd_line = out
.lines()
.find(|l| l.contains("/USD"))
.expect("USD row must be present");
let cad_line = out.lines().find(|l| l.contains("/CAD")).expect(
"CAD row must be present (multi-quote regression: pre-fix this row was missing)",
);
assert!(
usd_line.contains("yahoo(AAPL)"),
"USD row must use the per-spec yahoo source: {usd_line}"
);
assert!(
cad_line.contains("oanda(AAPL)"),
"CAD row must use the per-spec oanda source (NOT the first-spec yahoo): {cad_line}"
);
}
#[test]
fn dump_fetch_plan_source_flag_overrides_per_spec_for_all_quotes() {
use crate::config::DetailedMapping;
let mut discovered = HashMap::new();
discovered.insert(
"AAPL".to_string(),
DiscoveredCommodity {
mapping: None,
quote_currency: None,
quote_specs: vec![
crate::cmd::price::discovery::QuoteSpec {
quote_currency: "USD".to_string(),
mapping: Some(CommodityMapping::Detailed(DetailedMapping {
source: SourceRef::Single("yahoo".to_string()),
ticker: Some("AAPL".to_string()),
quote_currency: Some("USD".to_string()),
})),
},
crate::cmd::price::discovery::QuoteSpec {
quote_currency: "CAD".to_string(),
mapping: Some(CommodityMapping::Detailed(DetailedMapping {
source: SourceRef::Single("oanda".to_string()),
ticker: Some("AAPL".to_string()),
quote_currency: Some("CAD".to_string()),
})),
},
],
},
);
let combined = build_combined_mapping(&HashMap::new(), &discovered, &HashMap::new());
let mut buf = Vec::new();
let args = dump_args(&["-f", "x.beancount", "-n", "--source", "coinbase"]);
dump_fetch_plan(
&mut buf,
&args,
&["AAPL".to_string()],
&discovered,
&HashMap::new(),
&combined,
&HashSet::new(),
"yahoo",
false,
None,
)
.unwrap();
let out = String::from_utf8(buf).unwrap();
let usd_line = out.lines().find(|l| l.contains("/USD")).unwrap();
let cad_line = out.lines().find(|l| l.contains("/CAD")).unwrap();
assert!(
usd_line.contains("coinbase(AAPL)"),
"--source coinbase must override the USD per-spec yahoo: {usd_line}"
);
assert!(
cad_line.contains("coinbase(AAPL)"),
"--source coinbase must also override the CAD per-spec oanda \
(documented bypass behavior — applies to ALL quotes): {cad_line}"
);
}
#[test]
fn test_price_args_clobber_flag() {
let args = Args::parse_from(["price", "-f", "ledger.beancount", "--clobber"]);
assert!(args.price_args.clobber);
let args = Args::parse_from(["price", "-f", "ledger.beancount", "-C"]);
assert!(args.price_args.clobber);
let args = Args::parse_from(["price", "AAPL"]);
assert!(!args.price_args.clobber);
}
#[test]
fn test_price_args_clobber_requires_file() {
let result = Args::try_parse_from(["price", "AAPL", "--clobber"]);
assert!(result.is_err(), "--clobber without -f must be rejected");
}
#[test]
fn test_price_args_dry_run_flag() {
let args = Args::parse_from(["price", "AAPL", "--dry-run"]);
assert!(args.price_args.dry_run);
let args = Args::parse_from(["price", "AAPL", "-n"]);
assert!(args.price_args.dry_run);
let args = Args::try_parse_from(["price", "AAPL", "-n"]);
assert!(args.is_ok());
}
}