use crate::config::{CommodityMapping, DetailedMapping, FallbackDetail, FallbackEntry, SourceRef};
use rust_decimal::Decimal;
use rustledger_core::{Directive, MetaValue, NaiveDate};
use rustledger_loader::Options;
use rustledger_parser::Spanned;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone)]
pub struct QuoteSpec {
pub quote_currency: String,
pub mapping: Option<CommodityMapping>,
}
#[derive(Debug, Clone, Default)]
pub struct DiscoveredCommodity {
pub mapping: Option<CommodityMapping>,
pub quote_currency: Option<String>,
pub quote_specs: Vec<QuoteSpec>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PriceSpec {
quote_currency: String,
source: String,
ticker: String,
}
#[allow(clippy::implicit_hasher)]
pub fn discover_symbols(
directives: &[Spanned<Directive>],
options: &Options,
inactive: bool,
undeclared: bool,
as_of: Option<NaiveDate>,
config_mapping: &HashMap<String, CommodityMapping>,
) -> HashMap<String, DiscoveredCommodity> {
let active = if inactive {
None
} else {
Some(active_commodities(directives, options, as_of))
};
let mut out: HashMap<String, DiscoveredCommodity> = HashMap::new();
let mut seen_commodity_decl: HashSet<String> = HashSet::new();
let mut opted_out: HashSet<String> = HashSet::new();
for spanned in directives {
let Directive::Commodity(comm) = &spanned.value else {
continue;
};
if let Some(cutoff) = as_of
&& comm.date >= cutoff
{
continue;
}
let symbol = comm.currency.as_str();
seen_commodity_decl.insert(symbol.to_string());
let classification = classify_commodity_meta(&comm.meta);
if classification.malformed_price {
eprintln!(
"warning: commodity {symbol} has malformed `price:` metadata; \
expected `<quote>:<source>/<ticker>` (e.g. `USD:yahoo/AAPL`). Skipping."
);
}
let info = match classification.decision {
DiscoveryDecision::OptOut => {
opted_out.insert(symbol.to_string());
continue;
}
DiscoveryDecision::Discovered(info) => info,
DiscoveryDecision::Inherit => {
if !(undeclared && looks_like_ticker(symbol)) {
continue;
}
DiscoveredCommodity::default()
}
};
if let Some(ref active_set) = active
&& !active_set.contains(symbol)
{
continue;
}
out.insert(symbol.to_string(), info);
}
let txn_symbols: HashSet<String> = if undeclared || !config_mapping.is_empty() {
transaction_walked_currencies(directives, as_of)
} else {
HashSet::new()
};
if undeclared {
for symbol in &txn_symbols {
if seen_commodity_decl.contains(symbol) {
continue;
}
if !looks_like_ticker(symbol) {
continue;
}
if let Some(ref active_set) = active
&& !active_set.contains(symbol)
{
continue;
}
out.entry(symbol.clone()).or_default();
}
}
if !config_mapping.is_empty() {
let ledger_symbols: HashSet<&str> = seen_commodity_decl
.iter()
.map(String::as_str)
.chain(txn_symbols.iter().map(String::as_str))
.collect();
for symbol in config_mapping.keys() {
if out.contains_key(symbol) {
continue;
}
if opted_out.contains(symbol) {
continue;
}
if !ledger_symbols.contains(symbol.as_str()) {
continue;
}
if let Some(ref active_set) = active
&& !active_set.contains(symbol)
{
continue;
}
out.insert(symbol.clone(), DiscoveredCommodity::default());
}
}
out
}
fn transaction_walked_currencies(
directives: &[Spanned<Directive>],
as_of: Option<NaiveDate>,
) -> HashSet<String> {
let in_window = |d: NaiveDate| as_of.is_none_or(|cutoff| d < cutoff);
let mut out = HashSet::new();
for spanned in directives {
let Directive::Transaction(txn) = &spanned.value else {
continue;
};
if !in_window(txn.date) {
continue;
}
for posting in &txn.postings {
if let Some(amount) = posting.amount() {
out.insert(amount.currency.to_string());
}
if let Some(cost) = &posting.cost
&& let Some(c) = &cost.currency
{
out.insert(c.to_string());
}
if let Some(price) = &posting.price
&& let Some(amount) = price.amount()
{
out.insert(amount.currency.to_string());
}
}
}
out
}
enum DiscoveryDecision {
OptOut,
Discovered(DiscoveredCommodity),
Inherit,
}
struct Classification {
decision: DiscoveryDecision,
malformed_price: bool,
}
fn classify_commodity_meta(meta: &rustledger_core::Metadata) -> Classification {
let price_raw = meta.get("price").and_then(metavalue_as_str);
if let Some(p) = price_raw
&& p.trim().is_empty()
{
return Classification {
decision: DiscoveryDecision::OptOut,
malformed_price: false,
};
}
let price_specs = price_raw.map(parse_price_metadata).unwrap_or_default();
let malformed_price = price_raw.is_some_and(|s| !s.trim().is_empty()) && price_specs.is_empty();
let quote_currency_meta = meta.get("quote_currency").and_then(|v| match v {
MetaValue::String(s) => Some(s.clone()),
MetaValue::Currency(c) => Some(c.to_string()),
_ => None,
});
let mut quote_specs: Vec<QuoteSpec> = Vec::new();
for spec in &price_specs {
if let Some(existing) = quote_specs
.iter_mut()
.find(|qs| qs.quote_currency == spec.quote_currency)
{
let same_quote: Vec<PriceSpec> = price_specs
.iter()
.filter(|s| s.quote_currency == existing.quote_currency)
.cloned()
.collect();
existing.mapping = build_mapping(&same_quote);
} else {
let same_quote: Vec<PriceSpec> = price_specs
.iter()
.filter(|s| s.quote_currency == spec.quote_currency)
.cloned()
.collect();
quote_specs.push(QuoteSpec {
quote_currency: spec.quote_currency.clone(),
mapping: build_mapping(&same_quote),
});
}
}
if quote_specs.is_empty()
&& let Some(qc) = "e_currency_meta
{
quote_specs.push(QuoteSpec {
quote_currency: qc.clone(),
mapping: None,
});
}
let first_mapping = quote_specs.first().and_then(|qs| qs.mapping.clone());
let first_quote = quote_specs.first().map(|qs| qs.quote_currency.clone());
let info = DiscoveredCommodity {
mapping: first_mapping,
quote_currency: first_quote,
quote_specs,
};
let decision = if info.mapping.is_some() || info.quote_currency.is_some() {
DiscoveryDecision::Discovered(info)
} else {
DiscoveryDecision::Inherit
};
Classification {
decision,
malformed_price,
}
}
fn build_mapping(specs: &[PriceSpec]) -> Option<CommodityMapping> {
if specs.is_empty() {
return None;
}
if specs.len() == 1 {
let s = &specs[0];
return Some(CommodityMapping::Detailed(DetailedMapping {
source: SourceRef::Single(s.source.clone()),
ticker: Some(s.ticker.clone()),
quote_currency: None,
}));
}
let mut seen: HashSet<(String, String)> = HashSet::new();
let entries: Vec<FallbackEntry> = specs
.iter()
.filter(|s| seen.insert((s.source.clone(), s.ticker.clone())))
.map(|s| {
FallbackEntry::Detailed(FallbackDetail {
source: s.source.clone(),
ticker: Some(s.ticker.clone()),
})
})
.collect();
if entries.len() == 1
&& let FallbackEntry::Detailed(d) = &entries[0]
{
return Some(CommodityMapping::Detailed(DetailedMapping {
source: SourceRef::Single(d.source.clone()),
ticker: d.ticker.clone(),
quote_currency: None,
}));
}
Some(CommodityMapping::Detailed(DetailedMapping {
source: SourceRef::Fallback(entries),
ticker: Some(specs[0].ticker.clone()),
quote_currency: None,
}))
}
fn parse_price_metadata(raw: &str) -> Vec<PriceSpec> {
let mut specs = Vec::new();
for block in raw.split([' ', '\t', ';']) {
let block = block.trim();
if block.is_empty() {
continue;
}
let Some((block_quote, sources_str)) = block.split_once(':') else {
continue;
};
let block_quote = block_quote.trim();
if block_quote.is_empty() {
continue;
}
for entry in sources_str.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let Some((before_slash, ticker)) = entry.split_once('/') else {
continue;
};
let (effective_quote, source) = if let Some((q, src)) = before_slash.split_once(':') {
(q.trim(), src.trim())
} else {
(block_quote, before_slash.trim())
};
let ticker = ticker.trim();
if effective_quote.is_empty() || source.is_empty() || ticker.is_empty() {
continue;
}
specs.push(PriceSpec {
quote_currency: effective_quote.to_string(),
source: source.to_string(),
ticker: ticker.to_string(),
});
}
}
specs
}
fn metavalue_as_str(v: &MetaValue) -> Option<&str> {
match v {
MetaValue::String(s) => Some(s.as_str()),
MetaValue::Currency(c) => Some(c.as_str()),
_ => None,
}
}
fn looks_like_ticker(symbol: &str) -> bool {
!symbol.is_empty()
&& symbol.len() <= 10
&& symbol
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '-' || c == '.')
}
fn active_commodities(
directives: &[Spanned<Directive>],
options: &Options,
as_of: Option<NaiveDate>,
) -> HashSet<String> {
let assets_prefix = format!("{}:", options.name_assets);
let liabilities_prefix = format!("{}:", options.name_liabilities);
let is_balance_sheet = |account: &str| {
account.starts_with(&assets_prefix) || account.starts_with(&liabilities_prefix)
};
let in_window = |date: NaiveDate| as_of.is_none_or(|cutoff| date <= cutoff);
let mut balances: HashMap<(String, String), Decimal> = HashMap::new();
let mut closed: HashSet<String> = HashSet::new();
for spanned in directives {
match &spanned.value {
Directive::Transaction(txn) => {
if !in_window(txn.date) {
continue;
}
for posting in &txn.postings {
let account = posting.account.as_str();
if !is_balance_sheet(account) {
continue;
}
if let Some(amount) = posting.amount() {
let key = (account.to_string(), amount.currency.to_string());
*balances.entry(key).or_default() += amount.number;
}
}
}
Directive::Close(close) => {
if !in_window(close.date) {
continue;
}
closed.insert(close.account.to_string());
}
_ => {}
}
}
let mut active: HashSet<String> = HashSet::new();
for ((account, currency), amount) in &balances {
if !amount.is_zero() && !closed.contains(account) {
active.insert(currency.clone());
}
}
active
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use rustledger_core::{Amount, Close, Commodity, NaiveDate, Open, Posting, Transaction};
use rustledger_parser::Span;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
rustledger_core::naive_date(y, m, d).unwrap()
}
fn directives(items: Vec<Directive>) -> Vec<Spanned<Directive>> {
items
.into_iter()
.map(|d| Spanned::new(d, Span::ZERO))
.collect()
}
#[test]
fn parses_single_price_spec() {
let specs = parse_price_metadata("USD:yahoo/AAPL");
assert_eq!(
specs,
vec![PriceSpec {
quote_currency: "USD".into(),
source: "yahoo".into(),
ticker: "AAPL".into(),
}]
);
}
#[test]
fn parses_chained_price_specs() {
let specs = parse_price_metadata("USD:yahoo/AAPL, USD:google/NASDAQ:AAPL");
assert_eq!(specs.len(), 2);
assert_eq!(specs[0].source, "yahoo");
assert_eq!(specs[1].source, "google");
assert_eq!(specs[1].ticker, "NASDAQ:AAPL");
}
#[test]
fn build_mapping_dedups_identical_fallback_entries() {
let specs = parse_price_metadata("USD:yahoo/AAPL,USD:yahoo/AAPL");
assert_eq!(specs.len(), 2, "parser preserves duplicates as written");
let m = build_mapping(&specs).expect("mapping should be built");
match m {
CommodityMapping::Detailed(d) => match d.source {
SourceRef::Single(s) => assert_eq!(s, "yahoo"),
SourceRef::Fallback(entries) => {
panic!("duplicates should collapse to Single, got Fallback({entries:?})")
}
},
CommodityMapping::Simple(_) => panic!("expected Detailed mapping"),
}
}
#[test]
fn build_mapping_dedups_in_fallback_chain_preserves_distinct_entries() {
let specs = parse_price_metadata("USD:yahoo/AAPL,USD:ecb/AAPL,USD:yahoo/AAPL");
assert_eq!(specs.len(), 3);
let m = build_mapping(&specs).expect("mapping should be built");
match m {
CommodityMapping::Detailed(d) => match d.source {
SourceRef::Fallback(entries) => {
assert_eq!(entries.len(), 2, "duplicate yahoo dropped");
match &entries[0] {
FallbackEntry::Detailed(fd) => assert_eq!(fd.source, "yahoo"),
FallbackEntry::Name(_) => panic!("expected Detailed entry"),
}
match &entries[1] {
FallbackEntry::Detailed(fd) => assert_eq!(fd.source, "ecb"),
FallbackEntry::Name(_) => panic!("expected Detailed entry"),
}
}
SourceRef::Single(_) => panic!("expected Fallback chain of 2"),
},
CommodityMapping::Simple(_) => panic!("expected Detailed mapping"),
}
}
#[test]
fn parse_price_skips_malformed_entries() {
let specs = parse_price_metadata("USD:yahoo/AAPL,bogus,EUR:ecb/EUR.USD");
assert_eq!(specs.len(), 2);
assert_eq!(specs[0].quote_currency, "USD");
assert_eq!(specs[1].quote_currency, "EUR");
}
#[test]
fn parses_bean_price_multi_source_form() {
let specs = parse_price_metadata("EUR:ecbrates/GBP-EUR,ecb/GBP");
assert_eq!(
specs,
vec![
PriceSpec {
quote_currency: "EUR".into(),
source: "ecbrates".into(),
ticker: "GBP-EUR".into(),
},
PriceSpec {
quote_currency: "EUR".into(),
source: "ecb".into(),
ticker: "GBP".into(),
},
]
);
}
#[test]
fn parses_bean_price_multi_currency_form() {
let specs = parse_price_metadata("USD:yahoo/AAPL CAD:google/AAPL");
assert_eq!(specs.len(), 2);
assert_eq!(specs[0].quote_currency, "USD");
assert_eq!(specs[0].source, "yahoo");
assert_eq!(specs[1].quote_currency, "CAD");
assert_eq!(specs[1].source, "google");
let specs = parse_price_metadata("USD:yahoo/AAPL;CAD:google/AAPL");
assert_eq!(specs.len(), 2);
}
#[test]
fn parses_mixed_form() {
let specs = parse_price_metadata("USD:yahoo/AAPL,USD:google/NASDAQ:AAPL");
assert_eq!(specs.len(), 2);
assert_eq!(specs[0].source, "yahoo");
assert_eq!(specs[1].source, "google");
assert_eq!(specs[1].ticker, "NASDAQ:AAPL");
}
#[test]
fn parses_ticker_with_embedded_colon_in_bean_price_form() {
let specs = parse_price_metadata("USD:google/NASDAQ:AAPL");
assert_eq!(specs.len(), 1);
assert_eq!(specs[0].quote_currency, "USD");
assert_eq!(specs[0].source, "google");
assert_eq!(specs[0].ticker, "NASDAQ:AAPL");
}
#[test]
fn ticker_heuristic_rejects_long_or_lowercase() {
assert!(looks_like_ticker("AAPL"));
assert!(looks_like_ticker("BTC-USD"));
assert!(looks_like_ticker("VTI2025"));
assert!(looks_like_ticker("VECP.AS"));
assert!(looks_like_ticker("BRK.B"));
assert!(looks_like_ticker("7203.T"));
assert!(!looks_like_ticker("usd"));
assert!(!looks_like_ticker("Vanguard"));
assert!(!looks_like_ticker("VERYLONGTICKER"));
assert!(!looks_like_ticker(""));
}
#[test]
fn active_filter_keeps_held_commodities() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "AAPL")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "BTC")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy AAPL")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(100), "AAPL"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "AAPL"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 1), "Buy BTC")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(1), "BTC"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1), "BTC"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 4, 1), "Sell BTC")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(-1), "BTC"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(1), "BTC"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 5, 1), "Receive EUR")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(500), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-500), "EUR"),
)),
),
]);
let active = active_commodities(&dirs, &Options::new(), None);
assert!(active.contains("AAPL"));
assert!(active.contains("EUR"));
assert!(
!active.contains("BTC"),
"BTC was fully sold, should not be active"
);
}
#[test]
fn as_of_filter_includes_holdings_at_that_date_only() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "buy SHIB")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(100), "SHIB"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "SHIB"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 6, 1), "sell SHIB")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(-100), "SHIB"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(100), "SHIB"),
)),
),
]);
let active_now = active_commodities(&dirs, &Options::new(), None);
assert!(!active_now.contains("SHIB"));
let active_during = active_commodities(&dirs, &Options::new(), Some(date(2024, 4, 1)));
assert!(
active_during.contains("SHIB"),
"SHIB held on 2024-04-01 should be active when fetching as-of that date"
);
let active_after = active_commodities(&dirs, &Options::new(), Some(date(2024, 12, 31)));
assert!(!active_after.contains("SHIB"));
let active_buy_day = active_commodities(&dirs, &Options::new(), Some(date(2024, 2, 1)));
assert!(active_buy_day.contains("SHIB"));
let active_sell_day = active_commodities(&dirs, &Options::new(), Some(date(2024, 6, 1)));
assert!(!active_sell_day.contains("SHIB"));
}
#[test]
fn as_of_ignores_close_directive_after_cutoff() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "buy")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "DOGE"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-10), "DOGE"),
)),
),
Directive::Close(Close::new(date(2024, 12, 31), "Assets:Brokerage")),
]);
let active = active_commodities(&dirs, &Options::new(), Some(date(2024, 6, 1)));
assert!(active.contains("DOGE"));
let active_after = active_commodities(&dirs, &Options::new(), Some(date(2025, 1, 1)));
assert!(!active_after.contains("DOGE"));
let active_close_day = active_commodities(&dirs, &Options::new(), Some(date(2024, 12, 31)));
assert!(!active_close_day.contains("DOGE"));
}
#[test]
fn as_of_filters_commodity_directives_before_walking_them() {
let mut early = Commodity::new(date(2024, 1, 1), "AAPL");
early.meta.insert(
"price".to_string(),
MetaValue::String("USD:yahoo/AAPL".into()),
);
let mut late = Commodity::new(date(2030, 1, 1), "FUTURECOIN");
late.meta.insert(
"price".to_string(),
MetaValue::String("USD:yahoo/FUTURECOIN".into()),
);
let dirs = directives(vec![
Directive::Commodity(early),
Directive::Commodity(late),
]);
let all = discover_symbols(&dirs, &Options::new(), true, false, None, &HashMap::new());
assert!(all.contains_key("AAPL"));
assert!(all.contains_key("FUTURECOIN"));
let historical = discover_symbols(
&dirs,
&Options::new(),
true,
false,
Some(date(2025, 6, 1)),
&HashMap::new(),
);
assert!(historical.contains_key("AAPL"));
assert!(
!historical.contains_key("FUTURECOIN"),
"commodity declared 2030-01-01 must not be discovered when fetching as-of 2025-06-01"
);
let on_decl_day = discover_symbols(
&dirs,
&Options::new(),
true,
false,
Some(date(2030, 1, 1)),
&HashMap::new(),
);
assert!(!on_decl_day.contains_key("FUTURECOIN"));
let after_decl = discover_symbols(
&dirs,
&Options::new(),
true,
false,
Some(date(2030, 1, 2)),
&HashMap::new(),
);
assert!(after_decl.contains_key("FUTURECOIN"));
}
#[test]
fn closed_account_balance_does_not_count_as_active() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy stale token")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "DEFUNCT"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-10), "DEFUNCT"),
)),
),
Directive::Close(Close::new(date(2024, 12, 31), "Assets:Brokerage")),
]);
let active = active_commodities(&dirs, &Options::new(), None);
assert!(!active.contains("DEFUNCT"));
}
#[test]
fn undeclared_walks_transactions_for_ticker_shaped_currencies() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy SHIB and BAM")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(100), "SHIB"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "SHIB"),
))
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(50), "BAM"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-50), "BAM"),
)),
),
]);
let strict = discover_symbols(&dirs, &Options::new(), false, false, None, &HashMap::new());
assert!(strict.is_empty());
let with_undeclared =
discover_symbols(&dirs, &Options::new(), false, true, None, &HashMap::new());
assert!(
with_undeclared.contains_key("SHIB"),
"ticker-shaped commodity in transactions should be discovered with --undeclared"
);
assert!(
with_undeclared.contains_key("BAM"),
"3-letter uppercase currency codes also pass the heuristic — \
documents the known false-positive exposure of --undeclared"
);
}
#[test]
fn undeclared_walks_transactions_picks_up_at_cost_currency() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Wallet")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy ETH with BTC")
.with_synthesized_posting(
Posting::new("Assets:Wallet", Amount::new(dec!(1), "ETH"))
.with_cost(rustledger_core::CostSpec::empty().with_currency("BTC-USD")),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-50000), "USD"),
)),
),
]);
let with_undeclared =
discover_symbols(&dirs, &Options::new(), true, true, None, &HashMap::new());
assert!(with_undeclared.contains_key("ETH"));
assert!(
with_undeclared.contains_key("BTC-USD"),
"at-cost currency should also be discovered with --undeclared"
);
assert!(with_undeclared.contains_key("USD"));
}
#[test]
fn undeclared_transaction_walk_uses_strict_less_than_for_as_of() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 3, 1), "boundary day SHIB buy")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(100), "SHIB"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "SHIB"),
)),
),
]);
let at_cutoff = discover_symbols(
&dirs,
&Options::new(),
true,
true,
Some(date(2024, 3, 1)),
&HashMap::new(),
);
assert!(
!at_cutoff.contains_key("SHIB"),
"transaction dated exactly on as_of must be excluded (strict less-than, matches bean-price)"
);
let after_cutoff = discover_symbols(
&dirs,
&Options::new(),
true,
true,
Some(date(2024, 3, 2)),
&HashMap::new(),
);
assert!(after_cutoff.contains_key("SHIB"));
}
#[test]
fn undeclared_lowercase_or_long_names_still_excluded() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:X")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Y")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "use weird names")
.with_synthesized_posting(Posting::new(
"Assets:X",
Amount::new(dec!(1), "Vanguard"),
))
.with_synthesized_posting(Posting::new(
"Equity:Y",
Amount::new(dec!(-1), "Vanguard"),
)),
),
]);
let discovered =
discover_symbols(&dirs, &Options::new(), true, true, None, &HashMap::new());
assert!(
!discovered.contains_key("Vanguard"),
"non-uppercase name should not be picked up even with --undeclared"
);
}
#[test]
fn discover_picks_up_metadata_driven_commodity() {
let mut comm = Commodity::new(date(2024, 1, 1), "Vanguard_VTI");
comm.meta.insert(
"price".to_string(),
MetaValue::String("USD:yahoo/VTI".into()),
);
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(comm),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy VTI")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "Vanguard_VTI"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-10), "Vanguard_VTI"),
)),
),
]);
let discovered =
discover_symbols(&dirs, &Options::new(), false, false, None, &HashMap::new());
let info = discovered
.get("Vanguard_VTI")
.expect("should be discovered");
assert!(info.mapping.is_some());
assert_eq!(info.quote_currency.as_deref(), Some("USD"));
assert_eq!(info.quote_specs.len(), 1);
assert_eq!(info.quote_specs[0].quote_currency, "USD");
}
#[test]
fn discover_picks_up_multi_currency_price_metadata() {
let mut comm = Commodity::new(date(2024, 1, 1), "AAPL");
comm.meta.insert(
"price".to_string(),
MetaValue::String("USD:yahoo/AAPL CAD:google/AAPL".into()),
);
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(comm),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy AAPL")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "AAPL"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-10), "AAPL"),
)),
),
]);
let discovered =
discover_symbols(&dirs, &Options::new(), false, false, None, &HashMap::new());
let info = discovered.get("AAPL").expect("should be discovered");
assert_eq!(
info.quote_specs.len(),
2,
"multi-currency price: metadata must produce one QuoteSpec per quote"
);
assert_eq!(info.quote_specs[0].quote_currency, "USD");
assert_eq!(info.quote_specs[1].quote_currency, "CAD");
match info.quote_specs[0].mapping.as_ref().expect("USD mapping") {
CommodityMapping::Detailed(d) => match &d.source {
SourceRef::Single(s) => assert_eq!(s, "yahoo"),
SourceRef::Fallback(_) => panic!("expected Single yahoo for USD"),
},
CommodityMapping::Simple(_) => panic!("expected Detailed mapping for USD"),
}
match info.quote_specs[1].mapping.as_ref().expect("CAD mapping") {
CommodityMapping::Detailed(d) => match &d.source {
SourceRef::Single(s) => assert_eq!(s, "google"),
SourceRef::Fallback(_) => panic!("expected Single google for CAD"),
},
CommodityMapping::Simple(_) => panic!("expected Detailed mapping for CAD"),
}
assert_eq!(info.quote_currency.as_deref(), Some("USD"));
assert!(info.mapping.is_some());
}
#[test]
fn discover_skips_inactive_by_default() {
let mut comm = Commodity::new(date(2024, 1, 1), "OLD");
comm.meta.insert(
"price".to_string(),
MetaValue::String("USD:yahoo/OLD".into()),
);
let dirs = directives(vec![Directive::Commodity(comm)]);
let discovered =
discover_symbols(&dirs, &Options::new(), false, false, None, &HashMap::new());
assert!(!discovered.contains_key("OLD"));
let discovered_all =
discover_symbols(&dirs, &Options::new(), true, false, None, &HashMap::new());
assert!(discovered_all.contains_key("OLD"));
}
#[test]
fn discover_returns_empty_for_empty_ledger() {
let dirs: Vec<Spanned<Directive>> = vec![];
let discovered =
discover_symbols(&dirs, &Options::new(), false, false, None, &HashMap::new());
assert!(discovered.is_empty());
}
#[test]
fn discover_skips_no_metadata_commodity_by_default() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "BAM")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Receive BAM")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(100), "BAM"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "BAM"),
)),
),
]);
let strict = discover_symbols(&dirs, &Options::new(), false, false, None, &HashMap::new());
assert!(
!strict.contains_key("BAM"),
"BAM has no `price:` metadata, must not be discovered by default (#962)"
);
let with_undeclared =
discover_symbols(&dirs, &Options::new(), false, true, None, &HashMap::new());
assert!(with_undeclared.contains_key("BAM"));
}
#[test]
fn discover_honors_empty_price_opt_out() {
let mut comm = Commodity::new(date(2024, 1, 1), "BAM");
comm.meta
.insert("price".to_string(), MetaValue::String(String::new()));
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(comm),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Receive BAM")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(100), "BAM"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "BAM"),
)),
),
]);
let discovered =
discover_symbols(&dirs, &Options::new(), false, true, None, &HashMap::new());
assert!(!discovered.contains_key("BAM"));
}
#[test]
fn discover_picks_up_quote_currency_only_commodity() {
let mut comm = Commodity::new(date(2024, 1, 1), "GOVT_EU");
comm.meta.insert(
"quote_currency".to_string(),
MetaValue::String("EUR".into()),
);
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(comm),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy GOVT_EU")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "GOVT_EU"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-10), "GOVT_EU"),
)),
),
]);
let discovered =
discover_symbols(&dirs, &Options::new(), false, false, None, &HashMap::new());
let info = discovered
.get("GOVT_EU")
.expect("quote_currency: alone should opt into discovery");
assert!(info.mapping.is_none());
assert_eq!(info.quote_currency.as_deref(), Some("EUR"));
}
#[test]
fn discover_inactive_undeclared_combined_matches_legacy_all_commodities() {
let dirs = directives(vec![Directive::Commodity(Commodity::new(
date(2024, 1, 1),
"OLD",
))]);
let discovered =
discover_symbols(&dirs, &Options::new(), true, true, None, &HashMap::new());
assert!(discovered.contains_key("OLD"));
}
#[test]
fn discover_includes_config_mapped_commodity_without_metadata() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "PSK")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "buy")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "PSK"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-10), "PSK"),
)),
),
]);
let mut config_mapping = HashMap::new();
config_mapping.insert(
"PSK".to_string(),
CommodityMapping::Detailed(DetailedMapping {
source: SourceRef::Single("mybank".to_string()),
ticker: Some("PSK".to_string()),
quote_currency: Some("USD".to_string()),
}),
);
let discovered =
discover_symbols(&dirs, &Options::new(), false, false, None, &config_mapping);
assert!(
discovered.contains_key("PSK"),
"PSK has a config mapping and is held — must be discovered without metadata"
);
}
#[test]
fn discover_skips_config_mapped_commodity_not_in_ledger() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
]);
let mut config_mapping = HashMap::new();
config_mapping.insert(
"PSK".to_string(),
CommodityMapping::Simple("PSK".to_string()),
);
let discovered =
discover_symbols(&dirs, &Options::new(), true, false, None, &config_mapping);
assert!(
!discovered.contains_key("PSK"),
"PSK is in config but absent from ledger — must NOT be discovered"
);
}
#[test]
fn discover_config_mapped_commodity_respects_active_filter() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "PSK")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "buy")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "PSK"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-10), "PSK"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 1), "sell")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(-10), "PSK"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(10), "PSK"),
)),
),
]);
let mut config_mapping = HashMap::new();
config_mapping.insert(
"PSK".to_string(),
CommodityMapping::Simple("PSK".to_string()),
);
let strict = discover_symbols(&dirs, &Options::new(), false, false, None, &config_mapping);
assert!(
!strict.contains_key("PSK"),
"inactive holding skipped under default (active filter applies)"
);
let inclusive =
discover_symbols(&dirs, &Options::new(), true, false, None, &config_mapping);
assert!(
inclusive.contains_key("PSK"),
"--inactive opts the config-mapped commodity in"
);
}
#[test]
fn discover_opt_out_metadata_wins_over_config_mapping() {
let mut comm = Commodity::new(date(2024, 1, 1), "PSK");
comm.meta
.insert("price".to_string(), MetaValue::String(String::new()));
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(comm),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "buy")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "PSK"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-10), "PSK"),
)),
),
]);
let mut config_mapping = HashMap::new();
config_mapping.insert(
"PSK".to_string(),
CommodityMapping::Simple("PSK".to_string()),
);
let discovered =
discover_symbols(&dirs, &Options::new(), false, false, None, &config_mapping);
assert!(
!discovered.contains_key("PSK"),
"explicit `price: \"\"` opt-out must override config mapping"
);
}
#[test]
fn discover_metadata_precedence_unchanged_by_config_mapping_pass() {
let mut comm = Commodity::new(date(2024, 1, 1), "PSK");
comm.meta.insert(
"price".to_string(),
MetaValue::String("USD:yahoo/PSK".into()),
);
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(comm),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "buy")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "PSK"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-10), "PSK"),
)),
),
]);
let mut config_mapping = HashMap::new();
config_mapping.insert(
"PSK".to_string(),
CommodityMapping::Simple("PSK".to_string()),
);
let discovered =
discover_symbols(&dirs, &Options::new(), false, false, None, &config_mapping);
let info = discovered.get("PSK").expect("PSK discovered");
assert!(
info.mapping.is_some(),
"metadata-driven discovery should leave info.mapping set; \
the config pass must not overwrite it with default"
);
}
#[test]
fn classify_flags_malformed_price_metadata() {
let mut meta = rustledger_core::Metadata::default();
meta.insert(
"price".to_string(),
MetaValue::String("BOGUS_FORMAT".into()),
);
let classification = classify_commodity_meta(&meta);
assert!(classification.malformed_price);
assert!(matches!(
classification.decision,
DiscoveryDecision::Inherit
));
}
#[test]
fn classify_flags_malformed_price_even_when_quote_currency_present() {
let mut meta = rustledger_core::Metadata::default();
meta.insert(
"price".to_string(),
MetaValue::String("BOGUS_FORMAT".into()),
);
meta.insert(
"quote_currency".to_string(),
MetaValue::String("EUR".into()),
);
let classification = classify_commodity_meta(&meta);
assert!(classification.malformed_price);
assert!(matches!(
classification.decision,
DiscoveryDecision::Discovered(_)
));
}
#[test]
fn classify_does_not_flag_empty_price_as_malformed() {
let mut meta = rustledger_core::Metadata::default();
meta.insert("price".to_string(), MetaValue::String(String::new()));
let classification = classify_commodity_meta(&meta);
assert!(!classification.malformed_price);
assert!(matches!(classification.decision, DiscoveryDecision::OptOut));
}
#[test]
fn discover_honors_whitespace_only_price_opt_out() {
let mut comm = Commodity::new(date(2024, 1, 1), "BAM");
comm.meta
.insert("price".to_string(), MetaValue::String(" ".into()));
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Commodity(comm),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Receive BAM")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(100), "BAM"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "BAM"),
)),
),
]);
let discovered =
discover_symbols(&dirs, &Options::new(), false, true, None, &HashMap::new());
assert!(!discovered.contains_key("BAM"));
}
#[test]
fn active_filter_handles_explicit_amounts_on_balance_sheet_side() {
let dirs = directives(vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy AAPL")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(100), "AAPL"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "AAPL"),
)),
),
]);
let active = active_commodities(&dirs, &Options::new(), None);
assert!(active.contains("AAPL"));
}
}