use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use crate::parser::entry::Entry;
use crate::parser::located::Located;
use crate::parser::posting::{Costs, Posting};
use crate::parser::transaction::Transaction;
use crate::parser::entry::Price;
pub mod error;
pub use error::ResolveError;
#[derive(Debug, Clone)]
pub struct Resolved {
pub transactions: Vec<Located<Transaction>>,
pub prices: Vec<Located<Price>>,
pub fx_realized_gain: Option<String>,
pub fx_realized_loss: Option<String>,
pub cta_gain: Option<String>,
pub cta_loss: Option<String>,
pub capital_gain: Option<String>,
pub capital_loss: Option<String>,
pub fx_unrealized_gain: Option<String>,
pub fx_unrealized_loss: Option<String>,
pub precisions: HashMap<String, usize>,
pub aliases: HashMap<String, String>,
pub auto_rules: Vec<crate::parser::entry::AutoRule>,
}
pub fn resolve(entries: Vec<Located<Entry>>) -> Result<Resolved, ResolveError> {
let (aliases, roles, precisions) = collect_declarations(&entries)?;
let fx_realized_gain = roles.get("fx-realized gain").cloned();
let fx_realized_loss = roles.get("fx-realized loss").cloned();
let cta_gain = roles.get("cta gain").cloned();
let cta_loss = roles.get("cta loss").cloned();
let capital_gain = roles.get("capital gain").cloned();
let capital_loss = roles.get("capital loss").cloned();
let fx_unrealized_gain = roles.get("fx-unrealized gain").cloned();
let fx_unrealized_loss = roles.get("fx-unrealized loss").cloned();
let mut interner: HashSet<Arc<str>> = HashSet::new();
let mut arc_aliases: HashMap<String, Arc<str>> = HashMap::new();
for (alias, primary) in &aliases {
let primary_arc = intern_str(&mut interner, primary.as_str());
arc_aliases.insert(alias.clone(), primary_arc);
}
let mut transactions = Vec::new();
let mut prices = Vec::new();
let mut auto_rules = Vec::new();
for Located { file, line, value } in entries {
match value {
Entry::Price(mut p) => {
p.base = resolve_arc(&mut interner, &arc_aliases, p.base);
p.quote = resolve_arc(&mut interner, &arc_aliases, p.quote);
prices.push(Located { file, line, value: p });
}
Entry::Transaction(mut tx) => {
if tx.postings.len() < 2 {
return Err(ResolveError::new(
file.clone(),
line,
format!(
"transaction `{}` must have at least two postings, got {}",
tx.description.trim(),
tx.postings.len()
),
));
}
for lp in &mut tx.postings {
apply_to_posting(&mut lp.value, &aliases);
if let Some(name) = resolve_role_account(&lp.value.account, &roles) {
lp.value.account = name;
}
}
transactions.push(Located { file, line, value: tx });
}
Entry::AutoRule(mut rule) => {
if rule.postings.is_empty() {
return Err(ResolveError::new(
file.clone(),
line,
"auto-rule has no postings",
));
}
let mut total = crate::decimal::Decimal::zero();
for p in &rule.postings {
total += p.multiplier;
}
if !total.is_zero() {
return Err(ResolveError::new(
file.clone(),
line,
format!(
"auto-rule multipliers must sum to zero, got {}",
total
),
));
}
for _ in &mut rule.postings {
}
auto_rules.push(rule);
}
_ => {}
}
}
transactions.sort_by_key(|a| a.value.date);
Ok(Resolved {
transactions,
prices,
fx_realized_gain,
fx_realized_loss,
cta_gain,
cta_loss,
capital_gain,
capital_loss,
fx_unrealized_gain,
fx_unrealized_loss,
precisions,
aliases,
auto_rules,
})
}
fn resolve_role_account(account: &str, roles: &HashMap<String, String>) -> Option<String> {
let token = account.strip_prefix('$')?;
roles.get(&token.replace(':', " ")).cloned()
}
fn intern_str(interner: &mut HashSet<Arc<str>>, s: &str) -> Arc<str> {
if let Some(existing) = interner.get(s) {
return existing.clone();
}
let arc: Arc<str> = Arc::from(s);
interner.insert(arc.clone());
arc
}
fn resolve_arc(
interner: &mut HashSet<Arc<str>>,
aliases: &HashMap<String, Arc<str>>,
arc: Arc<str>,
) -> Arc<str> {
if let Some(primary) = aliases.get(arc.as_ref()) {
return primary.clone();
}
if let Some(existing) = interner.get(arc.as_ref()) {
return existing.clone();
}
interner.insert(arc.clone());
arc
}
fn collect_declarations(
entries: &[Located<Entry>],
) -> Result<
(
HashMap<String, String>,
HashMap<String, String>,
HashMap<String, usize>,
),
ResolveError,
> {
let mut aliases: HashMap<String, String> = HashMap::new();
let mut roles: HashMap<String, Declaration> = HashMap::new();
let mut precisions: HashMap<String, usize> = HashMap::new();
for e in entries {
match &e.value {
Entry::Commodity { symbol, aliases: list, precision } => {
for a in list {
if let Some(existing) = aliases.get(a)
&& existing != symbol {
return Err(ResolveError::new(
e.file.clone(),
e.line,
format!(
"alias `{}` already maps to `{}`, cannot remap to `{}`",
a, existing, symbol
),
));
}
aliases.insert(a.clone(), symbol.clone());
}
if let Some(p) = precision {
precisions.insert(symbol.clone(), *p);
}
}
Entry::RoleAccount { role, account } => {
if let Some(prev) = roles.get(role)
&& prev.name != *account {
return Err(ResolveError::new(
e.file.clone(),
e.line,
format!(
"`{}` account already set to `{}` at line {}",
role, prev.name, prev.line
),
));
}
roles.insert(role.clone(), Declaration { line: e.line, name: account.clone() });
}
_ => {}
}
}
Ok((
aliases,
roles.into_iter().map(|(role, d)| (role, d.name)).collect(),
precisions,
))
}
struct Declaration {
line: usize,
name: String,
}
fn apply_alias(commodity: &mut String, aliases: &HashMap<String, String>) {
if let Some(primary) = aliases.get(commodity) {
*commodity = primary.clone();
}
}
fn apply_to_posting(p: &mut Posting, aliases: &HashMap<String, String>) {
if let Some(a) = &mut p.amount {
apply_alias(&mut a.commodity, aliases);
}
if let Some(c) = &mut p.costs {
let a = match c {
Costs::Total(a) | Costs::PerUnit(a) => a,
};
apply_alias(&mut a.commodity, aliases);
}
if let Some(a) = &mut p.balance_assertion {
apply_alias(&mut a.commodity, aliases);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser;
fn parsed(src: &str) -> Vec<Located<Entry>> {
parser::parse(src).unwrap()
}
#[test]
fn applies_alias_to_price() {
let src = "commodity USD\n alias $\nP 2024-06-15 $ EUR 0.92\n";
let out = resolve(parsed(src)).unwrap();
assert_eq!(out.prices.len(), 1);
assert_eq!(&*out.prices[0].value.base, "USD");
assert_eq!(&*out.prices[0].value.quote, "EUR");
}
#[test]
fn applies_alias_to_posting_amount() {
let src = "commodity USD\n alias $\n2024-06-15 * X\n expenses:food $5\n assets:cash $-5\n";
let out = resolve(parsed(src)).unwrap();
let amt = out.transactions[0].value.postings[0].value.amount.as_ref().unwrap();
assert_eq!(amt.commodity, "USD");
}
#[test]
fn extracts_fx_accounts() {
let src = "account Equity:FxGain\n fx-realized gain\naccount Equity:FxLoss\n fx-realized loss\n";
let out = resolve(parsed(src)).unwrap();
assert_eq!(out.fx_realized_gain.as_deref(), Some("Equity:FxGain"));
assert_eq!(out.fx_realized_loss.as_deref(), Some("Equity:FxLoss"));
}
#[test]
fn sorts_transactions_by_date() {
let src = "2024-06-15 * Later\n assets:cash 1 USD\n equity -1 USD\n\
2024-06-14 * Earlier\n assets:cash 2 USD\n equity -2 USD\n";
let out = resolve(parsed(src)).unwrap();
assert_eq!(out.transactions[0].value.description, "Earlier");
assert_eq!(out.transactions[1].value.description, "Later");
}
#[test]
fn conflicting_aliases_error() {
let src = "commodity USD\n alias $\ncommodity EUR\n alias $\n";
let err = resolve(parsed(src)).unwrap_err();
assert!(err.message.contains("alias"));
assert!(err.message.contains("$"));
}
#[test]
fn conflicting_fx_gain_error() {
let src = "account Equity:A\n fx-realized gain\naccount Equity:B\n fx-realized gain\n";
let err = resolve(parsed(src)).unwrap_err();
assert!(err.message.contains("fx-realized gain"));
}
#[test]
fn resolves_role_account_references() {
let src = "account in:cap:market\n capital gain\n\
account ex:cap:market\n capital loss\n\
account in:cap:cta\n cta gain\n\
2024-06-15 * sell\n assets:eth -6 EUR\n $capital:gain 2 EUR\n $capital:loss 2 EUR\n $cta:gain 2 EUR\n";
let out = resolve(parsed(src)).unwrap();
let acct = |i: usize| out.transactions[0].value.postings[i].value.account.as_str();
assert_eq!(acct(1), "in:cap:market"); assert_eq!(acct(2), "ex:cap:market"); assert_eq!(acct(3), "in:cap:cta"); }
#[test]
fn unresolved_role_reference_passes_through() {
let src = "account in:cap\n capital gain\n\
2024-06-15 * x\n a -2 EUR\n $fx:gain 1 EUR\n $captial:gain 1 EUR\n";
let out = resolve(parsed(src)).unwrap();
let acct = |i: usize| out.transactions[0].value.postings[i].value.account.as_str();
assert_eq!(acct(1), "$fx:gain");
assert_eq!(acct(2), "$captial:gain");
}
#[test]
fn plain_account_and_commodity_are_dropped() {
let src = "commodity USD\naccount Assets:Bank\n";
let out = resolve(parsed(src)).unwrap();
assert!(out.transactions.is_empty());
assert!(out.prices.is_empty());
assert!(out.fx_realized_gain.is_none());
assert!(out.fx_realized_loss.is_none());
}
}