use std::{collections::BTreeMap, fmt::Display};
use rust_decimal::Decimal;
use crate::{
ast::{self, AmountDetails, ValueExpr},
resolution,
};
#[derive(Default, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct LotKey {
pub(crate) cost: Option<Decimal>,
pub(crate) cost_commodity: Option<String>,
pub(crate) date: Option<chrono::NaiveDate>,
pub(crate) note: Option<String>,
}
#[derive(Default, Clone, Debug)]
struct AccountInventory {
positions: BTreeMap<(String, Option<LotKey>), Decimal>,
}
impl AccountInventory {
fn commodity_balance(&self, commodity: &str) -> Decimal {
self.positions
.iter()
.filter(|((c, _), _)| c == commodity)
.map(|(_, v)| *v)
.sum()
}
fn iter_positions(&self) -> impl Iterator<Item = (&(String, Option<LotKey>), &Decimal)> {
self.positions.iter().filter(|(_, v)| !v.is_zero())
}
fn commodities(&self) -> impl Iterator<Item = &String> {
self.positions.keys().map(|(c, _)| c)
}
fn add(&mut self, commodity: String, lot: Option<LotKey>, delta: Decimal) {
*self.positions.entry((commodity, lot)).or_default() += delta;
}
}
#[derive(Default, Clone, Debug)]
struct RunningState {
account_inventories: BTreeMap<String, AccountInventory>,
pending_pads: BTreeMap<String, PendingPad>,
}
#[derive(Clone, Debug, Default)]
struct PendingPad {
date: chrono::NaiveDate,
source_account: String,
padded_commodities: std::collections::BTreeSet<String>,
}
pub type Commodity = String;
#[derive(Default, Debug)]
pub struct Amount(pub BTreeMap<Commodity, Decimal>);
#[derive(Debug)]
pub enum TransactionState {
Uncleared,
Pending,
Cleared,
}
impl From<ast::TransactionState> for TransactionState {
fn from(f: ast::TransactionState) -> TransactionState {
match f {
ast::TransactionState::Uncleared => TransactionState::Uncleared,
ast::TransactionState::Pending => TransactionState::Pending,
ast::TransactionState::Cleared => TransactionState::Cleared,
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ElaborationError {
AmountWithNoCommodity,
NonAmountWhereAmountExpected(ValueExpr),
EvaluationError(EvaluationError),
PostingBalanceAssertionFailed,
BalanceAssertionFailed {
account: String,
date: chrono::NaiveDate,
expected_amount: Decimal,
expected_commodity: String,
actual_amount: Decimal,
},
TooManyNullPostings,
TransactionDoesNotBalance(Amount),
AccountAssertionFailed {
account: String,
posting_index: usize,
expression: String,
},
TagAssertionFailed {
tag_name: String,
tag_value: String,
expression: String,
},
TotalCostWithZeroUnits,
PhantomLotReduction {
account: String,
commodity: String,
lot: String,
},
AmbiguousLotMatch {
account: String,
commodity: String,
match_count: usize,
},
OverReductionInBooking {
account: String,
commodity: String,
requested: Decimal,
available: Decimal,
},
AugmentingPostingWithMissingCost {
account: String,
commodity: String,
},
}
#[derive(Debug)]
#[non_exhaustive]
pub enum EvaluationError {
UnaryMultiplyOrDivide,
UnaryOnNonAmount(ValueExpr),
BinaryOperationTypeError((ValueExpr, ValueExpr, crate::ast::Op)),
NoSuchField(String),
FieldAccessTypeError(ValueExpr),
UnknownFunctionArgs((String, Vec<ValueExpr>)),
TypedCommodityToIncompatibleAmount((String, ValueExpr)),
InvalidFunctionArgs((String, ValueExpr)),
InvalidRegexPattern(String, String),
BoolDefineInValueContext(String),
DefineArgCountMismatch {
name: String,
expected: usize,
got: usize,
},
RecursionLimitExceeded,
}
impl Display for EvaluationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EvaluationError::UnaryMultiplyOrDivide => {
write!(f, "* and / cannot be used as unary prefix operators")
}
EvaluationError::UnaryOnNonAmount(val) => {
write!(f, "unary operator applied to non-amount value: {val:?}")
}
EvaluationError::BinaryOperationTypeError((lhs, rhs, op)) => {
write!(f, "binary operation type mismatch: {lhs:?} {op:?} {rhs:?}")
}
EvaluationError::NoSuchField(field) => {
write!(f, "no such field: {field}")
}
EvaluationError::FieldAccessTypeError(val) => {
write!(f, "field access on non-object value: {val:?}")
}
EvaluationError::UnknownFunctionArgs((name, args)) => {
write!(
f,
"unknown function or wrong argument count: {name}({args:?})"
)
}
EvaluationError::TypedCommodityToIncompatibleAmount((commodity, val)) => {
write!(
f,
"commodity annotation '{commodity}' is incompatible with value: {val:?}"
)
}
EvaluationError::InvalidFunctionArgs((name, arg)) => {
write!(f, "invalid argument to function {name}: {arg:?}")
}
EvaluationError::InvalidRegexPattern(pattern, err) => {
write!(f, "invalid regex pattern /{pattern}/: {err}")
}
EvaluationError::BoolDefineInValueContext(name) => {
write!(
f,
"define '{name}' has a boolean body and cannot be used in a value expression"
)
}
EvaluationError::DefineArgCountMismatch {
name,
expected,
got,
} => {
write!(
f,
"define '{name}' expects {expected} argument(s), got {got}"
)
}
EvaluationError::RecursionLimitExceeded => {
write!(
f,
"expression evaluation exceeded recursion limit (likely a cyclic `define`)"
)
}
}
}
}
impl From<EvaluationError> for ElaborationError {
fn from(e: EvaluationError) -> ElaborationError {
ElaborationError::EvaluationError(e)
}
}
impl std::error::Error for ElaborationError {}
impl Display for ElaborationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ElaborationError::AmountWithNoCommodity => {
write!(f, "amount has no commodity and no default commodity is set")
}
ElaborationError::NonAmountWhereAmountExpected(expr) => {
write!(f, "expected an amount but got: {expr:?}")
}
ElaborationError::EvaluationError(e) => {
write!(f, "evaluation error: {e}")
}
ElaborationError::PostingBalanceAssertionFailed => {
write!(f, "posting balance assertion failed")
}
ElaborationError::BalanceAssertionFailed {
account,
date,
expected_amount,
expected_commodity,
actual_amount,
} => {
write!(
f,
"balance assertion failed for account {account} on {date}: \
expected {expected_amount} {expected_commodity}, \
actual {actual_amount} {expected_commodity}"
)
}
ElaborationError::AccountAssertionFailed {
account,
posting_index,
expression,
} => {
write!(
f,
"account assertion failed for posting {posting_index} to {account}: \
assert {expression}"
)
}
ElaborationError::TooManyNullPostings => {
write!(f, "transaction has more than one null posting")
}
ElaborationError::TransactionDoesNotBalance(_) => {
write!(f, "transaction does not balance")
}
ElaborationError::TagAssertionFailed {
tag_name,
tag_value,
expression,
} => {
write!(
f,
"tag assertion failed for {tag_name}: \"{tag_value}\": assert {expression}"
)
}
ElaborationError::TotalCostWithZeroUnits => {
write!(
f,
"`{{{{total}}}}` lot cost cannot be applied to a posting with zero units"
)
}
ElaborationError::PhantomLotReduction {
account,
commodity,
lot,
} => {
write!(
f,
"phantom-lot reduction: account `{account}` has no prior augmentation \
of `{commodity}` matching lot `{lot}`"
)
}
ElaborationError::AmbiguousLotMatch {
account,
commodity,
match_count,
} => {
write!(
f,
"ambiguous booking: account `{account}` has {match_count} \
matching lots of `{commodity}`; STRICT booking requires \
exactly one (use FIFO/LIFO/HIFO/AVERAGE on `open` to disambiguate)"
)
}
ElaborationError::OverReductionInBooking {
account,
commodity,
requested,
available,
} => {
write!(
f,
"over-reduction during booking: account `{account}` has \
{available} {commodity} available across matching lots, \
but the posting tried to reduce {requested}"
)
}
ElaborationError::AugmentingPostingWithMissingCost { account, commodity } => {
write!(
f,
"MISSING-cost lot annotation (`{{}}`) on an augmenting posting \
of `{commodity}` to `{account}`: doppio's booking implementation \
handles reductions only; spell out `{{cost}}` explicitly for augmentations"
)
}
}
}
}
pub fn elaborate(
value: resolution::HIR,
config: &resolution::ElaborationConfig,
) -> Result<crate::elaboration::Journal, ElaborationError> {
{
let mut state = RunningState::default();
let mut transactions = vec![];
let mut accounts = BTreeMap::new();
for (name, properties) in &value.global_context.account_properties {
accounts.insert(
name.clone(),
crate::elaboration::AccountProperties {
note: properties.note.clone(),
metadata: properties
.metadata
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
},
);
}
let tolerance_mode = config.tolerance_mode;
let tolerance_overrides = value.global_context.tolerance_overrides.clone();
let balance_mode = config.balance_mode.clone();
let assertion_scope = config.assertion_scope;
let lot_validation_mode = config.lot_validation_mode;
let auto_rules = value.auto_rules;
for entry in value.entries {
let entry_context = &value.contexts[entry.context_id];
match entry.data {
resolution::Entry::Pad(p) => {
state.pending_pads.insert(
p.target_account,
PendingPad {
date: p.date,
source_account: p.source_account,
padded_commodities: std::collections::BTreeSet::new(),
},
);
}
resolution::Entry::Assertion(assertion) => {
let (expected_amount, expected_commodity) =
evaluator::eval_and_normalize_amount(
assertion.amount,
entry_context,
&state,
)?;
let mut actual_amount = match assertion_scope {
crate::resolution::AssertionScope::Direct => state
.account_inventories
.get(&assertion.account)
.map(|inv| inv.commodity_balance(&expected_commodity))
.unwrap_or(Decimal::ZERO),
crate::resolution::AssertionScope::Subtree => subtree_commodity_balance(
&state.account_inventories,
&assertion.account,
)
.get(&expected_commodity)
.copied()
.unwrap_or(Decimal::ZERO),
};
let pad_should_fire = state
.pending_pads
.get(&assertion.account)
.map(|pad| !pad.padded_commodities.contains(&expected_commodity))
.unwrap_or(false);
if pad_should_fire
&& let Some(pad) = state.pending_pads.get(&assertion.account).cloned()
{
let diff = expected_amount - actual_amount;
if !diff.is_zero() {
for acct in [&assertion.account, &pad.source_account] {
if !accounts.contains_key(acct.as_str()) {
accounts.insert(acct.clone(), Default::default());
}
}
state
.account_inventories
.entry(assertion.account.clone())
.or_default()
.add(expected_commodity.clone(), None, diff);
state
.account_inventories
.entry(pad.source_account.clone())
.or_default()
.add(expected_commodity.clone(), None, -diff);
let pad_marker_meta: BTreeMap<String, String> =
[("pad".to_string(), pad.source_account.clone())]
.into_iter()
.collect();
let target_amount = crate::elaboration::Amount {
by_commodity: BTreeMap::from([(
expected_commodity.clone(),
crate::decimal_to_proto(diff),
)]),
};
let source_amount = crate::elaboration::Amount {
by_commodity: BTreeMap::from([(
expected_commodity.clone(),
crate::decimal_to_proto(-diff),
)]),
};
let synthesized_postings = vec![
crate::elaboration::Posting {
account: assertion.account.clone(),
payee: String::from("(pad)"),
amount: Some(target_amount),
state: crate::state_to_proto(&TransactionState::Cleared),
tags: vec![],
metadata: BTreeMap::new(),
kind: crate::posting_kind_to_proto(ast::PostingKind::Real),
lot: None,
},
crate::elaboration::Posting {
account: pad.source_account.clone(),
payee: String::from("(pad)"),
amount: Some(source_amount),
state: crate::state_to_proto(&TransactionState::Cleared),
tags: vec![],
metadata: BTreeMap::new(),
kind: crate::posting_kind_to_proto(ast::PostingKind::Real),
lot: None,
},
];
transactions.push(crate::elaboration::Transaction {
date: pad.date.to_epoch_days(),
secondary_date: None,
state: crate::state_to_proto(&TransactionState::Cleared),
code: None,
description: String::from(
"(padding inserted for balance assertion)",
),
tags: vec![],
metadata: pad_marker_meta,
postings: synthesized_postings,
});
actual_amount = expected_amount;
}
if let Some(pad_mut) = state.pending_pads.get_mut(&assertion.account) {
pad_mut
.padded_commodities
.insert(expected_commodity.clone());
}
}
let _ = assertion.strict;
if actual_amount != expected_amount {
return Err(ElaborationError::BalanceAssertionFailed {
account: assertion.account,
date: assertion.date,
expected_amount,
expected_commodity,
actual_amount,
});
}
}
resolution::Entry::Transaction(mut transaction) => {
let mut transaction_state = Amount(BTreeMap::default());
let mut cost_basis_state = Amount(BTreeMap::default());
let payee = transaction
.metadata
.remove("payee")
.unwrap_or_else(|| transaction.description.clone());
let mut null_postings = vec![];
let mut resolved_postings = vec![];
let mut booked_consumed: BTreeMap<(String, String, LotKey), Decimal> =
BTreeMap::new();
let account_properties = &value.global_context.account_properties;
if config.infer_implicit_total_cost {
infer_implicit_total_cost(
&mut transaction.postings,
entry_context,
&state,
)?;
}
for mut posting in transaction.postings {
let posting_kind = posting.kind;
if let Some(amount) = posting.amount {
let account_name = entry_context
.account_aliases
.get(&posting.account)
.cloned()
.unwrap_or(posting.account);
let account_balance = state.account_inventories.get(&account_name);
#[allow(clippy::type_complexity)]
let (value, commodity, lot_cash, proto_lot, cost_basis_cash): (
Decimal,
String,
Option<(Decimal, String)>,
Option<crate::elaboration::Lot>,
Option<(Decimal, String)>,
) = match amount {
AmountDetails::Amount {
value,
lot_annotation,
lot_pricing,
balance_assertion,
} => {
let (value, commodity) = evaluator::eval_and_normalize_amount(
value,
entry_context,
&state,
)?;
let (proto_lot, cost_for_balance) =
if let Some(ann) = lot_annotation {
let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1)
.expect("epoch is valid");
let is_augmenting =
!value.is_sign_negative() && !value.is_zero();
let lot_date = ann.date.or(
if is_augmenting && ann.cost.is_some() {
Some(transaction.date)
} else {
Option::None
},
);
let proto_date =
lot_date.map(|d| (d - epoch).num_days() as i32);
let cost_is_total = ann.cost_is_total;
let (proto_cost, cost_pair) =
if let Some(cost_expr) = ann.cost {
let (mut cv, cc) =
evaluator::eval_and_normalize_amount(
cost_expr,
entry_context,
&state,
)?;
if cost_is_total {
if value.is_zero() {
return Err(
ElaborationError::TotalCostWithZeroUnits,
);
}
cv /= value.abs();
}
let proto_amount = crate::elaboration::Amount {
by_commodity: BTreeMap::from([(
cc.clone(),
crate::decimal_to_proto(cv),
)]),
};
(Some(proto_amount), Some((cv, cc)))
} else {
(None, None)
};
let lot = crate::elaboration::Lot {
cost: proto_cost,
date: proto_date,
note: ann.note,
};
(Some(lot), cost_pair)
} else {
(None, None)
};
let cost_basis_cash = cost_for_balance
.as_ref()
.map(|(cv, cc)| (value * *cv, cc.clone()));
let at_price_cash = |lp: ast::LotPricing,
state: &RunningState|
-> Result<
Option<(Decimal, String)>,
ElaborationError,
> {
Ok(match lp {
ast::LotPricing::Total(expr) => {
let (mut v, c) =
evaluator::eval_and_normalize_amount(
expr,
entry_context,
state,
)?;
if value.is_sign_negative() {
v = -v;
}
Some((v, c))
}
ast::LotPricing::Unit(expr) => {
let (v, c) =
evaluator::eval_and_normalize_amount(
expr,
entry_context,
state,
)?;
Some((v * value, c))
}
})
};
let lot_cash = match &balance_mode {
resolution::BalanceMode::CostBasis => {
match &cost_basis_cash {
Some(_) => cost_basis_cash.clone(),
None => match lot_pricing {
Some(lp) => at_price_cash(lp, &state)?,
None => None,
},
}
}
resolution::BalanceMode::AtPriceWithSynthesis {
..
} => match lot_pricing {
Some(lp) => at_price_cash(lp, &state)?,
None => cost_basis_cash.clone(),
},
};
if let Some(balance_assertion) = balance_assertion {
let (baval, bacommodity) =
evaluator::eval_and_normalize_amount(
balance_assertion,
entry_context,
&state,
)?;
if !(bacommodity == commodity
&& account_balance
.map(|ab| ab.commodity_balance(&commodity))
.unwrap_or(Decimal::ZERO)
+ value
== baval)
{
Err(ElaborationError::PostingBalanceAssertionFailed)?;
}
}
(value, commodity, lot_cash, proto_lot, cost_basis_cash)
}
AmountDetails::BalanceAssignmentAllCommodities(target) => {
let target_value = evaluator::eval_amount_value(
target,
entry_context,
&state,
)?;
let deltas: Vec<(String, Decimal)> =
subtree_commodity_balance(
&state.account_inventories,
&account_name,
)
.into_iter()
.map(|(c, v)| (c, target_value - v))
.collect();
if !accounts.contains_key(&account_name) {
accounts.insert(account_name.clone(), Default::default());
}
let bal_entry = state
.account_inventories
.entry(account_name.clone())
.or_default();
let mut by_commodity: BTreeMap<
String,
crate::elaboration::Decimal,
> = BTreeMap::new();
if posting_kind != ast::PostingKind::VirtualUnbalanced {
for (c, delta) in &deltas {
*transaction_state.0.entry(c.clone()).or_default() +=
delta;
*cost_basis_state.0.entry(c.clone()).or_default() +=
delta;
}
}
for (c, delta) in &deltas {
bal_entry.add(c.clone(), None, *delta);
by_commodity
.insert(c.clone(), crate::decimal_to_proto(*delta));
}
let payee =
posting.metadata.remove("payee").unwrap_or(payee.clone());
resolved_postings.push(crate::elaboration::Posting {
account: account_name,
payee,
amount: Some(crate::elaboration::Amount { by_commodity }),
state: crate::state_to_proto(&posting.state.into()),
tags: posting.tags,
metadata: posting.metadata,
kind: crate::posting_kind_to_proto(posting_kind),
lot: None,
});
continue;
}
AmountDetails::BalanceAssignment(assignment) => {
let from_account = account_balance.and_then(|ab| {
let mut non_zero = ab
.commodities()
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.filter(|c| !ab.commodity_balance(c).is_zero())
.map(|c| c.as_str());
let first = non_zero.next()?;
non_zero.next().is_none().then_some(first)
});
let from_transaction = || {
let mut keys = transaction_state.0.keys();
let first = keys.next()?;
keys.next().is_none().then_some(first.as_str())
};
let inferred_commodity = from_account.or_else(from_transaction);
let (newsum, commodity) =
evaluator::eval_and_normalize_amount_with_fallback(
assignment,
entry_context,
&state,
inferred_commodity,
)?;
let value = newsum
- account_balance
.map(|ab| ab.commodity_balance(&commodity))
.unwrap_or(Decimal::ZERO);
(value, commodity, None, None, None)
}
};
let needs_booking = proto_lot.as_ref().is_some_and(|l| {
if l.cost.is_none() {
return true;
}
if !value.is_sign_negative() {
return false;
}
state
.account_inventories
.get(&account_name)
.is_some_and(|inv| {
inv.positions
.iter()
.any(|((c, _), v)| c == &commodity && !v.is_zero())
})
});
if needs_booking {
let booking_method = value_global_account_property(
&account_name,
account_properties,
config.default_booking_method,
);
if !matches!(booking_method, resolution::BookingMethod::None) {
let payee_for_booking =
posting.metadata.remove("payee").unwrap_or(payee.clone());
let booked = book_missing_cost_inline(
&account_name,
&payee_for_booking,
posting_kind,
&posting.state.into(),
&posting.tags,
&posting.metadata,
&commodity,
value,
proto_lot.as_ref().expect("checked"),
booking_method,
state.account_inventories.get(&account_name),
&mut booked_consumed,
)?;
if !accounts.contains_key(&account_name) {
accounts.insert(account_name.clone(), Default::default());
}
if posting_kind != ast::PostingKind::VirtualUnbalanced {
for (_, cash) in &booked {
let (cb_total, cb_commodity) = cash;
*transaction_state
.0
.entry(cb_commodity.clone())
.or_default() += *cb_total;
*cost_basis_state
.0
.entry(cb_commodity.clone())
.or_default() += *cb_total;
}
}
for (booked_posting, _) in booked {
resolved_postings.push(booked_posting);
}
continue;
}
}
let payee = posting.metadata.remove("payee").unwrap_or(payee.clone());
if posting_kind != ast::PostingKind::VirtualUnbalanced {
if let Some((lot_total, lot_commodity)) = &lot_cash {
let dec = transaction_state
.0
.entry(lot_commodity.clone())
.or_default();
*dec += *lot_total;
} else {
let dec =
transaction_state.0.entry(commodity.clone()).or_default();
*dec += value;
}
if let Some((cb_total, cb_commodity)) = &cost_basis_cash {
let dec =
cost_basis_state.0.entry(cb_commodity.clone()).or_default();
*dec += *cb_total;
} else if let Some((lot_total, lot_commodity)) = &lot_cash {
let dec = cost_basis_state
.0
.entry(lot_commodity.clone())
.or_default();
*dec += *lot_total;
} else {
let dec =
cost_basis_state.0.entry(commodity.clone()).or_default();
*dec += value;
}
}
let by_commodity =
BTreeMap::from([(commodity, crate::decimal_to_proto(value))]);
resolved_postings.push(crate::elaboration::Posting {
account: account_name,
payee,
amount: Some(crate::elaboration::Amount { by_commodity }),
state: crate::state_to_proto(&posting.state.into()),
tags: posting.tags,
metadata: posting.metadata,
kind: crate::posting_kind_to_proto(posting_kind),
lot: proto_lot,
});
} else {
null_postings.push(posting);
}
}
if null_postings.len() > 1 {
return Err(ElaborationError::TooManyNullPostings);
}
if let Some(mut posting) = null_postings.pop() {
let account_name = entry_context
.account_aliases
.get(&posting.account)
.cloned()
.unwrap_or(posting.account);
let payee = posting.metadata.remove("payee").unwrap_or(payee.clone());
let by_commodity: BTreeMap<String, _> = transaction_state
.0
.iter()
.map(|(c, v)| (c.clone(), crate::decimal_to_proto(-v)))
.collect();
for (c, v) in &transaction_state.0 {
*cost_basis_state.0.entry(c.clone()).or_default() -= *v;
}
resolved_postings.push(crate::elaboration::Posting {
account: account_name,
payee,
amount: Some(crate::elaboration::Amount { by_commodity }),
state: crate::state_to_proto(&posting.state.into()),
tags: posting.tags,
metadata: posting.metadata,
kind: crate::posting_kind_to_proto(ast::PostingKind::Real),
lot: None,
});
} else {
let residuals: Vec<(String, Decimal)> = transaction_state
.0
.iter()
.filter(|(_, v)| !v.is_zero())
.map(|(c, v)| (c.clone(), *v))
.collect();
let mut absorbed: BTreeMap<String, Decimal> = BTreeMap::new();
for (commodity, residual) in &residuals {
let tolerance =
if let Some(absolute) = tolerance_overrides.get(commodity) {
*absolute
} else {
let resolution::ToleranceMode::FractionOfSmallestPrecision(
fraction,
) = tolerance_mode;
if fraction.is_zero() {
Decimal::ZERO
} else {
let min_scale = resolved_postings
.iter()
.filter_map(|p| {
p.amount.as_ref()?.by_commodity.get(commodity)
})
.map(|d| d.scale)
.min()
.unwrap_or(0);
let one_unit = Decimal::new(1, min_scale);
fraction * one_unit
}
};
if residual.abs() > tolerance {
return Err(ElaborationError::TransactionDoesNotBalance(
transaction_state,
));
}
absorbed.insert(commodity.clone(), -*residual);
}
if !absorbed.is_empty() {
let by_commodity: BTreeMap<String, crate::elaboration::Decimal> =
absorbed
.iter()
.map(|(c, v)| (c.clone(), crate::decimal_to_proto(*v)))
.collect();
resolved_postings.push(crate::elaboration::Posting {
account: String::new(),
payee: payee.clone(),
amount: Some(crate::elaboration::Amount { by_commodity }),
state: crate::state_to_proto(&TransactionState::Cleared),
tags: vec![],
metadata: BTreeMap::new(),
kind: crate::posting_kind_to_proto(ast::PostingKind::Real),
lot: None,
});
}
}
if let resolution::BalanceMode::AtPriceWithSynthesis { gains_account } =
&balance_mode
{
let gains: BTreeMap<String, Decimal> = cost_basis_state
.0
.iter()
.filter(|(_, v)| !v.is_zero())
.map(|(c, v)| (c.clone(), -*v))
.collect();
if !gains.is_empty() {
if !accounts.contains_key(gains_account.as_str()) {
accounts.insert(gains_account.clone(), Default::default());
}
let gains_balances = state
.account_inventories
.entry(gains_account.clone())
.or_default();
let mut by_commodity: BTreeMap<String, crate::elaboration::Decimal> =
BTreeMap::new();
for (c, v) in &gains {
gains_balances.add(c.clone(), None, *v);
by_commodity.insert(c.clone(), crate::decimal_to_proto(*v));
}
resolved_postings.push(crate::elaboration::Posting {
account: gains_account.clone(),
payee: payee.clone(),
amount: Some(crate::elaboration::Amount { by_commodity }),
state: crate::state_to_proto(&TransactionState::Cleared),
tags: vec![],
metadata: BTreeMap::new(),
kind: crate::posting_kind_to_proto(ast::PostingKind::Real),
lot: None,
});
}
}
for (posting_index, posting) in resolved_postings.iter().enumerate() {
if let Some(props) = value
.global_context
.account_properties
.get(&posting.account)
{
let merged_metadata =
merge_metadata(&transaction.metadata, &posting.metadata);
for (commodity, amount_val) in posting.amounts() {
for assert_expr in &props.asserts {
let passed = evaluator::eval_bool_expr(
assert_expr,
amount_val,
commodity,
&merged_metadata,
entry_context,
&state,
)
.map_err(ElaborationError::EvaluationError)?;
if !passed {
return Err(ElaborationError::AccountAssertionFailed {
account: posting.account.clone(),
posting_index,
expression: assert_expr.to_string(),
});
}
}
for check_expr in &props.checks {
let passed = evaluator::eval_bool_expr(
check_expr,
amount_val,
commodity,
&merged_metadata,
entry_context,
&state,
)
.map_err(ElaborationError::EvaluationError)?;
if !passed {
eprintln!(
"warning: check failed for posting {posting_index} \
to {account}: check {expr}",
account = posting.account,
expr = check_expr,
);
}
}
}
}
}
eval_tag_metadata(
&transaction.metadata,
&value.global_context.tag_properties,
entry_context,
&state,
)?;
for posting in resolved_postings.iter() {
eval_tag_metadata(
&posting.metadata,
&value.global_context.tag_properties,
entry_context,
&state,
)?;
}
if lot_validation_mode == resolution::LotValidationMode::Strict {
let mut projected: BTreeMap<(String, String, LotKey), Decimal> =
BTreeMap::new();
for posting in resolved_postings.iter() {
let Some(lot_key) = lot_key_from_proto(posting.lot.as_ref()) else {
continue;
};
for (commodity, delta) in posting.amounts() {
*projected
.entry((
posting.account.clone(),
commodity.to_string(),
lot_key.clone(),
))
.or_default() += delta;
}
}
for ((account, commodity, lot), delta) in &projected {
if !delta.is_sign_negative() {
continue;
}
let booking_method = value
.global_context
.account_properties
.get(account)
.and_then(|p| p.booking_method);
if matches!(booking_method, Some(resolution::BookingMethod::None)) {
continue;
}
let any_match = state
.account_inventories
.get(account)
.map(|inv| {
inv.positions.iter().any(|((c, k), v)| {
c == commodity
&& !v.is_zero()
&& k.as_ref().is_some_and(|inv_lot| {
lot_matches_pattern(lot, inv_lot)
})
})
})
.unwrap_or(false);
if any_match {
continue;
}
let any_prior_same_commodity = state
.account_inventories
.get(account)
.map(|inv| {
inv.positions
.iter()
.any(|((c, _), v)| c == commodity && !v.is_zero())
})
.unwrap_or(false);
if any_prior_same_commodity {
return Err(ElaborationError::PhantomLotReduction {
account: account.clone(),
commodity: commodity.clone(),
lot: format_lot_key(lot),
});
}
}
}
let base_posting_count = resolved_postings.len();
for rule in &auto_rules {
let matches: Vec<(String, Vec<(String, Decimal)>)> = (0
..base_posting_count)
.filter_map(|pi| {
let p = &resolved_postings[pi];
if !rule.query.is_match(&p.account) {
return None;
}
let amounts: Vec<(String, Decimal)> =
p.amounts().map(|(c, v)| (c.to_string(), v)).collect();
Some((p.account.clone(), amounts))
})
.collect();
for (_matched_account, matched_amounts) in matches {
for rule_posting in rule.postings.iter() {
let synth_account = entry_context
.account_aliases
.get(&rule_posting.account)
.cloned()
.unwrap_or_else(|| rule_posting.account.clone());
let synth_amount: Option<crate::elaboration::Amount> =
match &rule_posting.amount {
None => {
None
}
Some(amount_details) => {
let body_value_expr = match amount_details.clone() {
AmountDetails::Amount { value, .. } => value,
_ => continue,
};
let is_bare_number =
is_bare_number_expr(&body_value_expr);
if is_bare_number {
let scalar = evaluator::eval_amount_value(
body_value_expr,
entry_context,
&state,
)?;
let by_commodity: BTreeMap<_, _> = matched_amounts
.iter()
.map(|(c, v)| {
(
c.clone(),
crate::decimal_to_proto(scalar * v),
)
})
.collect();
if by_commodity.is_empty() {
None
} else {
Some(crate::elaboration::Amount {
by_commodity,
})
}
} else {
let (body_val, body_commodity) =
evaluator::eval_and_normalize_amount(
body_value_expr,
entry_context,
&state,
)?;
let by_commodity = BTreeMap::from([(
body_commodity,
crate::decimal_to_proto(body_val),
)]);
Some(crate::elaboration::Amount { by_commodity })
}
}
};
if !accounts.contains_key(&synth_account) {
accounts.insert(synth_account.clone(), Default::default());
}
resolved_postings.push(crate::elaboration::Posting {
account: synth_account,
payee: payee.clone(),
amount: synth_amount,
state: crate::state_to_proto(&TransactionState::Uncleared),
tags: vec![],
metadata: BTreeMap::new(),
kind: crate::posting_kind_to_proto(
ast::PostingKind::VirtualUnbalanced,
),
lot: None,
});
}
}
}
for posting in resolved_postings.iter() {
if !accounts.contains_key(&posting.account) {
accounts.insert(posting.account.clone(), Default::default());
}
let balances = state
.account_inventories
.entry(posting.account.clone())
.or_default();
let lot_key = lot_key_from_proto(posting.lot.as_ref());
for (commodity, delta) in posting.amounts() {
balances.add(commodity.to_string(), lot_key.clone(), delta);
}
}
transactions.push(crate::elaboration::Transaction {
date: transaction.date.to_epoch_days(),
secondary_date: transaction.secondary_date.map(|d| d.to_epoch_days()),
state: crate::state_to_proto(&transaction.state.into()),
code: transaction.code,
description: transaction.description,
tags: transaction.tags,
metadata: transaction.metadata,
postings: resolved_postings,
});
}
}
}
let final_context = value
.contexts
.last()
.expect("HIR always has at least one context");
let mut prices = vec![];
for hp in value.prices {
let (price, price_commodity) =
evaluator::eval_and_normalize_amount(hp.price, final_context, &state)?;
prices.push(crate::elaboration::HistoricalPrice {
date: hp.date.to_epoch_days(),
time: hp.time,
commodity: hp.commodity,
price: Some(crate::decimal_to_proto(price)),
price_commodity,
});
}
let commodities = value
.global_context
.commodity_properties
.into_iter()
.map(|(name, p)| {
(
name,
crate::elaboration::CommodityProperties {
format: p.format,
no_market: p.no_market,
note: p.note,
},
)
})
.collect();
let declared_metadata: BTreeMap<&str, &BTreeMap<String, String>> = value
.global_context
.account_properties
.iter()
.map(|(name, props)| (name.as_str(), &props.metadata))
.collect();
let account_names: Vec<String> = accounts.keys().cloned().collect();
for name in account_names {
let mut inherited: BTreeMap<String, String> = BTreeMap::new();
for prefix in ancestor_prefixes(&name) {
if let Some(parent_meta) = declared_metadata.get(prefix.as_str()) {
for (k, v) in *parent_meta {
inherited.insert(k.clone(), v.clone());
}
}
}
if let Some(props) = accounts.get_mut(&name) {
props.metadata = inherited;
}
}
Ok(crate::elaboration::Journal {
transactions,
accounts,
commodities,
prices,
})
}
}
fn is_bare_number_expr(expr: &ast::ValueExpr) -> bool {
match expr {
ast::ValueExpr::Amount { commodity, .. } => commodity.is_none(),
ast::ValueExpr::Unary { expr, .. } => is_bare_number_expr(expr),
_ => false,
}
}
fn ancestor_prefixes(name: &str) -> Vec<String> {
let mut prefixes = Vec::new();
for (i, _) in name.match_indices(':') {
prefixes.push(name[..i].to_string());
}
prefixes.push(name.to_string());
prefixes
}
fn for_each_descendant<T>(map: &BTreeMap<String, T>, account: &str, mut f: impl FnMut(&str, &T)) {
let prefix = format!("{account}:");
let upper = format!("{account};");
if let Some((k, v)) = map.get_key_value(account) {
f(k.as_str(), v);
}
for (k, v) in map.range::<String, _>(prefix..upper) {
f(k.as_str(), v);
}
}
fn lot_matches_pattern(pattern: &LotKey, inventory: &LotKey) -> bool {
if let Some(c) = &pattern.cost
&& Some(c) != inventory.cost.as_ref()
{
return false;
}
if let Some(c) = &pattern.cost_commodity
&& Some(c) != inventory.cost_commodity.as_ref()
{
return false;
}
if let Some(d) = &pattern.date
&& Some(d) != inventory.date.as_ref()
{
return false;
}
if let Some(n) = &pattern.note
&& Some(n) != inventory.note.as_ref()
{
return false;
}
true
}
fn value_global_account_property(
account: &str,
account_properties: &BTreeMap<String, resolution::AccountProperties>,
default: resolution::BookingMethod,
) -> resolution::BookingMethod {
account_properties
.get(account)
.and_then(|p| p.booking_method)
.unwrap_or(default)
}
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn book_missing_cost_inline(
account: &str,
payee: &str,
posting_kind: ast::PostingKind,
posting_state: &TransactionState,
posting_tags: &[String],
posting_metadata: &BTreeMap<String, String>,
commodity: &str,
units: Decimal,
partial_lot: &crate::elaboration::Lot,
method: resolution::BookingMethod,
inventory: Option<&AccountInventory>,
in_tx_consumed: &mut BTreeMap<(String, String, LotKey), Decimal>,
) -> Result<Vec<(crate::elaboration::Posting, (Decimal, String))>, ElaborationError> {
if !units.is_sign_negative() {
return Err(ElaborationError::AugmentingPostingWithMissingCost {
account: account.to_string(),
commodity: commodity.to_string(),
});
}
let cost_hint = partial_lot.cost.as_ref().and_then(|amount| {
amount
.by_commodity
.iter()
.next()
.map(|(c, v)| (v.to_decimal(), c.clone()))
});
let date_hint = partial_lot.date.and_then(|epoch_days| {
chrono::NaiveDate::from_ymd_opt(1970, 1, 1)
.and_then(|epoch| epoch.checked_add_signed(chrono::Duration::days(epoch_days as i64)))
});
let note_hint = partial_lot.note.clone();
let mut eligible_lots: Vec<(LotKey, Decimal)> = inventory
.map(|inv| {
inv.positions
.iter()
.filter_map(|((c, lot_key_opt), bal)| {
if c != commodity {
return None;
}
let lot_key = lot_key_opt.clone()?;
if !bal.is_sign_positive() || bal.is_zero() {
return None;
}
if let Some((cv, cc)) = cost_hint.as_ref()
&& (lot_key.cost.as_ref() != Some(cv)
|| lot_key.cost_commodity.as_ref() != Some(cc))
{
return None;
}
if let Some(d) = date_hint
&& lot_key.date != Some(d)
{
return None;
}
if let Some(n) = note_hint.as_deref()
&& lot_key.note.as_deref() != Some(n)
{
return None;
}
let already = in_tx_consumed
.get(&(account.to_string(), commodity.to_string(), lot_key.clone()))
.copied()
.unwrap_or_default();
let remaining = *bal - already;
if remaining.is_zero() || !remaining.is_sign_positive() {
return None;
}
Some((lot_key, remaining))
})
.collect()
})
.unwrap_or_default();
match method {
resolution::BookingMethod::Fifo
| resolution::BookingMethod::Strict
| resolution::BookingMethod::StrictWithSize
| resolution::BookingMethod::Average => {
eligible_lots.sort_by_key(|(k, _)| k.date);
}
resolution::BookingMethod::Lifo => {
eligible_lots.sort_by_key(|b| std::cmp::Reverse(b.0.date));
}
resolution::BookingMethod::Hifo => {
eligible_lots.sort_by_key(|b| std::cmp::Reverse(b.0.cost));
}
resolution::BookingMethod::None => unreachable!("caller filters NONE"),
}
if matches!(method, resolution::BookingMethod::Strict) && eligible_lots.len() > 1 {
return Err(ElaborationError::AmbiguousLotMatch {
account: account.to_string(),
commodity: commodity.to_string(),
match_count: eligible_lots.len(),
});
}
let mut to_consume = -units; let total_available: Decimal = eligible_lots.iter().map(|(_, b)| *b).sum();
if to_consume > total_available {
return Err(ElaborationError::OverReductionInBooking {
account: account.to_string(),
commodity: commodity.to_string(),
requested: to_consume,
available: total_available,
});
}
let mut out = Vec::new();
for (lot_key, balance) in eligible_lots {
if to_consume.is_zero() {
break;
}
let take = to_consume.min(balance);
let take_units = -take; let cost = lot_key.cost.expect(
"eligible_lots filters retained only lots with explicit cost; \
None-cost positions are skipped",
);
let cost_commodity = lot_key
.cost_commodity
.clone()
.expect("eligible lots have cost_commodity when cost is Some");
let cost_basis_cash = (take_units * cost, cost_commodity.clone());
let proto_amount = crate::elaboration::Amount {
by_commodity: BTreeMap::from([(
commodity.to_string(),
crate::decimal_to_proto(take_units),
)]),
};
let proto_cost = crate::elaboration::Amount {
by_commodity: BTreeMap::from([(cost_commodity.clone(), crate::decimal_to_proto(cost))]),
};
let proto_date = lot_key.date.and_then(|d| {
chrono::NaiveDate::from_ymd_opt(1970, 1, 1).map(|epoch| (d - epoch).num_days() as i32)
});
let booked = crate::elaboration::Posting {
account: account.to_string(),
payee: payee.to_string(),
amount: Some(proto_amount),
state: crate::state_to_proto(posting_state),
tags: posting_tags.to_vec(),
metadata: posting_metadata.clone(),
kind: crate::posting_kind_to_proto(posting_kind),
lot: Some(crate::elaboration::Lot {
cost: Some(proto_cost),
date: proto_date,
note: lot_key.note.clone(),
}),
};
*in_tx_consumed
.entry((account.to_string(), commodity.to_string(), lot_key.clone()))
.or_default() += take;
out.push((booked, cost_basis_cash));
to_consume -= take;
}
Ok(out)
}
fn infer_implicit_total_cost(
postings: &mut [resolution::Posting],
eval_context: &resolution::Context,
state: &RunningState,
) -> Result<(), ElaborationError> {
let real_indices: Vec<usize> = postings
.iter()
.enumerate()
.filter(|(_, p)| p.kind == ast::PostingKind::Real && p.amount.is_some())
.map(|(i, _)| i)
.collect();
if real_indices.len() != 2 {
return Ok(());
}
let [idx_a, idx_b] = [real_indices[0], real_indices[1]];
for &idx in &[idx_a, idx_b] {
if let Some(ast::AmountDetails::Amount {
lot_annotation,
lot_pricing,
..
}) = &postings[idx].amount
{
if lot_pricing.is_some() {
return Ok(());
}
if lot_annotation
.as_ref()
.is_some_and(|ann| ann.cost.is_some())
{
return Ok(());
}
} else {
return Ok(());
}
}
let (val_a, commodity_a) = {
let value = match &postings[idx_a].amount {
Some(ast::AmountDetails::Amount { value, .. }) => value.clone(),
_ => return Ok(()),
};
evaluator::eval_and_normalize_amount(value, eval_context, state)?
};
let (val_b, commodity_b) = {
let value = match &postings[idx_b].amount {
Some(ast::AmountDetails::Amount { value, .. }) => value.clone(),
_ => return Ok(()),
};
evaluator::eval_and_normalize_amount(value, eval_context, state)?
};
if commodity_a == commodity_b {
return Ok(());
}
let (buyer_idx, total_cost_value, total_cost_commodity) =
if val_a.is_sign_positive() && val_b.is_sign_negative() {
(idx_a, val_b.abs(), commodity_b)
} else if val_b.is_sign_positive() && val_a.is_sign_negative() {
(idx_b, val_a.abs(), commodity_a)
} else {
return Ok(());
};
if let Some(ast::AmountDetails::Amount { lot_pricing, .. }) = &mut postings[buyer_idx].amount {
*lot_pricing = Some(ast::LotPricing::Total(ast::ValueExpr::Amount {
value: total_cost_value,
commodity: Some(total_cost_commodity),
}));
}
Ok(())
}
fn format_lot_key(lot: &LotKey) -> String {
let mut parts = Vec::new();
if let (Some(cost), Some(commodity)) = (&lot.cost, &lot.cost_commodity) {
parts.push(format!("{{{} {}}}", cost, commodity));
}
if let Some(date) = lot.date {
parts.push(format!("[{}]", date));
}
if let Some(note) = &lot.note {
parts.push(format!("(({}))", note));
}
if parts.is_empty() {
"{}".to_string()
} else {
parts.join(" ")
}
}
fn lot_key_from_proto(lot: Option<&crate::elaboration::Lot>) -> Option<LotKey> {
let lot = lot?;
let mut cost = None;
let mut cost_commodity = None;
if let Some(amount) = lot.cost.as_ref()
&& let Some((commodity, value)) = amount.by_commodity.iter().next()
{
cost = Some(value.to_decimal());
cost_commodity = Some(commodity.clone());
}
let date = lot.date.and_then(|epoch_days| {
chrono::NaiveDate::from_ymd_opt(1970, 1, 1)
.and_then(|epoch| epoch.checked_add_signed(chrono::Duration::days(epoch_days as i64)))
});
Some(LotKey {
cost,
cost_commodity,
date,
note: lot.note.clone(),
})
}
fn subtree_commodity_balance(
account_inventories: &BTreeMap<String, AccountInventory>,
account: &str,
) -> BTreeMap<String, Decimal> {
let mut out: BTreeMap<String, Decimal> = BTreeMap::new();
for_each_descendant(account_inventories, account, |_, inv| {
for ((c, _lot), v) in inv.iter_positions() {
*out.entry(c.clone()).or_default() += *v;
}
});
out
}
fn merge_metadata(
transaction: &BTreeMap<String, String>,
posting: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
let mut merged = transaction.clone();
for (k, v) in posting {
merged.insert(k.clone(), v.clone());
}
merged
}
fn eval_tag_metadata(
metadata: &BTreeMap<String, String>,
tag_properties: &BTreeMap<String, resolution::TagProperties>,
eval_context: &resolution::Context,
state: &RunningState,
) -> Result<(), ElaborationError> {
for (tag_name, tag_value) in metadata {
if let Some(props) = tag_properties.get(tag_name) {
for assert_expr in &props.asserts {
let passed =
evaluator::eval_bool_expr_for_tag(assert_expr, tag_value, eval_context, state)
.map_err(ElaborationError::EvaluationError)?;
if !passed {
return Err(ElaborationError::TagAssertionFailed {
tag_name: tag_name.clone(),
tag_value: tag_value.clone(),
expression: assert_expr.to_string(),
});
}
}
for check_expr in &props.checks {
let passed =
evaluator::eval_bool_expr_for_tag(check_expr, tag_value, eval_context, state)
.map_err(ElaborationError::EvaluationError)?;
if !passed {
eprintln!(
"warning: tag check failed for {tag_name}: \"{tag_value}\": \
check {check_expr}",
);
}
}
}
}
Ok(())
}
mod evaluator {
use std::collections::BTreeMap;
use regex::Regex;
use rust_decimal::Decimal;
use crate::{
ast::{self, BoolExpr, CmpOp, ValueExpr},
resolution,
};
use super::{ElaborationError, EvaluationError, RunningState};
pub fn eval_and_normalize_amount(
val: ast::ValueExpr,
eval_context: &resolution::Context,
running_state: &RunningState,
) -> Result<(Decimal, String), ElaborationError> {
eval_and_normalize_amount_with_fallback(val, eval_context, running_state, None)
}
pub fn eval_amount_value(
val: ast::ValueExpr,
eval_context: &resolution::Context,
running_state: &RunningState,
) -> Result<Decimal, ElaborationError> {
let empty_meta = BTreeMap::default();
match eval(val, eval_context, running_state, &empty_meta, EVAL_BUDGET)? {
ast::ValueExpr::Amount { value, .. } => Ok(value),
other => Err(EvaluationError::UnaryOnNonAmount(other).into()),
}
}
pub fn eval_and_normalize_amount_with_fallback(
val: ast::ValueExpr,
eval_context: &resolution::Context,
running_state: &RunningState,
fallback_commodity: Option<&str>,
) -> Result<(Decimal, String), ElaborationError> {
let empty_meta = BTreeMap::default();
match eval(val, eval_context, running_state, &empty_meta, EVAL_BUDGET)? {
ast::ValueExpr::Amount { value, commodity } => {
let (value, commodity) = if let Some(commodity) = commodity {
if let Some((canonical, divisor)) =
eval_context.commodity_conversions.get(&commodity)
{
(value / divisor, canonical.clone())
} else {
(value, commodity)
}
} else {
let commodity = eval_context
.default_commodity
.as_deref()
.or(fallback_commodity)
.ok_or(ElaborationError::AmountWithNoCommodity)?
.to_owned();
(value, commodity)
};
Ok((value, commodity))
}
val => Err(ElaborationError::NonAmountWhereAmountExpected(val)),
}
}
pub fn eval_bool_expr(
expr: &BoolExpr,
posting_amount: Decimal,
posting_commodity: &str,
posting_metadata: &BTreeMap<String, String>,
eval_context: &resolution::Context,
state: &RunningState,
) -> Result<bool, EvaluationError> {
let mut ctx = eval_context.clone();
ctx.defines.insert(
"amount".into(),
resolution::Define {
params: vec![],
body: ast::DefineBody::Value(ast::ValueExpr::Amount {
value: posting_amount,
commodity: Some(posting_commodity.to_string()),
}),
},
);
ctx.defines.insert(
"commodity".into(),
resolution::Define {
params: vec![],
body: ast::DefineBody::Value(ast::ValueExpr::Str(posting_commodity.to_string())),
},
);
if expr.cmp.is_none()
&& let ast::ValueExpr::Function { name, args } = &expr.lhs
&& let Some(define) = ctx.defines.get(name.as_str())
&& let ast::DefineBody::Bool(body) = define.body.clone()
{
if define.params.len() != args.len() {
return Err(EvaluationError::DefineArgCountMismatch {
name: name.clone(),
expected: define.params.len(),
got: args.len(),
});
}
let mut call_ctx = ctx.clone();
for (param, arg_expr) in define.params.iter().zip(args.iter()) {
let arg_val = eval(arg_expr.clone(), &ctx, state, posting_metadata, EVAL_BUDGET)?;
call_ctx.defines.insert(
param.clone(),
resolution::Define {
params: vec![],
body: ast::DefineBody::Value(arg_val),
},
);
}
let segment_result = eval_bool_expr_with_context(
&body,
posting_amount,
posting_commodity,
posting_metadata,
&call_ctx,
state,
)?;
return match &expr.chain {
None => Ok(segment_result),
Some((ast::BoolOp::And, cont)) => {
if !segment_result {
Ok(false)
} else {
eval_bool_expr(
cont,
posting_amount,
posting_commodity,
posting_metadata,
eval_context,
state,
)
}
}
Some((ast::BoolOp::Or, cont)) => {
if segment_result {
Ok(true)
} else {
eval_bool_expr(
cont,
posting_amount,
posting_commodity,
posting_metadata,
eval_context,
state,
)
}
}
};
}
let lhs_val = eval(expr.lhs.clone(), &ctx, state, posting_metadata, EVAL_BUDGET)?;
let result = match &expr.cmp {
None => match lhs_val {
ast::ValueExpr::Amount { value, .. } => !value.is_zero(),
_ => false,
},
Some((cmp_op, rhs_expr)) => {
let rhs_val = match rhs_expr {
ast::ValueExpr::Regex(_) => rhs_expr.clone(),
other => eval(other.clone(), &ctx, state, posting_metadata, EVAL_BUDGET)?,
};
eval_cmp(cmp_op, &lhs_val, &rhs_val)?
}
};
match &expr.chain {
None => Ok(result),
Some((ast::BoolOp::And, cont)) => {
if !result {
Ok(false)
} else {
eval_bool_expr(
cont,
posting_amount,
posting_commodity,
posting_metadata,
eval_context,
state,
)
}
}
Some((ast::BoolOp::Or, cont)) => {
if result {
Ok(true)
} else {
eval_bool_expr(
cont,
posting_amount,
posting_commodity,
posting_metadata,
eval_context,
state,
)
}
}
}
}
#[allow(clippy::only_used_in_recursion)]
fn eval_bool_expr_with_context(
expr: &BoolExpr,
posting_amount: Decimal,
posting_commodity: &str,
posting_metadata: &BTreeMap<String, String>,
eval_context: &resolution::Context,
state: &RunningState,
) -> Result<bool, EvaluationError> {
if expr.cmp.is_none()
&& let ast::ValueExpr::Function { name, args } = &expr.lhs
&& let Some(define) = eval_context.defines.get(name.as_str())
&& let ast::DefineBody::Bool(body) = define.body.clone()
{
if define.params.len() != args.len() {
return Err(EvaluationError::DefineArgCountMismatch {
name: name.clone(),
expected: define.params.len(),
got: args.len(),
});
}
let mut call_ctx = eval_context.clone();
for (param, arg_expr) in define.params.iter().zip(args.iter()) {
let arg_val = eval(
arg_expr.clone(),
eval_context,
state,
posting_metadata,
EVAL_BUDGET,
)?;
call_ctx.defines.insert(
param.clone(),
resolution::Define {
params: vec![],
body: ast::DefineBody::Value(arg_val),
},
);
}
let segment_result = eval_bool_expr_with_context(
&body,
posting_amount,
posting_commodity,
posting_metadata,
&call_ctx,
state,
)?;
return match &expr.chain {
None => Ok(segment_result),
Some((ast::BoolOp::And, cont)) => {
if !segment_result {
Ok(false)
} else {
eval_bool_expr_with_context(
cont,
posting_amount,
posting_commodity,
posting_metadata,
eval_context,
state,
)
}
}
Some((ast::BoolOp::Or, cont)) => {
if segment_result {
Ok(true)
} else {
eval_bool_expr_with_context(
cont,
posting_amount,
posting_commodity,
posting_metadata,
eval_context,
state,
)
}
}
};
}
let lhs_val = eval(
expr.lhs.clone(),
eval_context,
state,
posting_metadata,
EVAL_BUDGET,
)?;
let result = match &expr.cmp {
None => match lhs_val {
ast::ValueExpr::Amount { value, .. } => !value.is_zero(),
_ => false,
},
Some((cmp_op, rhs_expr)) => {
let rhs_val = match rhs_expr {
ast::ValueExpr::Regex(_) => rhs_expr.clone(),
other => eval(
other.clone(),
eval_context,
state,
posting_metadata,
EVAL_BUDGET,
)?,
};
eval_cmp(cmp_op, &lhs_val, &rhs_val)?
}
};
match &expr.chain {
None => Ok(result),
Some((ast::BoolOp::And, cont)) => {
if !result {
Ok(false)
} else {
eval_bool_expr_with_context(
cont,
posting_amount,
posting_commodity,
posting_metadata,
eval_context,
state,
)
}
}
Some((ast::BoolOp::Or, cont)) => {
if result {
Ok(true)
} else {
eval_bool_expr_with_context(
cont,
posting_amount,
posting_commodity,
posting_metadata,
eval_context,
state,
)
}
}
}
}
pub fn eval_bool_expr_for_tag(
expr: &BoolExpr,
tag_value: &str,
eval_context: &resolution::Context,
state: &RunningState,
) -> Result<bool, EvaluationError> {
let empty_meta = BTreeMap::default();
let mut ctx = eval_context.clone();
ctx.defines.insert(
"value".into(),
resolution::Define {
params: vec![],
body: ast::DefineBody::Value(ast::ValueExpr::Str(tag_value.to_string())),
},
);
let lhs_val = eval(expr.lhs.clone(), &ctx, state, &empty_meta, EVAL_BUDGET)?;
let result = match &expr.cmp {
None => match lhs_val {
ast::ValueExpr::Amount { value, .. } => !value.is_zero(),
_ => false,
},
Some((cmp_op, rhs_expr)) => {
let rhs_val = match rhs_expr {
ast::ValueExpr::Regex(_) => rhs_expr.clone(),
other => eval(other.clone(), &ctx, state, &empty_meta, EVAL_BUDGET)?,
};
eval_cmp(cmp_op, &lhs_val, &rhs_val)?
}
};
match &expr.chain {
None => Ok(result),
Some((ast::BoolOp::And, cont)) => {
if !result {
Ok(false)
} else {
eval_bool_expr_for_tag(cont, tag_value, eval_context, state)
}
}
Some((ast::BoolOp::Or, cont)) => {
if result {
Ok(true)
} else {
eval_bool_expr_for_tag(cont, tag_value, eval_context, state)
}
}
}
}
fn eval_cmp(
op: &CmpOp,
lhs: &ast::ValueExpr,
rhs: &ast::ValueExpr,
) -> Result<bool, EvaluationError> {
match (lhs, rhs) {
(ast::ValueExpr::Str(text), ast::ValueExpr::Regex(pattern)) => {
let re = Regex::new(pattern).map_err(|e| {
EvaluationError::InvalidRegexPattern(pattern.clone(), e.to_string())
})?;
Ok(match op {
CmpOp::RegexMatch => re.is_match(text),
CmpOp::RegexNotMatch => !re.is_match(text),
_ => unreachable!(
"parser should only produce RegexMatch/RegexNotMatch with a Regex RHS"
),
})
}
(ast::ValueExpr::Str(a), ast::ValueExpr::Str(b)) => Ok(match op {
CmpOp::Eq => a == b,
CmpOp::Ne => a != b,
_ => {
return Err(EvaluationError::BinaryOperationTypeError((
lhs.clone(),
rhs.clone(),
ast::Op::Add, )));
}
}),
(
ast::ValueExpr::Amount {
value: v1,
commodity: c1,
},
ast::ValueExpr::Amount {
value: v2,
commodity: c2,
},
) if c1 == c2 || c1.is_none() || c2.is_none() => Ok(match op {
CmpOp::Eq => v1 == v2,
CmpOp::Ne => v1 != v2,
CmpOp::Lt => v1 < v2,
CmpOp::Le => v1 <= v2,
CmpOp::Gt => v1 > v2,
CmpOp::Ge => v1 >= v2,
CmpOp::RegexMatch | CmpOp::RegexNotMatch => {
return Err(EvaluationError::BinaryOperationTypeError((
lhs.clone(),
rhs.clone(),
ast::Op::Add,
)));
}
}),
_ => Err(EvaluationError::BinaryOperationTypeError((
lhs.clone(),
rhs.clone(),
ast::Op::Add,
))),
}
}
pub const EVAL_BUDGET: usize = 64;
fn eval(
val: ast::ValueExpr,
eval_context: &resolution::Context,
state: &RunningState,
posting_metadata: &BTreeMap<String, String>,
budget: usize,
) -> Result<ast::ValueExpr, EvaluationError> {
let Some(budget) = budget.checked_sub(1) else {
return Err(EvaluationError::RecursionLimitExceeded);
};
match val {
a @ ast::ValueExpr::Amount { .. } => Ok(a),
s @ ast::ValueExpr::Str(_) => Ok(s),
r @ ast::ValueExpr::Regex(_) => Ok(r),
o @ ast::ValueExpr::Object(_) => Ok(o),
ast::ValueExpr::Unary { op, expr } => {
match eval(*expr, eval_context, state, posting_metadata, budget)? {
ast::ValueExpr::Amount { value, commodity } => match op {
ast::Op::Sub => Ok(ast::ValueExpr::Amount {
value: -value,
commodity,
}),
ast::Op::Add => Ok(ast::ValueExpr::Amount { value, commodity }),
_ => Err(EvaluationError::UnaryMultiplyOrDivide),
},
val => Err(EvaluationError::UnaryOnNonAmount(val)),
}
}
ast::ValueExpr::Binary { lhs, rhs, op } => {
match (
eval(*lhs, eval_context, state, posting_metadata, budget)?,
eval(*rhs, eval_context, state, posting_metadata, budget)?,
) {
(
ast::ValueExpr::Amount {
value: v1,
commodity: c,
},
ast::ValueExpr::Amount {
value: v2,
commodity: None,
},
)
| (
ast::ValueExpr::Amount {
value: v1,
commodity: None,
},
ast::ValueExpr::Amount {
value: v2,
commodity: c,
},
) => Ok(match op {
ast::Op::Add => ast::ValueExpr::Amount {
value: v1 + v2,
commodity: c,
},
ast::Op::Sub => ast::ValueExpr::Amount {
value: v1 - v2,
commodity: c,
},
ast::Op::Mul => ast::ValueExpr::Amount {
value: v1 * v2,
commodity: c,
},
ast::Op::Div => ast::ValueExpr::Amount {
value: v1 / v2,
commodity: c,
},
}),
(
ast::ValueExpr::Amount {
value: v1,
commodity: c,
},
ast::ValueExpr::Amount {
value: v2,
commodity: c2,
},
) if c == c2 => Ok(match op {
ast::Op::Add => ast::ValueExpr::Amount {
value: v1 + v2,
commodity: c,
},
ast::Op::Sub => ast::ValueExpr::Amount {
value: v1 - v2,
commodity: c,
},
ast::Op::Mul => ast::ValueExpr::Amount {
value: v1 * v2,
commodity: c,
},
ast::Op::Div => ast::ValueExpr::Amount {
value: v1 / v2,
commodity: c,
},
}),
(
ast::ValueExpr::Commodity(commodity),
ast::ValueExpr::Amount {
value,
commodity: None,
},
)
| (
ast::ValueExpr::Amount {
value,
commodity: None,
},
ast::ValueExpr::Commodity(commodity),
) => match op {
ast::Op::Sub => Ok(ast::ValueExpr::Amount {
value: -value,
commodity: Some(commodity),
}),
ast::Op::Add => Ok(ast::ValueExpr::Amount {
value,
commodity: Some(commodity),
}),
_ => Err(EvaluationError::UnaryMultiplyOrDivide),
},
(a, b) => Err(EvaluationError::BinaryOperationTypeError((a, b, op))),
}
}
ast::ValueExpr::Function { name, args } => {
if let Some(define) = eval_context.defines.get(name.as_str()) {
if define.params.len() != args.len() {
return Err(EvaluationError::DefineArgCountMismatch {
name: name.clone(),
expected: define.params.len(),
got: args.len(),
});
}
return match &define.body {
ast::DefineBody::Bool(_) => {
Err(EvaluationError::BoolDefineInValueContext(name.clone()))
}
ast::DefineBody::Value(body_expr) => {
let mut ctx = eval_context.clone();
for (param, arg_expr) in define.params.iter().zip(args.iter()) {
let arg_val = eval(
arg_expr.clone(),
eval_context,
state,
posting_metadata,
budget,
)?;
ctx.defines.insert(
param.clone(),
resolution::Define {
params: vec![],
body: ast::DefineBody::Value(arg_val),
},
);
}
eval(body_expr.clone(), &ctx, state, posting_metadata, budget)
}
};
}
match (name.as_str(), args.as_slice()) {
("scrub", [arg]) => {
eval(arg.clone(), eval_context, state, posting_metadata, budget)
}
("account", [account]) => {
if let ValueExpr::Str(account) = eval(
account.clone(),
eval_context,
state,
posting_metadata,
budget,
)? {
let account = eval_context
.account_aliases
.get(&account)
.unwrap_or(&account);
let balance = state
.account_inventories
.get(account)
.map(|ab| ab.commodity_balance("$"))
.unwrap_or_default();
Ok(ast::ValueExpr::Object(BTreeMap::from([(
"total".into(),
ast::ValueExpr::Amount {
value: balance,
commodity: Some("$".into()),
},
)])))
} else {
Err(EvaluationError::InvalidFunctionArgs((
name,
account.clone(),
)))
}
}
("tag", [key_expr]) => {
if let ValueExpr::Str(key) = eval(
key_expr.clone(),
eval_context,
state,
posting_metadata,
budget,
)? {
let value = posting_metadata.get(&key).cloned().unwrap_or_default();
Ok(ast::ValueExpr::Str(value))
} else {
Err(EvaluationError::InvalidFunctionArgs((
name,
key_expr.clone(),
)))
}
}
_ => Err(EvaluationError::UnknownFunctionArgs((name, args))),
}
}
ast::ValueExpr::Commodity(ref name) => {
if let Some(define) = eval_context.defines.get(name.as_str()) {
if define.params.is_empty() {
match &define.body {
ast::DefineBody::Value(expr) => {
eval(expr.clone(), eval_context, state, posting_metadata, budget)
}
ast::DefineBody::Bool(_) => Ok(val),
}
} else {
Ok(val)
}
} else {
Ok(val)
}
}
ast::ValueExpr::Typed {
expr,
commodity: new_commodity,
} => match eval(*expr, eval_context, state, posting_metadata, budget)? {
ast::ValueExpr::Amount { value, commodity }
if commodity.is_none() || commodity.as_ref() == Some(&new_commodity) =>
{
Ok(ast::ValueExpr::Amount {
value,
commodity: Some(new_commodity),
})
}
a => Err(EvaluationError::TypedCommodityToIncompatibleAmount((
new_commodity,
a,
))),
},
ast::ValueExpr::Access { expr, field } => {
match eval(*expr, eval_context, state, posting_metadata, budget)? {
ast::ValueExpr::Object(map) => map
.get(&field)
.cloned()
.ok_or(EvaluationError::NoSuchField(field)),
val => Err(EvaluationError::FieldAccessTypeError(val)),
}
}
ast::ValueExpr::Group(bool_expr) => {
let (posting_amount, posting_commodity) =
extract_posting_context_from_defines(eval_context);
let result = eval_bool_expr_with_context(
&bool_expr,
posting_amount,
&posting_commodity,
posting_metadata,
eval_context,
state,
)?;
Ok(ast::ValueExpr::Amount {
value: if result { Decimal::ONE } else { Decimal::ZERO },
commodity: None,
})
}
}
}
fn extract_posting_context_from_defines(ctx: &resolution::Context) -> (Decimal, String) {
let amount = ctx
.defines
.get("amount")
.and_then(|d| {
if let ast::DefineBody::Value(ast::ValueExpr::Amount { value, .. }) = &d.body {
Some(*value)
} else {
None
}
})
.unwrap_or(Decimal::ZERO);
let commodity = ctx
.defines
.get("commodity")
.and_then(|d| {
if let ast::DefineBody::Value(ast::ValueExpr::Str(s)) = &d.body {
Some(s.clone())
} else {
None
}
})
.unwrap_or_default();
(amount, commodity)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal::dec;
#[test]
fn test_amount_default_is_empty() {
let amount = Amount::default();
assert!(amount.0.is_empty());
}
#[test]
fn test_amount_multi_commodity() {
let amount = Amount(BTreeMap::from([
("USD".to_string(), dec!(42.5)),
("$".to_string(), dec!(-1.5)),
]));
assert_eq!(amount.0.len(), 2);
assert_eq!(amount.0["USD"], dec!(42.5));
assert_eq!(amount.0["$"], dec!(-1.5));
}
#[test]
fn test_prices_wired_through_to_journal() {
use crate::{ast, resolution};
let price_ast = ast::HistoricalPrice {
date: ast::Date {
year: Some(2024),
month: 6,
date: 15,
},
time: Some("14:30:00".into()),
commodity: "AAPL".into(),
price: ast::ValueExpr::amount(rust_decimal::Decimal::from(182), "$".into()),
};
let journal_ast = ast::Journal {
entries: vec![ast::Entry::HistoricalPrice(price_ast)],
};
let hir = resolution::HIR::try_from(journal_ast).expect("resolution should succeed");
assert_eq!(hir.prices.len(), 1, "HIR should contain one price");
let journal = crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults())
.expect("elaboration should succeed");
assert_eq!(journal.prices.len(), 1, "Journal should contain one price");
let price = &journal.prices[0];
let expected_days = chrono::NaiveDate::from_ymd_opt(2024, 6, 15)
.unwrap()
.to_epoch_days();
assert_eq!(price.date, expected_days);
assert_eq!(price.time.as_deref(), Some("14:30:00"));
assert_eq!(price.commodity, "AAPL");
assert_eq!(
price.price.as_ref().unwrap().to_decimal(),
rust_decimal::Decimal::from(182)
);
assert_eq!(price.price_commodity, "$");
}
fn elaborate(input: &str) -> crate::elaboration::Journal {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults())
.expect("elaboration failed")
}
#[test]
fn test_define_simple_amount_alias() {
let input = "\
define monthly_rent = $1500.00
2024-01-01 Rent Payment
Expenses:Rent monthly_rent
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let rent_posting = tx
.postings
.iter()
.find(|p| p.account == "Expenses:Rent")
.unwrap();
assert_eq!(
rent_posting.amount_in("$"),
Some(dec!(1500.00)),
"define alias should expand to $1500.00"
);
let checking_posting = tx
.postings
.iter()
.find(|p| p.account == "Assets:Checking")
.unwrap();
assert_eq!(
checking_posting.amount_in("$"),
Some(dec!(-1500.00)),
"balancing posting should be -$1500.00"
);
}
#[test]
fn test_define_used_in_arithmetic_expression() {
let input = "\
define base_amount = 100 USD
2024-02-01 Double Amount
Expenses:Food 2 * base_amount
Assets:Cash
";
let journal = elaborate(input);
let tx = &journal.transactions[0];
let food = tx
.postings
.iter()
.find(|p| p.account == "Expenses:Food")
.unwrap();
assert_eq!(
food.amount_in("USD"),
Some(dec!(200)),
"2 * define alias should expand to 200 USD"
);
}
#[test]
fn test_define_does_not_affect_earlier_transactions() {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let input = "\
2024-01-01 Before Define
Expenses:A $10.00
Assets:Cash
define myval = $99.00
2024-01-02 After Define
Expenses:B myval
Assets:Cash
";
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
assert_eq!(hir.contexts.len(), 2);
assert_eq!(hir.entries[0].context_id, 0);
assert!(hir.contexts[0].defines.is_empty());
assert_eq!(hir.entries[1].context_id, 1);
assert!(hir.contexts[1].defines.contains_key("myval"));
let journal = crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults())
.expect("elaboration failed");
let after_tx = &journal.transactions[1];
let b_posting = after_tx
.postings
.iter()
.find(|p| p.account == "Expenses:B")
.unwrap();
assert_eq!(b_posting.amount_in("$"), Some(dec!(99.00)));
}
#[test]
fn test_transaction_state_preserved_through_pipeline() {
let input = "\
2024-01-01 * Cleared Transaction
Expenses:Food $10.00
Assets:Checking
2024-01-02 Uncleared Transaction
Expenses:Food $5.00
Assets:Checking
2024-01-03 ! Pending Transaction
Expenses:Food $3.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 3);
assert!(
journal.transactions[0].state == crate::elaboration::TransactionState::Cleared as i32,
"first transaction should be Cleared"
);
assert!(
journal.transactions[1].state == crate::elaboration::TransactionState::Uncleared as i32,
"second transaction should be Uncleared"
);
assert!(
journal.transactions[2].state == crate::elaboration::TransactionState::Pending as i32,
"third transaction should be Pending"
);
}
#[test]
fn test_cleared_filter_mixed_transactions() {
let input = "\
2024-01-01 * Cleared Transaction
Expenses:Food $10.00
Assets:Checking
2024-01-02 Uncleared Transaction
Expenses:Food $5.00
Assets:Checking
2024-01-03 ! Pending Transaction
Expenses:Food $3.00
Assets:Checking
";
let journal = elaborate(input);
let cleared_total: rust_decimal::Decimal = journal
.transactions
.iter()
.filter(|txn| txn.state == crate::elaboration::TransactionState::Cleared as i32)
.flat_map(|txn| txn.postings.iter())
.filter(|p| p.account == "Expenses:Food")
.filter_map(|p| p.amount_in("$"))
.sum();
assert_eq!(
cleared_total,
dec!(10.00),
"--cleared should include only the $10.00 cleared transaction"
);
}
#[test]
fn test_cleared_filter_no_cleared_transactions() {
let input = "\
2024-01-01 Uncleared One
Expenses:Food $10.00
Assets:Checking
2024-01-02 ! Pending One
Expenses:Food $5.00
Assets:Checking
";
let journal = elaborate(input);
let count = journal
.transactions
.iter()
.filter(|txn| txn.state == crate::elaboration::TransactionState::Cleared as i32)
.count();
assert_eq!(count, 0, "no cleared transactions should be found");
}
#[test]
fn test_no_cleared_filter_includes_all_transactions() {
let input = "\
2024-01-01 * Cleared Transaction
Expenses:Food $10.00
Assets:Checking
2024-01-02 Uncleared Transaction
Expenses:Food $5.00
Assets:Checking
";
let journal = elaborate(input);
let total: rust_decimal::Decimal = journal
.transactions
.iter()
.flat_map(|txn| txn.postings.iter())
.filter(|p| p.account == "Expenses:Food")
.filter_map(|p| p.amount_in("$"))
.sum();
assert_eq!(
total,
dec!(15.00),
"without --cleared both transactions should contribute to the balance"
);
}
fn try_elaborate(input: &str) -> Result<crate::elaboration::Journal, ElaborationError> {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults())
}
#[test]
fn test_balance_assertion_succeeds_when_balance_matches() {
let input = "\
2024-01-01 Opening
Assets:Checking $1000.00
Equity:Opening
2024-01-01 = Assets:Checking $1000.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_balance_assertion_fails_when_balance_mismatches() {
let input = "\
2024-01-01 Opening
Assets:Checking $1000.00
Equity:Opening
2024-01-01 = Assets:Checking $500.00
";
let result = try_elaborate(input);
assert!(result.is_err(), "assertion should fail");
let err = result.unwrap_err();
match err {
ElaborationError::BalanceAssertionFailed {
ref account,
expected_amount,
actual_amount,
..
} => {
assert_eq!(account, "Assets:Checking");
assert_eq!(expected_amount, dec!(500.00));
assert_eq!(actual_amount, dec!(1000.00));
}
other => panic!("expected BalanceAssertionFailed, got: {other:?}"),
}
let msg = err.to_string();
assert!(
msg.contains("Assets:Checking"),
"error should name the account: {msg}"
);
assert!(
msg.contains("500"),
"error should show expected amount: {msg}"
);
assert!(
msg.contains("1000"),
"error should show actual amount: {msg}"
);
}
#[test]
fn test_balance_assertion_zero_balance_at_start() {
let input = "\
2024-01-01 = Assets:Checking $0.00
";
let journal = elaborate(input);
assert!(journal.transactions.is_empty());
}
#[test]
fn test_balance_assertion_nonzero_at_start_fails() {
let input = "\
2024-01-01 = Assets:Checking $100.00
";
let result = try_elaborate(input);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
ElaborationError::BalanceAssertionFailed { .. }
));
}
#[test]
fn test_balance_assertion_after_multiple_transactions() {
let input = "\
2024-01-01 First deposit
Assets:Checking $500.00
Income:Salary
2024-01-15 Second deposit
Assets:Checking $300.00
Income:Salary
2024-01-31 = Assets:Checking $800.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
}
#[test]
fn test_balance_assertion_with_expression() {
let input = "\
2024-01-01 Opening
Assets:Checking $1000.00
Equity:Opening
2024-01-01 = Assets:Checking $500.00 + $500.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_balance_assertion_weak_ignores_other_commodities() {
let input = "\
2024-01-01 USD deposit
Assets:Multi $1000.00
Equity:Opening
2024-01-02 EUR deposit
Assets:Multi 500.00 EUR
Equity:Opening
2024-01-02 = Assets:Multi $1000.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
}
#[test]
fn test_balance_assertion_between_transactions() {
let input = "\
2024-01-01 First
Assets:Checking $100.00
Equity:Opening
2024-01-01 = Assets:Checking $100.00
2024-01-02 Second
Assets:Checking $50.00
Income:Salary
2024-01-02 = Assets:Checking $150.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
}
#[test]
fn test_balance_assertion_strict_treated_as_weak() {
let input = "\
2024-01-01 Opening
Assets:Checking $1000.00
Equity:Opening
2024-01-01 == Assets:Checking $1000.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
fn elaborate_ok(input: &str) -> crate::elaboration::Journal {
elaborate(input)
}
fn elaborate_assert_fails(input: &str) -> (String, usize, String) {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
match crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults())
.expect_err("expected assertion failure")
{
ElaborationError::AccountAssertionFailed {
account,
posting_index,
expression,
} => (account, posting_index, expression),
e => panic!("expected AccountAssertionFailed, got {e:?}"),
}
}
#[test]
fn test_account_assert_commodity_passes() {
let input = "\
account Assets:Checking
assert commodity == \"$\"
2024-01-01 Deposit
Assets:Checking $500.00
Income:Salary
";
let journal = elaborate_ok(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_account_assert_commodity_fails() {
let input = "\
account Assets:Checking
assert commodity == \"$\"
2024-01-01 Foreign deposit
Assets:Checking 500 EUR
Income:Salary
";
let (account, posting_index, expression) = elaborate_assert_fails(input);
assert_eq!(account, "Assets:Checking");
assert_eq!(posting_index, 0);
assert!(
expression.contains("commodity"),
"expression should mention 'commodity', got: {expression}"
);
}
#[test]
fn test_account_assert_amount_positive_passes() {
let input = "\
account Income:Salary
assert amount < 0
2024-01-01 Paycheck
Income:Salary $-3000.00
Assets:Checking
";
let journal = elaborate_ok(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_account_assert_amount_fails() {
let input = "\
account Income:Salary
assert amount < 0
2024-01-01 Bad entry
Income:Salary $100.00
Assets:Checking
";
let (account, _, expression) = elaborate_assert_fails(input);
assert_eq!(account, "Income:Salary");
assert!(
expression.contains("amount"),
"expression should mention 'amount'"
);
}
#[test]
fn test_account_assert_dimensionless_lhs_compares_with_amount() {
let input = "\
account Assets:Savings
assert 0 < amount
2024-01-01 Deposit
Assets:Savings $100.00
Assets:Checking
";
let journal = elaborate_ok(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_account_assert_dimensionless_lhs_fails_when_false() {
let input = "\
account Assets:Savings
assert 0 < amount
2024-01-01 Withdrawal
Assets:Savings $-50.00
Assets:Checking
";
let (account, _, _) = elaborate_assert_fails(input);
assert_eq!(account, "Assets:Savings");
}
#[test]
fn test_account_assert_no_whitespace_around_cmp_op() {
let input = "\
account Assets:Savings
assert amount>0
2024-01-01 Deposit
Assets:Savings $100.00
Assets:Checking
";
let journal = elaborate_ok(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_multiple_asserts_all_must_pass() {
let input = "\
account Assets:Savings
assert commodity == \"$\"
assert amount > 0
2024-01-01 Withdrawal
Assets:Savings $-100.00
Assets:Checking
";
let (account, _, _) = elaborate_assert_fails(input);
assert_eq!(account, "Assets:Savings");
}
#[test]
fn test_account_check_failure_does_not_halt() {
let input = "\
account Expenses:Food
check amount > 0
2024-01-01 Refund
Expenses:Food $-10.00
Assets:Checking
";
let journal = elaborate_ok(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_account_check_passing_no_warning() {
let input = "\
account Expenses:Food
check amount > 0
2024-01-01 Dinner
Expenses:Food $25.00
Assets:Checking
";
let journal = elaborate_ok(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_assert_only_applies_to_declared_account() {
let input = "\
account Assets:Checking
assert commodity == \"$\"
2024-01-01 Euro lunch
Expenses:Food 50 EUR
Assets:Checking -50 EUR
";
let (account, _, _) = elaborate_assert_fails(input);
assert_eq!(account, "Assets:Checking");
}
#[test]
fn test_bool_expr_and_chain_fails_when_rhs_false() {
let input = "\
account Assets:Savings
assert amount > 0 and amount < 0
2024-01-01 Deposit
Assets:Savings $50.00
Assets:Checking
";
let (account, _, _) = elaborate_assert_fails(input);
assert_eq!(account, "Assets:Savings");
}
#[test]
fn test_bool_expr_or_chain_passes_when_either_true() {
let input = "\
account Assets:Savings
assert amount > 0 or amount < 0
2024-01-01 Deposit
Assets:Savings $50.00
Assets:Checking
";
let journal = elaborate_ok(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_balance_assignment_infers_commodity_from_account_balance() {
let input = "\
2026-04-01 Setup
Account A $100
Account B
2026-04-02 Zero out
Account A =0
Account B
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
let tx = &journal.transactions[1];
let posting_a = tx
.postings
.iter()
.find(|p| p.account == "Account A")
.expect("Account A posting not found");
assert_eq!(
posting_a.amount_in("$"),
Some(dec!(-100)),
"balance assignment =0 after $100 should yield -$100 delta"
);
}
#[test]
fn test_balance_assignment_explicit_commodity_still_works() {
let input = "\
2026-04-01 Setup
Account A $100
Account B
2026-04-02 Zero out
Account A =$0
Account B
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
let tx = &journal.transactions[1];
let posting_a = tx
.postings
.iter()
.find(|p| p.account == "Account A")
.expect("Account A posting not found");
assert_eq!(
posting_a.amount_in("$"),
Some(dec!(-100)),
"explicit =$0 should also yield -$100 delta"
);
}
#[test]
fn test_balance_assignment_with_default_commodity() {
let input = "\
commodity $
default
2026-04-01 Setup
Account A $100
Account B
2026-04-02 Zero out
Account A =0
Account B
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
let tx = &journal.transactions[1];
let posting_a = tx
.postings
.iter()
.find(|p| p.account == "Account A")
.expect("Account A posting not found");
assert_eq!(
posting_a.amount_in("$"),
Some(dec!(-100)),
"default-commodity path should yield -$100 delta"
);
}
#[test]
fn test_balance_assignment_ignores_stale_zero_commodities() {
let input = "\
2026-04-01 Setup USD
Account A $100
Account B
2026-04-02 Zero out USD
Account A =$0
Account B
2026-04-03 Add EUR
Account A EUR 50
Account B
2026-04-04 Zero out (bare)
Account A =0
Account B
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 4);
let tx = &journal.transactions[3];
let posting_a = tx
.postings
.iter()
.find(|p| p.account == "Account A")
.expect("Account A posting not found");
assert_eq!(
posting_a.amount_in("EUR"),
Some(dec!(-50)),
"bare =0 should infer the only non-zero commodity (EUR)"
);
}
#[test]
fn test_balance_assignment_infers_commodity_from_same_transaction() {
let input = "\
2026-04-01 Test
Account A $100
Account B =0
Account C $-100
";
let journal = elaborate(input);
let tx = &journal.transactions[0];
let posting_b = tx
.postings
.iter()
.find(|p| p.account == "Account B")
.expect("Account B posting not found");
assert!(
posting_b.amount_in("$").is_some(),
"bare =0 should infer $ from same-transaction context: {:?}",
posting_b.amount
);
assert_eq!(
posting_b.amount_in("$"),
Some(dec!(0)),
"Account B target is 0 with no prior balance, so delta is 0"
);
}
#[test]
fn test_balance_assignment_no_context_errors() {
let input = "\
2026-04-01 Test
Account A =0
";
let result = try_elaborate(input);
assert!(
result.is_err(),
"bare =0 with no commodity context anywhere should error"
);
}
#[test]
fn test_regex_match_string_literal_passes() {
let input = "\
account Expenses:Food
assert \"abc\" =~ /^a/
2024-01-01 Test
Expenses:Food $10.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_regex_match_string_literal_fails() {
let input = "\
account Expenses:Food
assert \"abc\" =~ /^z/
2024-01-01 Test
Expenses:Food $10.00
Assets:Checking
";
let result = try_elaborate(input);
assert!(
result.is_err(),
"regex match should fail when string doesn't match pattern"
);
}
#[test]
fn test_regex_not_match_passes_when_no_match() {
let input = "\
account Expenses:Food
assert \"abc\" !~ /^z/
2024-01-01 Test
Expenses:Food $10.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_regex_not_match_fails_when_match() {
let input = "\
account Expenses:Food
assert \"abc\" !~ /^a/
2024-01-01 Test
Expenses:Food $10.00
Assets:Checking
";
let result = try_elaborate(input);
assert!(
result.is_err(),
"!~ should fail when string matches pattern"
);
}
#[test]
fn test_regex_match_non_empty_string_pattern() {
let input = "\
account Expenses:Travel
assert commodity =~ /[a-z]/
2024-01-01 Test
Expenses:Travel 100 usd
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_fn_inherits_transaction_metadata() {
let input = "\
account Expenses:Food
assert tag(\"Entity\") =~ /^Foo/
2024-01-01 Lunch
; Entity: Foo Inc
Expenses:Food $10.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_fn_posting_overrides_transaction() {
let input = "\
account Expenses:Food
assert tag(\"Entity\") =~ /^Bar/
2024-01-01 Lunch
; Entity: Foo Inc
Expenses:Food $10.00
; Entity: Bar LLC
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_fn_matches_metadata_value() {
let input = "\
account Expenses:Food
assert tag(\"Entity\") =~ /^foo/
2024-01-01 Test
Expenses:Food $10.00
; Entity: foobar
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_fn_absent_key_returns_empty_string() {
let input = "\
account Expenses:Food
assert tag(\"Entity\") !~ /^foo/
2024-01-01 Test
Expenses:Food $10.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_fn_absent_key_fails_match() {
let input = "\
account Expenses:Food
assert tag(\"Entity\") =~ /^foo/
2024-01-01 Test
Expenses:Food $10.00
Assets:Checking
";
let result = try_elaborate(input);
assert!(
result.is_err(),
"tag() on absent key returns empty string, which should not match /^foo/"
);
}
#[test]
fn test_tag_fn_chained_and_both_present() {
let input = "\
account Income:Salary
assert tag(\"Entity\") !~ /^\\s*$/ and tag(\"IncomeType\") !~ /^\\s*$/
2024-01-01 Paycheck
Income:Salary $-5000.00
; Entity: AcmeCorp
; IncomeType: Salary
Assets:Checking $5000.00
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_fn_chained_and_one_missing() {
let input = "\
account Income:Salary
assert tag(\"Entity\") !~ /^\\s*$/ and tag(\"IncomeType\") !~ /^\\s*$/
2024-01-01 Paycheck
Income:Salary $-5000.00
; Entity: AcmeCorp
Assets:Checking $5000.00
";
let result = try_elaborate(input);
assert!(
result.is_err(),
"chained tag() check should fail when one tag is absent"
);
}
#[test]
fn test_tag_fn_does_not_see_bare_colon_tags() {
let input = "\
account Expenses:Food
assert tag(\"foo\") =~ /foo/
2024-01-01 Test
Expenses:Food $10.00
; :foo:
Assets:Checking
";
let result = try_elaborate(input);
assert!(
result.is_err(),
"tag() must return \"\" for bare colon-style tags; /foo/ should not match"
);
}
#[test]
fn test_invalid_regex_fails_at_parse_time() {
use crate::grammars::ledger::parse_ledger;
let input = "\
account Expenses:Food
assert commodity =~ /[unclosed/
";
let result = parse_ledger(input);
let err = result.expect_err("invalid regex should fail parsing");
let msg = err.to_string();
assert!(
msg.contains("[unclosed") && msg.contains("invalid regex"),
"error message should include the invalid pattern and identify it as a regex; got: {msg}"
);
}
#[test]
fn test_tag_assert_passes_when_value_matches() {
let input = "\
tag Statement
assert value =~ /^foo/
2024-01-01 Test
; Statement: foobar
Expenses:Food $10.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_assert_fails_when_value_does_not_match() {
let input = "\
tag Statement
assert value =~ /^foo/
2024-01-01 Test
; Statement: barfoo
Expenses:Food $10.00
Assets:Checking
";
let result = try_elaborate(input);
assert!(
matches!(result, Err(ElaborationError::TagAssertionFailed { .. })),
"expected TagAssertionFailed, got: {result:?}"
);
if let Err(ElaborationError::TagAssertionFailed {
tag_name,
tag_value,
..
}) = result
{
assert_eq!(tag_name, "Statement");
assert_eq!(tag_value, "barfoo");
}
}
#[test]
fn test_tag_check_warns_does_not_halt() {
let input = "\
tag Statement
check value =~ /^foo/
2024-01-01 Test
; Statement: barfoo
Expenses:Food $10.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_multiple_asserts_all_must_pass() {
let input = "\
tag IncomeType
assert value =~ /^(Donations|RBI|UBTI)$/
2024-01-01 Income
; IncomeType: RBI
Income:Donations $100.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_assert_invalid_value_fails() {
let input = "\
tag IncomeType
assert value =~ /^(Donations|RBI|UBTI)$/
2024-01-01 Income
; IncomeType: Salary
Income:Salary $100.00
Assets:Checking
";
let result = try_elaborate(input);
assert!(
matches!(result, Err(ElaborationError::TagAssertionFailed { .. })),
"expected TagAssertionFailed for unrecognised IncomeType"
);
}
#[test]
fn test_tag_declared_but_unused_no_error() {
let input = "\
tag Receipt
assert value =~ /foo/
2024-01-01 Test
Expenses:Food $10.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_assert_on_posting_level_metadata() {
let input = "\
tag Statement
assert value =~ /^foo/
2024-01-01 Test
Expenses:Food $10.00
; Statement: barfoo
Assets:Checking
";
let result = try_elaborate(input);
assert!(
matches!(result, Err(ElaborationError::TagAssertionFailed { .. })),
"posting-level tag metadata should also be validated"
);
}
#[test]
fn test_tag_assert_on_posting_level_metadata_passes() {
let input = "\
tag Statement
assert value =~ /^foo/
2024-01-01 Test
Expenses:Food $10.00
; Statement: foobar
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_tag_directive_does_not_validate_bare_colon_tags() {
let input = "\
tag payroll
assert value =~ /^.+$/
2024-01-01 Payroll
; :payroll:
Income:Salary $5000.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
}
#[test]
fn test_parameterized_define_bool_passing() {
let input = "\
define isPositive(x) = x > 0
account Expenses:Food
assert isPositive(amount)
2024-01-01 Lunch
Expenses:Food $10.00
Assets:Cash
";
elaborate(input);
}
#[test]
fn test_parameterized_define_bool_failing() {
let input = "\
define isNegative(x) = x < 0
account Expenses:Food
assert isNegative(amount)
2024-01-01 Lunch
Expenses:Food $10.00
Assets:Cash
";
let ast = crate::grammars::ledger::parse_ledger(input).expect("parse failed");
let hir = crate::resolution::HIR::try_from(ast).expect("resolution failed");
let result = crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults());
assert!(
matches!(result, Err(ElaborationError::AccountAssertionFailed { .. })),
"positive amount should fail isNegative assertion; got: {result:?}"
);
}
#[test]
fn test_parameterized_define_with_tag_and_regex_passing() {
let input = "\
define hasReceipt(x) = tag(\"Receipt\") !~ /^\\s*$/ and x > 0
account Expenses:Food
assert hasReceipt(amount)
2024-01-01 Lunch
Expenses:Food $10.00
; Receipt: scan123.pdf
Assets:Cash
";
elaborate(input);
}
#[test]
fn test_parameterized_define_two_args_passing() {
let input = "\
define between(lo, hi) = amount > lo and amount < hi
account Expenses:Food
assert between(0, 100)
2024-01-01 Lunch
Expenses:Food $50.00
Assets:Cash
";
elaborate(input);
}
#[test]
fn test_parameterized_define_two_args_failing() {
let input = "\
define between(lo, hi) = amount > lo and amount < hi
account Expenses:Food
assert between(0, 10)
2024-01-01 BigPurchase
Expenses:Food $50.00
Assets:Cash
";
let ast = crate::grammars::ledger::parse_ledger(input).expect("parse failed");
let hir = crate::resolution::HIR::try_from(ast).expect("resolution failed");
let result = crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults());
assert!(
matches!(result, Err(ElaborationError::AccountAssertionFailed { .. })),
"$50 should fail between(0, 10); got: {result:?}"
);
}
#[test]
fn test_parameterized_define_param_shadows_amount() {
let input = "\
define isPositiveAmt(amount) = amount > 0
account Expenses:Food
assert isPositiveAmt(amount)
2024-01-01 Lunch
Expenses:Food $10.00
Assets:Cash
";
elaborate(input);
}
#[test]
fn test_zero_param_define_value_body_still_works() {
let input = "\
define monthly = $1500.00
2024-01-01 Rent
Expenses:Rent monthly
Assets:Cash
";
let journal = elaborate(input);
let rent = journal.transactions[0]
.postings
.iter()
.find(|p| p.account == "Expenses:Rent")
.unwrap();
assert_eq!(rent.amount_in("$"), Some(dec!(1500.00)));
}
#[test]
fn test_mutually_recursive_defines_caught() {
let input = "\
define a = b
define b = a
2024-01-01 Test
Expenses:Food a
Assets:Cash
";
let result = try_elaborate(input);
assert!(
matches!(
result,
Err(ElaborationError::EvaluationError(
EvaluationError::RecursionLimitExceeded
))
),
"cyclic defines should produce RecursionLimitExceeded; got: {result:?}"
);
}
#[test]
fn test_self_referential_define_caught() {
let input = "\
define x = x
2024-01-01 Test
Expenses:Food x
Assets:Cash
";
let result = try_elaborate(input);
assert!(
matches!(
result,
Err(ElaborationError::EvaluationError(
EvaluationError::RecursionLimitExceeded
))
),
"self-referential define should produce RecursionLimitExceeded; got: {result:?}"
);
}
#[test]
fn test_parameterized_define_value_body_in_posting() {
let input = "\
define double(x) = x * 2
2024-01-01 Purchase
Expenses:Food double(50 USD)
Assets:Cash
";
let journal = elaborate(input);
let food = journal.transactions[0]
.postings
.iter()
.find(|p| p.account == "Expenses:Food")
.unwrap();
assert_eq!(food.amount_in("USD"), Some(dec!(100)));
}
#[test]
fn test_paren_bool_simple_assert_passes() {
let input = "\
account Assets:Savings
assert (amount > 0)
2024-01-01 Deposit
Assets:Savings $100.00
Assets:Cash
";
elaborate(input); }
#[test]
fn test_paren_bool_or_chain_passes_when_first_true() {
let input = "\
account Assets:Savings
assert (amount > 0 or amount < -10)
2024-01-01 Deposit
Assets:Savings $100.00
Assets:Cash
";
elaborate(input);
}
#[test]
fn test_paren_bool_or_chain_passes_when_second_true() {
let input = "\
account Assets:Savings
assert (amount > 0 or amount < -10)
2024-01-01 Withdrawal
Assets:Cash $100.00
Assets:Savings $-100.00
";
elaborate(input);
}
#[test]
fn test_define_paren_bool_used_in_assert() {
let input = "\
define inRange(x) = (x > 0 and x < 1000)
account Assets:Savings
assert inRange(amount)
2024-01-01 Deposit
Assets:Savings $100.00
Assets:Cash
";
elaborate(input);
}
#[test]
fn test_issue_89_define_with_complex_paren_bool() {
let input = "\
define assetChecker(amt) = (amt > -100.00 or (tag(\"TaxImplication\") !~ /^\\s*$/ and tag(\"Entity\") !~ /^\\s*$/))
account Assets:Savings
assert assetChecker(amount)
2024-01-01 Deposit
Assets:Savings $500.00
Assets:Cash
";
elaborate(input);
}
#[test]
fn virtual_unbalanced_does_not_affect_null_posting_inference() {
let input = "\
2024-01-15 Test
Assets:Checking $100
(Equity:Reservations) $-25
Equity:Opening
";
let j = elaborate(input);
let t = &j.transactions[0];
assert_eq!(t.postings.len(), 3);
let null_p = t
.postings
.iter()
.find(|p| p.account == "Equity:Opening")
.expect("null posting present");
assert_eq!(null_p.amount_in("$"), Some(dec!(-100)));
let virt = t
.postings
.iter()
.find(|p| p.account == "Equity:Reservations")
.expect("virtual posting present");
assert_eq!(virt.amount_in("$"), Some(dec!(-25)));
use crate::elaboration::PostingKind;
assert_eq!(virt.kind, PostingKind::VirtualUnbalanced as i32);
assert_eq!(null_p.kind, PostingKind::Real as i32);
}
#[test]
fn virtual_balanced_participates_in_null_posting_inference() {
let input = "\
2024-01-15 Test
Assets:Checking $100
[Equity:Reservations] $25
Equity:Opening
";
let j = elaborate(input);
let t = &j.transactions[0];
assert_eq!(t.postings.len(), 3);
let null_p = t
.postings
.iter()
.find(|p| p.account == "Equity:Opening")
.expect("null posting present");
assert_eq!(null_p.amount_in("$"), Some(dec!(-125)));
let virt = t
.postings
.iter()
.find(|p| p.account == "Equity:Reservations")
.expect("virtual posting present");
assert_eq!(virt.amount_in("$"), Some(dec!(25)));
use crate::elaboration::PostingKind;
assert_eq!(virt.kind, PostingKind::VirtualBalanced as i32);
}
#[test]
fn transaction_with_only_virtual_unbalanced_postings_does_not_error() {
let input = "\
2024-01-15 Memo-only entry
(Budget:Food) $50
(Budget:Travel) $-50
";
let j = elaborate(input);
let t = &j.transactions[0];
assert_eq!(t.postings.len(), 2);
use crate::elaboration::PostingKind;
for p in &t.postings {
assert_eq!(p.kind, PostingKind::VirtualUnbalanced as i32);
}
}
#[test]
fn virtual_unbalanced_posting_updates_account_balance_for_assertions() {
let input = "\
2024-01-15 Setup
Assets:Checking $100
(Equity:Reservations) $-25
Equity:Opening
2024-01-15 = Equity:Reservations $-25
";
let j = elaborate(input);
assert_eq!(j.transactions.len(), 1);
let virt = j.transactions[0]
.postings
.iter()
.find(|p| p.account == "Equity:Reservations")
.expect("virtual posting present");
assert_eq!(virt.amount_in("$"), Some(dec!(-25)));
}
#[test]
fn test_lot_cost_only_drives_cash_balance() {
let input = "\
2024-03-01 Buy AAPL
Assets:Brokerage 10 AAPL {$150}
Assets:Cash
";
let journal = elaborate(input);
let t = &journal.transactions[0];
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(
t.postings[1].amount_in("$"),
Some(dec!(-1500)),
"cash side should be -$1500 when lot cost drives balance"
);
assert_eq!(t.postings[0].lot_cost_in("$"), Some(dec!(150)));
}
#[test]
fn test_lot_cost_and_price_cost_wins_cash() {
let input = "\
2024-03-01 Buy AAPL
Assets:Brokerage 10 AAPL {$150} @ $155
Assets:Cash
";
let journal = elaborate(input);
let t = &journal.transactions[0];
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(
t.postings[1].amount_in("$"),
Some(dec!(-1500)),
"cash side should be -$1500 when {{cost}} is present (cost wins over @price)"
);
assert_eq!(t.postings[0].lot_cost_in("$"), Some(dec!(150)));
}
#[test]
fn test_lot_no_cost_no_price_value_in_own_commodity() {
let input = "\
2024-03-01 Transfer
Assets:Brokerage 10 AAPL
Assets:OtherBrokerage
";
let journal = elaborate(input);
let t = &journal.transactions[0];
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(
t.postings[1].amount_in("AAPL"),
Some(dec!(-10)),
"null posting should balance as -10 AAPL when no price is given"
);
assert!(!t.postings[0].has_lot(), "no lot annotation should be set");
}
fn elaborate_with_lot_mode(
input: &str,
mode: resolution::LotValidationMode,
) -> Result<crate::elaboration::Journal, ElaborationError> {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
let mut config = crate::grammars::ledger::ledger_defaults();
config.lot_validation_mode = mode;
crate::elaborate(hir, &config)
}
#[test]
fn strict_mode_rejects_phantom_lot_among_existing_lots() {
let input = "\
2024-03-01 Buy AAPL at $150
Assets:Brokerage 10 AAPL {$150}
Assets:Cash
2024-03-02 Try to sell at a phantom cost basis
Assets:Brokerage -5 AAPL {$200}
Assets:Cash $1000
";
let err = elaborate_with_lot_mode(input, resolution::LotValidationMode::Strict)
.expect_err("strict mode should reject phantom lot among existing lots");
match err {
ElaborationError::PhantomLotReduction {
ref account,
ref commodity,
..
} => {
assert_eq!(account, "Assets:Brokerage");
assert_eq!(commodity, "AAPL");
}
other => panic!("expected PhantomLotReduction, got {other:?}"),
}
}
#[test]
fn strict_mode_accepts_phantom_lot_with_no_prior_positions() {
let input = "\
2024-03-02 Open short position
Assets:Brokerage -5 AAPL {$150}
Assets:Cash $750
";
elaborate_with_lot_mode(input, resolution::LotValidationMode::Strict)
.expect("strict mode should accept reduction with no prior positions");
}
#[test]
fn permissive_mode_accepts_phantom_lot_among_existing_lots() {
let input = "\
2024-03-01 Buy AAPL at $150
Assets:Brokerage 10 AAPL {$150}
Assets:Cash
2024-03-02 Try to sell at a phantom cost basis
Assets:Brokerage -5 AAPL {$200}
Assets:Cash $1000
";
let journal = elaborate_with_lot_mode(input, resolution::LotValidationMode::Permissive)
.expect("permissive mode should accept");
assert_eq!(journal.transactions.len(), 2);
}
#[test]
fn strict_mode_accepts_augmentation_followed_by_reduction_same_transaction() {
let input = "\
2024-03-02 Buy and immediately rebalance
Assets:Brokerage 5 AAPL {$150}
Assets:Brokerage -5 AAPL {$150}
Assets:Cash $0
";
elaborate_with_lot_mode(input, resolution::LotValidationMode::Strict)
.expect("augment+reduce in same tx should be accepted");
}
#[test]
fn strict_mode_accepts_reduction_against_prior_augmentation() {
let input = "\
2024-03-01 Buy AAPL
Assets:Brokerage 10 AAPL {$150}
Assets:Cash
2024-03-02 Sell AAPL
Assets:Brokerage -5 AAPL {$150}
Assets:Cash $750
";
let journal = elaborate_with_lot_mode(input, resolution::LotValidationMode::Strict)
.expect("reduction with prior augmentation should be accepted");
assert_eq!(journal.transactions.len(), 2);
}
#[test]
fn strict_mode_does_not_constrain_postings_without_lot_annotation() {
let input = "\
2024-03-01 Plain transfer
Assets:Cash -$50
Expenses:Food $50
";
elaborate_with_lot_mode(input, resolution::LotValidationMode::Strict)
.expect("non-lot postings should be unaffected by strict mode");
}
fn elaborate_ledger(input: &str) -> crate::elaboration::Journal {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults())
.expect("elaboration failed")
}
fn try_elaborate_ledger(input: &str) -> Result<crate::elaboration::Journal, ElaborationError> {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let ast = parse_ledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults())
}
#[test]
fn implicit_cost_ledger_two_leg_stock_buy() {
let input = "\
2002/09/30 * Buy
Assets:Stock 866.231 GGGGG
Assets:Cash $-17783.72
";
let journal = elaborate_ledger(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let stock = tx
.postings
.iter()
.find(|p| p.account == "Assets:Stock")
.expect("stock posting present");
assert_eq!(
stock.amount_in("GGGGG"),
Some(dec!(866.231)),
"stock posting amount"
);
let cash = tx
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.expect("cash posting present");
assert_eq!(
cash.amount_in("$"),
Some(dec!(-17783.72)),
"cash posting amount"
);
}
#[test]
fn implicit_cost_ledger_two_leg_balances() {
let input = "\
2024-01-15 Buy ETH
Assets:Crypto 2.5 ETH
Assets:Bank $-5000
";
let journal = elaborate_ledger(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let bank = tx
.postings
.iter()
.find(|p| p.account == "Assets:Bank")
.expect("bank posting");
assert_eq!(bank.amount_in("$"), Some(dec!(-5000)));
let crypto = tx
.postings
.iter()
.find(|p| p.account == "Assets:Crypto")
.expect("crypto posting");
assert_eq!(crypto.amount_in("ETH"), Some(dec!(2.5)));
}
#[test]
fn implicit_cost_ledger_inferred_lot_matches_explicit() {
let explicit_input = "\
2024-03-01 Buy stock explicit
Assets:Brokerage 10 AAPL @@ $1800
Assets:Cash $-1800
";
let implicit_input = "\
2024-03-01 Buy stock implicit
Assets:Brokerage 10 AAPL
Assets:Cash $-1800
";
let explicit_journal = elaborate_ledger(explicit_input);
let implicit_journal = elaborate_ledger(implicit_input);
assert_eq!(explicit_journal.transactions.len(), 1);
assert_eq!(implicit_journal.transactions.len(), 1);
let explicit_tx = &explicit_journal.transactions[0];
let implicit_tx = &implicit_journal.transactions[0];
let explicit_brokerage = explicit_tx
.postings
.iter()
.find(|p| p.account == "Assets:Brokerage")
.expect("explicit brokerage posting");
let implicit_brokerage = implicit_tx
.postings
.iter()
.find(|p| p.account == "Assets:Brokerage")
.expect("implicit brokerage posting");
assert_eq!(
explicit_brokerage.amount_in("AAPL"),
implicit_brokerage.amount_in("AAPL"),
"brokerage AAPL amounts should match"
);
let explicit_cash = explicit_tx
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.expect("explicit cash posting");
let implicit_cash = implicit_tx
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.expect("implicit cash posting");
assert_eq!(
explicit_cash.amount_in("$"),
implicit_cash.amount_in("$"),
"cash amounts should match"
);
}
#[test]
fn implicit_cost_hledger_two_leg() {
use crate::{grammars::hledger::parse_hledger, resolution::HIR};
let input = "\
2024-01-15 Buy
Assets:Crypto 2.5 ETH
Assets:Bank $-5000
";
let ast = parse_hledger(input).expect("parse");
let hir = HIR::try_from(ast).expect("resolution");
let journal = crate::elaborate(hir, &crate::grammars::hledger::hledger_defaults())
.expect("hledger elaboration should accept implicit cost");
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let crypto = tx
.postings
.iter()
.find(|p| p.account == "Assets:Crypto")
.expect("crypto posting");
assert_eq!(
crypto.amount_in("ETH"),
Some(dec!(2.5)),
"hledger implicit cost: ETH posting amount"
);
let bank = tx
.postings
.iter()
.find(|p| p.account == "Assets:Bank")
.expect("bank posting");
assert_eq!(
bank.amount_in("$"),
Some(dec!(-5000)),
"hledger implicit cost: bank posting amount"
);
}
#[test]
fn implicit_cost_beancount_two_leg_still_rejects() {
use crate::{grammars::beancount::parse_beancount, resolution::HIR};
let input = "\
2024-01-15 * \"Buy\"
Assets:Crypto 2.5 ETH
Assets:Bank -5000 USD
";
let ast = parse_beancount(input).expect("beancount parse");
let hir = HIR::try_from(ast).expect("resolution");
let result = crate::elaborate(hir, &crate::grammars::beancount::beancount_defaults());
assert!(
matches!(result, Err(ElaborationError::TransactionDoesNotBalance(_))),
"Beancount must not infer implicit cost; got: {result:?}"
);
}
#[test]
fn implicit_cost_three_leg_still_rejects() {
let input = "\
2024-01-15 Three legs
Assets:Brokerage 10 AAPL
Assets:Cash $-1800
Expenses:Commission $5
";
let result = try_elaborate_ledger(input);
assert!(
matches!(result, Err(ElaborationError::TransactionDoesNotBalance(_))),
"three-leg shape must not trigger implicit cost inference; got: {result:?}"
);
}
#[test]
fn implicit_cost_explicit_at_at_no_regression() {
let input = "\
2024-01-15 Buy with explicit price
Assets:Brokerage 10 AAPL @@ $1800
Assets:Cash $-1800
";
let journal = elaborate_ledger(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let stock = tx
.postings
.iter()
.find(|p| p.account == "Assets:Brokerage")
.expect("brokerage posting");
assert_eq!(
stock.amount_in("AAPL"),
Some(dec!(10)),
"explicit @@ should preserve AAPL units"
);
let cash = tx
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.expect("cash posting");
assert_eq!(cash.amount_in("$"), Some(dec!(-1800)));
}
#[test]
fn implicit_cost_virtual_posting_does_not_fire() {
let input = "\
2024-01-15 Virtual example
Assets:Cash $-1000
(Track:Units) 10 GGGGG
Equity:Opening
";
let journal = elaborate_ledger(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let equity = tx
.postings
.iter()
.find(|p| p.account == "Equity:Opening")
.expect("equity posting");
assert_eq!(
equity.amount_in("$"),
Some(dec!(1000)),
"null posting should be filled to $1000"
);
let virtual_posting = tx
.postings
.iter()
.find(|p| p.account == "Track:Units")
.expect("virtual posting");
assert!(
virtual_posting.lot.is_none(),
"virtual posting should not receive an inferred lot"
);
}
#[test]
fn auto_rule_literal_amount_synthesises_posting() {
let input = "\
= /^Income/
(Liabilities:Tithe) $12.00
2024-01-01 * Salary
Income:Salary $-100.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "Liabilities:Tithe")
.collect();
assert_eq!(synth.len(), 1, "one synthesised posting expected");
let amount = synth[0]
.amount
.as_ref()
.expect("synthesised posting has amount");
let decimal = amount.by_commodity.get("$").expect("$ commodity");
assert_eq!(
decimal.to_decimal(),
rust_decimal::Decimal::from(12),
"literal $12.00 expected"
);
}
#[test]
fn auto_rule_multiplier_scales_matched_posting() {
let input = "\
= /^Income/
(Liabilities:Tithe) 0.10
2024-01-01 * Salary
Income:Salary $-100.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "Liabilities:Tithe")
.collect();
assert_eq!(synth.len(), 1, "one synthesised posting expected");
let amount = synth[0]
.amount
.as_ref()
.expect("synthesised posting has amount");
let decimal = amount.by_commodity.get("$").expect("$ commodity");
assert_eq!(
decimal.to_decimal(),
rust_decimal::Decimal::from(-10),
"10% of $-100 = $-10 expected"
);
}
#[test]
fn auto_rule_no_match_produces_no_postings() {
let input = "\
= /^Expenses/
(Liabilities:Tithe) 0.10
2024-01-01 * Salary
Income:Salary $-100.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "Liabilities:Tithe")
.collect();
assert_eq!(synth.len(), 0, "no match, no synthesised posting expected");
}
#[test]
fn auto_rule_applies_to_multiple_transactions() {
let input = "\
= /^Income/
(Liabilities:Tithe) 0.10
2024-01-01 * January salary
Income:Salary $-100.00
Assets:Checking
2024-02-01 * February salary
Income:Salary $-200.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
for tx in &journal.transactions {
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "Liabilities:Tithe")
.collect();
assert_eq!(
synth.len(),
1,
"each transaction gets its own synthesised posting"
);
}
}
#[test]
fn auto_rule_matches_multiple_postings_in_same_transaction() {
let input = "\
= /^Income/
(Liabilities:Tithe) 0.10
2024-01-01 * Multi-income
Income:Salary $-100.00
Income:Bonus $-50.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "Liabilities:Tithe")
.collect();
assert_eq!(synth.len(), 2, "two matches => two synthesised postings");
}
#[test]
fn auto_rule_body_posting_no_amount_synthesises_amountless_posting() {
let input = "\
= /^Income/
(Liabilities:Tithe)
2024-01-01 * Salary
Income:Salary $-100.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "Liabilities:Tithe")
.collect();
assert_eq!(synth.len(), 1, "one synthesised posting expected");
assert!(
synth[0].amount.is_none(),
"body posting with no amount should produce amountless synthesised posting"
);
}
#[test]
fn periodic_directive_parse_and_discard() {
let input = "\
~ monthly
Expenses:Rent $1000
Assets:Checking
2024-01-01 * Real transaction
Expenses:Food $50
Assets:Cash
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
assert_eq!(journal.transactions[0].description, "Real transaction");
}
#[test]
fn beancount_frontend_unaffected_by_auto_rules() {
use crate::{grammars::beancount::parse_beancount, resolution::HIR};
let input = "\
2024-01-01 open Assets:Checking USD
2024-01-01 open Income:Salary USD
2024-01-01 * \"Salary\"
Income:Salary -100 USD
Assets:Checking 100 USD
";
let ast = parse_beancount(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
assert!(
hir.auto_rules.is_empty(),
"Beancount frontend should produce no auto-rules"
);
let journal = crate::elaborate(hir, &crate::grammars::ledger::ledger_defaults())
.expect("elaboration failed");
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
assert_eq!(
tx.postings.len(),
2,
"no synthesised postings expected from Beancount journal"
);
}
#[test]
fn auto_rule_invalid_regex_fails_at_resolution() {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let input = "\
= /[unclosed/
(Liabilities:Bad) $1.00
2024-01-01 * Salary
Income:Salary $-100.00
Assets:Checking
";
let ast = parse_ledger(input).expect("parse should succeed");
let err = HIR::try_from(ast).expect_err("resolution should reject invalid regex");
match err {
resolution::ResolutionError::InvalidAutoRuleQuery(query, _) => {
assert_eq!(query, "/[unclosed/");
}
other => panic!("expected InvalidAutoRuleQuery, got {other:?}"),
}
}
#[test]
fn hledger_auto_rule_parity_with_ledger() {
use crate::{grammars::hledger::parse_hledger, resolution::HIR};
let input = "\
= expenses:groceries
(budget:groceries) 0.10
2024-01-01 * Shopping
expenses:groceries $50
assets:cash
";
let ast = parse_hledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
let journal = crate::elaborate(hir, &crate::grammars::hledger::hledger_defaults())
.expect("elaboration failed");
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "budget:groceries")
.collect();
assert_eq!(synth.len(), 1, "one synthesised posting expected");
let amount = synth[0]
.amount
.as_ref()
.expect("synthesised posting has amount");
let decimal = amount.by_commodity.get("$").expect("$ commodity");
assert_eq!(
decimal.to_decimal(),
rust_decimal::Decimal::from(5),
"10% of $50 = $5 expected"
);
}
#[test]
fn ledger_star_n_multiplier_equals_bare_decimal() {
let input = "\
= /^Income/
(Liabilities:Tithe) *0.10
2024-01-01 * Salary
Income:Salary $-100.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "Liabilities:Tithe")
.collect();
assert_eq!(synth.len(), 1, "one synthesised posting expected");
let amount = synth[0]
.amount
.as_ref()
.expect("synthesised posting has amount");
let decimal = amount.by_commodity.get("$").expect("$ commodity");
assert_eq!(
decimal.to_decimal(),
rust_decimal::Decimal::from(-10),
"*0.10 of $-100 should yield $-10"
);
}
#[test]
fn ledger_star_negative_one_negates_matched_amount() {
let input = "\
= /^Income/
Equity:Budget *-1
2024-01-01 * Salary
Income:Salary $-100.00
Assets:Checking
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "Equity:Budget")
.collect();
assert_eq!(synth.len(), 1, "one synthesised posting expected");
let amount = synth[0]
.amount
.as_ref()
.expect("synthesised posting has amount");
let decimal = amount.by_commodity.get("$").expect("$ commodity");
assert_eq!(
decimal.to_decimal(),
rust_decimal::Decimal::from(100),
"*-1 of $-100 should yield $100"
);
}
#[test]
fn ledger_star_n_with_whitespace_is_accepted() {
let input = "\
= /^Income/
(Split:Half) * 0.5
2024-01-01 * Salary
Income:Salary $-200.00
Assets:Checking
";
let journal = elaborate(input);
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "Split:Half")
.collect();
assert_eq!(synth.len(), 1);
let amount = synth[0].amount.as_ref().expect("has amount");
let decimal = amount.by_commodity.get("$").expect("$ commodity");
assert_eq!(
decimal.to_decimal(),
rust_decimal::Decimal::from(-100),
"* 0.5 of $-200 should yield $-100"
);
}
#[test]
fn hledger_star_n_multiplier_equals_bare_decimal() {
use crate::{grammars::hledger::parse_hledger, resolution::HIR};
let input = "\
= expenses:groceries
(budget:groceries) *0.10
2024-01-01 * Shopping
expenses:groceries $50
assets:cash
";
let ast = parse_hledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
let journal = crate::elaborate(hir, &crate::grammars::hledger::hledger_defaults())
.expect("elaboration failed");
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "budget:groceries")
.collect();
assert_eq!(synth.len(), 1, "one synthesised posting expected");
let amount = synth[0].amount.as_ref().expect("has amount");
let decimal = amount.by_commodity.get("$").expect("$ commodity");
assert_eq!(
decimal.to_decimal(),
rust_decimal::Decimal::from(5),
"*0.10 of $50 should yield $5"
);
}
#[test]
fn hledger_star_negative_one_negates_matched_amount() {
use crate::{grammars::hledger::parse_hledger, resolution::HIR};
let input = "\
= expenses:groceries
(budget:groceries) *-1
2024-01-01 * Shopping
expenses:groceries $50
assets:cash
";
let ast = parse_hledger(input).expect("parse failed");
let hir = HIR::try_from(ast).expect("resolution failed");
let journal = crate::elaborate(hir, &crate::grammars::hledger::hledger_defaults())
.expect("elaboration failed");
let tx = &journal.transactions[0];
let synth: Vec<_> = tx
.postings
.iter()
.filter(|p| p.account == "budget:groceries")
.collect();
assert_eq!(synth.len(), 1, "one synthesised posting expected");
let amount = synth[0].amount.as_ref().expect("has amount");
let decimal = amount.by_commodity.get("$").expect("$ commodity");
assert_eq!(
decimal.to_decimal(),
rust_decimal::Decimal::from(-50),
"*-1 of $50 should yield $-50"
);
}
#[test]
fn test_c_directive_c_to_s() {
let input = "\
C 1.00s = 100c
2024-01-01 Test
Assets:Cash 250c
Equity
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let cash = tx
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.unwrap();
assert_eq!(
cash.amount_in("s"),
Some(dec!(2.50)),
"250c / 100 should be 2.50s"
);
}
#[test]
fn test_c_directive_canonical_posting() {
let input = "\
C 100c = 1.00s
2024-01-01 Test
Assets:Cash 250c
Equity
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let cash = tx
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.unwrap();
assert_eq!(
cash.amount_in("c"),
Some(dec!(250)),
"posting in canonical commodity should not be rescaled (divisor = 1)"
);
}
#[test]
fn test_alias_divisor_one_regression() {
let input = "\
commodity BTC
alias Bitcoin
2024-01-01 Test
Assets:Wallet 2 Bitcoin
Equity
";
let journal = elaborate(input);
let tx = &journal.transactions[0];
let wallet = tx
.postings
.iter()
.find(|p| p.account == "Assets:Wallet")
.unwrap();
assert_eq!(
wallet.amount_in("BTC"),
Some(dec!(2)),
"alias with divisor=1 should be a plain rename, not scaled"
);
}
#[test]
fn test_c_directive_not_retroactive() {
let input = "\
2024-01-01 Before
Assets:Cash 250c
Equity
C 1.00s = 100c
2024-01-02 After
Assets:Cash 250c
Equity
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 2);
let before = &journal.transactions[0];
let before_cash = before
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.unwrap();
assert_eq!(
before_cash.amount_in("c"),
Some(dec!(250)),
"posting before C directive should not be affected"
);
let after = &journal.transactions[1];
let after_cash = after
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.unwrap();
assert_eq!(
after_cash.amount_in("s"),
Some(dec!(2.50)),
"posting after C directive should convert to canonical commodity"
);
}
#[test]
fn test_c_directive_chain_applied_for_balance_verification() {
let input = "\
C 1.00s = 100c
C 1.00G = 100s
2024-01-01 mixed
Assets:A 1G
Assets:B -10000c
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let asset_a = tx
.postings
.iter()
.find(|p| p.account == "Assets:A")
.unwrap();
let asset_b = tx
.postings
.iter()
.find(|p| p.account == "Assets:B")
.unwrap();
assert_eq!(
asset_a.amount_in("G"),
Some(dec!(1)),
"Assets:A should be 1G"
);
assert_eq!(
asset_b.amount_in("G"),
Some(dec!(-1)),
"Assets:B should be -1G after 2-hop c→s→G conversion"
);
}
#[test]
fn test_c_directive_three_hop_chain() {
let input = "\
C 1.00s = 100c
C 1.00G = 100s
C 1.00P = 10G
2024-01-01 mixed
Assets:A 1P
Assets:B -100000c
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let asset_a = tx
.postings
.iter()
.find(|p| p.account == "Assets:A")
.unwrap();
let asset_b = tx
.postings
.iter()
.find(|p| p.account == "Assets:B")
.unwrap();
assert_eq!(
asset_a.amount_in("P"),
Some(dec!(1)),
"Assets:A should be 1P"
);
assert_eq!(
asset_b.amount_in("P"),
Some(dec!(-1)),
"Assets:B should be -1P after 3-hop c→s→G→P conversion"
);
}
#[test]
fn test_c_directive_cycle_detection() {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let input = "\
C 1.00X = 1Y
C 1.00Y = 1X
2024-01-01 t
Assets:A 1X
Equity
";
let ast = parse_ledger(input).expect("parse should succeed");
let err = HIR::try_from(ast).expect_err("cycle should be detected at resolution time");
assert!(
matches!(
err,
crate::resolution::ResolutionError::CommodityConversionCycle(_)
),
"expected CommodityConversionCycle, got: {err:?}"
);
}
#[test]
fn test_c_directive_single_hop_regression() {
let input = "\
C 1.00s = 100c
2024-01-01 Test
Assets:Cash 250c
Equity
";
let journal = elaborate(input);
let tx = &journal.transactions[0];
let cash = tx
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.unwrap();
assert_eq!(
cash.amount_in("s"),
Some(dec!(2.50)),
"single-hop: 250c / 100 should be 2.50s"
);
}
#[test]
fn test_c_directive_parse_ledger_round_trip() {
let input = "\
2024-01-01 Simple
Assets:Cash 100 USD
Equity
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let cash = tx
.postings
.iter()
.find(|p| p.account == "Assets:Cash")
.unwrap();
assert_eq!(cash.amount_in("USD"), Some(dec!(100)));
}
#[test]
fn test_c_directive_n1_ne_1_single_hop_g_to_slv() {
let input = "\
C 100 SLV = 1 G
2024-01-01 mixed
Assets:A 1 G
Assets:B -100 SLV
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let asset_a = tx
.postings
.iter()
.find(|p| p.account == "Assets:A")
.unwrap();
let asset_b = tx
.postings
.iter()
.find(|p| p.account == "Assets:B")
.unwrap();
assert_eq!(
asset_a.amount_in("SLV"),
Some(dec!(100)),
"1G should convert to 100 SLV with N1=100, N2=1"
);
assert_eq!(
asset_b.amount_in("SLV"),
Some(dec!(-100)),
"SLV posting should remain -100 SLV"
);
}
#[test]
fn test_c_directive_n1_ne_1_issue_274_repro() {
let input = "\
C 1 SLV = 100c
C 100 SLV = 1 G
2024-01-01 mixed
Assets:A 1 G
Assets:B -10000c
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let asset_a = tx
.postings
.iter()
.find(|p| p.account == "Assets:A")
.unwrap();
let asset_b = tx
.postings
.iter()
.find(|p| p.account == "Assets:B")
.unwrap();
assert_eq!(
asset_a.amount_in("SLV"),
Some(dec!(100)),
"1G should convert to 100 SLV"
);
assert_eq!(
asset_b.amount_in("SLV"),
Some(dec!(-100)),
"-10000c should convert to -100 SLV"
);
}
#[test]
fn test_c_directive_n1_ne_1_fractional_ratio() {
let input = "\
C 5 X = 500 Y
2024-01-01 Test
Assets:A 5 X
Assets:B -500 Y
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let asset_a = tx
.postings
.iter()
.find(|p| p.account == "Assets:A")
.unwrap();
let asset_b = tx
.postings
.iter()
.find(|p| p.account == "Assets:B")
.unwrap();
assert_eq!(
asset_a.amount_in("X"),
Some(dec!(5)),
"5X should remain 5X (canonical, self-loop divisor=1)"
);
assert_eq!(
asset_b.amount_in("X"),
Some(dec!(-5)),
"-500Y should convert to -5X via divisor = N2/N1 = 100"
);
}
#[test]
fn test_c_directive_two_hop_n1_ne_1() {
let input = "\
C 1 X = 5 Y
C 2 Z = 1 X
2024-01-01 Test
Assets:A 2 Z
Assets:B -5 Y
";
let journal = elaborate(input);
assert_eq!(journal.transactions.len(), 1);
let tx = &journal.transactions[0];
let asset_a = tx
.postings
.iter()
.find(|p| p.account == "Assets:A")
.unwrap();
let asset_b = tx
.postings
.iter()
.find(|p| p.account == "Assets:B")
.unwrap();
assert_eq!(
asset_a.amount_in("Z"),
Some(dec!(2)),
"2Z should remain 2Z (self-loop divisor=1)"
);
assert_eq!(
asset_b.amount_in("Z"),
Some(dec!(-2)),
"-5Y should convert to -2Z via 2-hop chain (divisor=2.5)"
);
}
#[test]
fn test_c_directive_zero_lhs_errors() {
use crate::{grammars::ledger::parse_ledger, resolution::HIR};
let input = "\
C 0 X = 100 Y
2024-01-01 t
Assets:A 1 Y
Equity
";
let ast = parse_ledger(input).expect("parse should succeed");
let err =
HIR::try_from(ast).expect_err("zero LHS amount should produce a resolution error");
assert!(
matches!(
err,
crate::resolution::ResolutionError::InvalidCommodityConversion(_, _)
),
"expected InvalidCommodityConversion, got: {err:?}"
);
}
}