use chrono::NaiveDate;
use doppio::elaboration::Journal;
use rust_decimal::dec;
use std::path::PathBuf;
fn fixture(name: &str) -> String {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("parity")
.join("fixtures")
.join(name);
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
}
fn compile(name: &str) -> Journal {
let src = fixture(name);
let parser = doppio::grammars::ledger::Parser {
opener: |_: &str| Ok::<String, Box<dyn std::error::Error>>(String::new()),
base_path: PathBuf::new(),
};
doppio::compile(&src, parser).expect("compile failed")
}
#[test]
fn transactions_basic() {
let j = compile("transactions_basic.ledger");
assert_eq!(j.transactions.len(), 1);
let t = &j.transactions[0];
assert_eq!(t.description, "Groceries");
assert_eq!(t.postings.len(), 2);
assert_eq!(t.postings[0].account, "Expenses:Food");
assert_eq!(t.postings[0].amount_in("$"), Some(dec!(50)));
assert_eq!(t.postings[1].account, "Assets:Checking");
assert_eq!(t.postings[1].amount_in("$"), Some(dec!(-50)));
}
#[test]
fn multi_commodity() {
let j = compile("multi_commodity.ledger");
assert_eq!(j.transactions.len(), 1);
let t = &j.transactions[0];
assert_eq!(t.postings.len(), 2);
assert_eq!(t.postings[0].account, "Assets:Brokerage");
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(t.postings[1].account, "Assets:Cash");
assert_eq!(t.postings[1].amount_in("$"), Some(dec!(-1500)));
}
#[test]
fn lot_pricing_unit() {
let j = compile("lot_pricing_unit.ledger");
assert_eq!(j.transactions.len(), 1);
let t = &j.transactions[0];
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(t.postings[1].amount_in("$"), Some(dec!(-1500)));
}
#[test]
fn lot_pricing_total() {
let j = compile("lot_pricing_total.ledger");
assert_eq!(j.transactions.len(), 1);
let t = &j.transactions[0];
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(t.postings[1].amount_in("$"), Some(dec!(-1500)));
}
#[test]
fn balance_assertion() {
let j = compile("balance_assertion.ledger");
assert_eq!(j.transactions.len(), 1);
let t = &j.transactions[0];
assert_eq!(t.postings[0].amount_in("$"), Some(dec!(1000)));
assert_eq!(t.postings[1].amount_in("$"), Some(dec!(-1000)));
}
#[test]
fn balance_assignment() {
let j = compile("balance_assignment.ledger");
let t = &j.transactions[0];
assert_eq!(t.postings[0].account, "Assets:Checking");
assert_eq!(t.postings[0].amount_in("$"), Some(dec!(1000)));
assert_eq!(t.postings[1].amount_in("$"), Some(dec!(-1000)));
}
#[test]
fn account_assert() {
let j = compile("account_assert.ledger");
assert_eq!(j.transactions.len(), 1);
assert!(
j.accounts.contains_key("Assets:Checking"),
"account block should register Assets:Checking on the journal"
);
}
#[test]
fn commodity_format() {
let j = compile("commodity_format.ledger");
let dollar = j.commodities.get("$").expect("$ commodity declared");
assert_eq!(dollar.format.as_deref(), Some("$1,000.00"));
}
#[test]
fn tag_check() {
let j = compile("tag_check.ledger");
assert_eq!(j.transactions.len(), 1);
}
#[test]
fn define_param() {
let j = compile("define_param.ledger");
let t = &j.transactions[0];
let food = t
.postings
.iter()
.find(|p| p.account == "Expenses:Food")
.expect("food posting present");
assert_eq!(food.amount_in("$"), Some(dec!(100)));
}
#[test]
fn historical_price_directive() {
let j = compile("historical_price_directive.ledger");
assert_eq!(j.prices.len(), 1, "one P directive parsed");
let p = &j.prices[0];
assert_eq!(p.commodity, "AAPL");
assert_eq!(p.price_commodity, "$");
assert_eq!(
p.price.as_ref().expect("price set").to_decimal(),
dec!(182.50)
);
}
#[test]
fn metadata_inheritance() {
let j = compile("metadata_inheritance.ledger");
assert_eq!(j.transactions.len(), 1);
}
#[test]
fn date_formats() {
let j = compile("date_formats.ledger");
assert_eq!(j.transactions.len(), 2);
assert_eq!(
j.transactions[0].date_naive(),
chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()
);
assert_eq!(
j.transactions[1].date_naive(),
chrono::NaiveDate::from_ymd_opt(2024, 2, 20).unwrap()
);
}
#[test]
fn secondary_date() {
let j = compile("secondary_date.ledger");
let t = &j.transactions[0];
assert_eq!(
t.date_naive(),
chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()
);
assert_eq!(
t.secondary_date_naive(),
Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 20).unwrap())
);
}
#[test]
fn transaction_state() {
use doppio::elaboration::TransactionState;
let j = compile("transaction_state.ledger");
assert_eq!(j.transactions.len(), 3);
assert_eq!(j.transactions[0].state, TransactionState::Cleared as i32);
assert_eq!(j.transactions[1].state, TransactionState::Pending as i32);
assert_eq!(j.transactions[2].state, TransactionState::Uncleared as i32);
}
#[test]
fn transaction_code() {
let j = compile("transaction_code.ledger");
let t = &j.transactions[0];
assert_eq!(t.code.as_deref(), Some("INV-042"));
assert_eq!(t.description, "Invoice paid");
}
#[test]
fn amount_forms() {
let j = compile("amount_forms.ledger");
assert_eq!(j.transactions.len(), 5);
assert_eq!(
j.transactions[0].postings[0].amount_in("$"),
Some(dec!(100))
);
assert_eq!(
j.transactions[1].postings[0].amount_in("$"),
Some(dec!(-100))
);
assert_eq!(
j.transactions[2].postings[0].amount_in("$"),
Some(dec!(-100))
);
assert_eq!(
j.transactions[3].postings[0].amount_in("USD"),
Some(dec!(100))
);
assert_eq!(
j.transactions[4].postings[0].amount_in("USD"),
Some(dec!(-100))
);
}
#[test]
fn posting_state() {
use doppio::elaboration::TransactionState;
let j = compile("posting_state.ledger");
let postings = &j.transactions[0].postings;
assert_eq!(postings.len(), 3);
assert_eq!(postings[0].state, TransactionState::Cleared as i32);
assert_eq!(postings[1].state, TransactionState::Pending as i32);
assert_eq!(postings[2].state, TransactionState::Uncleared as i32);
}
#[test]
fn transaction_notes() {
let j = compile("transaction_notes.ledger");
let t = &j.transactions[0];
assert_eq!(t.metadata.get("KeyA").map(String::as_str), Some("ValueA"));
let food = t
.postings
.iter()
.find(|p| p.account == "Expenses:Food")
.expect("food posting present");
assert_eq!(
food.metadata.get("KeyB").map(String::as_str),
Some("ValueB")
);
}
#[test]
fn bare_tag_list() {
let j = compile("bare_tag_list.ledger");
let t = &j.transactions[0];
assert!(
t.tags.iter().any(|s| s == "urgent"),
"expected `urgent` tag on transaction, got {:?}",
t.tags
);
assert!(
t.tags.iter().any(|s| s == "reviewed"),
"expected `reviewed` tag on transaction, got {:?}",
t.tags
);
}
#[test]
fn comment_chars() {
let j = compile("comment_chars.ledger");
assert_eq!(j.transactions.len(), 1);
assert_eq!(j.transactions[0].description, "Real transaction");
}
#[test]
fn account_check() {
let j = compile("account_check.ledger");
assert_eq!(j.transactions.len(), 1);
assert!(j.accounts.contains_key("Assets:Checking"));
}
#[test]
fn account_note() {
let j = compile("account_note.ledger");
let brokerage = j
.accounts
.get("Assets:Brokerage")
.expect("account block declared");
assert_eq!(brokerage.note.as_deref(), Some("Schwab #1234"));
}
#[test]
fn account_metadata() {
let j = compile("account_metadata.ledger");
let income = j.accounts.get("Income").expect("Income declared");
assert_eq!(income.metadata.get("type").map(String::as_str), Some("R"));
let liab = j.accounts.get("Liabilities").expect("Liabilities declared");
assert_eq!(liab.metadata.get("type").map(String::as_str), Some("L"));
let assets = j.accounts.get("Assets").expect("Assets declared");
assert_eq!(assets.metadata.get("type").map(String::as_str), Some("A"));
let salary = j
.accounts
.get("Income:Salary")
.expect("Income:Salary referenced via posting");
assert_eq!(salary.metadata.get("type").map(String::as_str), Some("R"));
let visa = j
.accounts
.get("Liabilities:Visa")
.expect("Liabilities:Visa referenced via posting");
assert_eq!(visa.metadata.get("type").map(String::as_str), Some("L"));
let checking = j
.accounts
.get("Assets:Bank:Checking")
.expect("Assets:Bank:Checking referenced via posting");
assert_eq!(checking.metadata.get("type").map(String::as_str), Some("A"));
let brokerage = j.accounts.get("Assets:Brokerage").expect("declared");
assert_eq!(
brokerage.metadata.get("type").map(String::as_str),
Some("I")
);
assert_eq!(brokerage.note.as_deref(), Some("Schwab #1234"));
}
#[test]
fn commodity_default() {
let j = compile("commodity_default.ledger");
let t = &j.transactions[0];
let food = &t.postings[0];
assert_eq!(food.amount_in("$"), Some(dec!(100)));
}
#[test]
fn top_level_alias() {
let j = compile("top_level_alias.ledger");
let t = &j.transactions[0];
let checking = t
.postings
.iter()
.find(|p| p.account == "Assets:Checking")
.expect("alias should resolve to Assets:Checking");
assert_eq!(checking.amount_in("$"), Some(dec!(1000)));
}
#[test]
fn standalone_balance_assertion() {
let j = compile("standalone_balance_assertion.ledger");
assert_eq!(j.transactions.len(), 1);
}
#[test]
fn define_zero_arg() {
let j = compile("define_zero_arg.ledger");
let rent = j.transactions[0]
.postings
.iter()
.find(|p| p.account == "Expenses:Rent")
.expect("rent posting present");
assert_eq!(rent.amount_in("$"), Some(dec!(1500.00)));
}
#[test]
fn budget_directive() {
let j = compile("budget_directive.ledger");
assert_eq!(
j.transactions.len(),
1,
"budget directive should not produce an elaborated transaction"
);
assert_eq!(j.transactions[0].description, "Real spending");
}
#[test]
fn regex_match() {
let j = compile("regex_match.ledger");
assert_eq!(j.transactions.len(), 1);
}
#[test]
fn arithmetic_expression() {
let j = compile("arithmetic_expression.ledger");
let food = j.transactions[0]
.postings
.iter()
.find(|p| p.account == "Expenses:Food")
.expect("food posting present");
assert_eq!(food.amount_in("$"), Some(dec!(50)));
}
#[test]
fn running_balance() {
let j = compile("running_balance.ledger");
assert_eq!(j.transactions.len(), 3);
}
#[test]
fn lot_persistence_cost() {
let j = compile("lot_persistence_cost.ledger");
let t = &j.transactions[0];
assert_eq!(t.postings.len(), 2);
assert_eq!(t.postings[0].account, "Assets:Brokerage");
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(t.postings[1].account, "Assets:Cash");
assert_eq!(t.postings[1].amount_in("$"), Some(dec!(-1500)));
let lot = t.postings[0].lot.as_ref().expect("lot annotation present");
assert_eq!(lot.cost.as_ref().and_then(|a| a.get("$")), Some(dec!(150)));
}
#[test]
fn lot_persistence_date() {
let j = compile("lot_persistence_date.ledger");
let t = &j.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 be -10 AAPL (per-lot inverse, no @price)"
);
assert_eq!(
t.postings[1].amount_in("$"),
None,
"no cash posting when {{cost}} has no @price"
);
let lot = t.postings[0].lot.as_ref().expect("lot annotation present");
assert_eq!(lot.cost.as_ref().and_then(|a| a.get("$")), Some(dec!(150)));
assert_eq!(
t.postings[0].lot_date_naive(),
Some(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
);
}
#[test]
fn lot_persistence_note() {
let j = compile("lot_persistence_note.ledger");
let t = &j.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 be -10 AAPL (per-lot inverse, no @price)"
);
assert_eq!(
t.postings[1].amount_in("$"),
None,
"no cash posting when {{cost}} has no @price"
);
let lot = t.postings[0].lot.as_ref().expect("lot annotation present");
assert_eq!(lot.cost.as_ref().and_then(|a| a.get("$")), Some(dec!(150)));
assert_eq!(t.postings[0].lot_note(), Some("BUY-2024-01"));
}
#[test]
fn lot_persistence_combined() {
let j = compile("lot_persistence_combined.ledger");
let t = &j.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 be -10 AAPL (per-lot inverse, no @price)"
);
assert_eq!(
t.postings[1].amount_in("$"),
None,
"no cash posting when {{cost}} has no @price"
);
assert!(t.postings[0].has_lot());
assert_eq!(
t.postings[0].lot_cost_in("$"),
Some(dec!(150)),
"lot cost should be $150/share"
);
assert_eq!(
t.postings[0].lot_date_naive(),
Some(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
);
assert_eq!(t.postings[0].lot_note(), Some("BUY-2024-01"));
}
#[test]
fn lot_persistence_cost_vs_price() {
let j = compile("lot_persistence_cost_vs_price.ledger");
let t = &j.transactions[0];
assert_eq!(t.postings[0].account, "Assets:Brokerage");
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(t.postings[1].account, "Assets:Cash");
assert_eq!(t.postings[1].amount_in("$"), Some(dec!(-1500)));
assert_eq!(
t.postings[0].lot_cost_in("$"),
Some(dec!(150)),
"lot cost should be $150"
);
}
#[test]
fn lot_persistence_date_only() {
let j = compile("lot_persistence_date_only.ledger");
let t = &j.transactions[0];
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(t.postings[1].amount_in("AAPL"), Some(dec!(-10)));
assert!(t.postings[0].has_lot(), "lot annotation should be present");
assert_eq!(
t.postings[0].lot_date_naive(),
Some(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap()),
);
assert_eq!(t.postings[0].lot_cost_in("$"), None, "no cost annotation");
assert_eq!(t.postings[0].lot_note(), None, "no note annotation");
}
#[test]
fn lot_persistence_note_only() {
let j = compile("lot_persistence_note_only.ledger");
let t = &j.transactions[0];
assert_eq!(t.postings[0].amount_in("AAPL"), Some(dec!(10)));
assert_eq!(t.postings[1].amount_in("AAPL"), Some(dec!(-10)));
assert!(t.postings[0].has_lot(), "lot annotation should be present");
assert_eq!(t.postings[0].lot_note(), Some("BUY-2024-01"));
assert_eq!(t.postings[0].lot_date_naive(), None, "no date annotation");
assert_eq!(t.postings[0].lot_cost_in("$"), None, "no cost annotation");
}
#[test]
fn virtual_posting_unbalanced() {
let j = compile("virtual_posting_unbalanced.ledger");
let t = &j.transactions[0];
assert_eq!(t.postings.len(), 3);
let virt = t
.postings
.iter()
.find(|p| p.account == "Equity:Reservations")
.expect("virtual posting account name should have parens stripped");
assert_eq!(virt.amount_in("$"), Some(dec!(-25)));
let real_sum: rust_decimal::Decimal = t
.postings
.iter()
.filter(|p| p.account != "Equity:Reservations")
.filter_map(|p| p.amount_in("$"))
.sum();
assert_eq!(real_sum, dec!(0), "real postings should balance to 0");
use doppio::elaboration::PostingKind;
assert_eq!(
virt.kind,
PostingKind::VirtualUnbalanced as i32,
"virtual posting should carry PostingKind::VirtualUnbalanced"
);
let real_postings: Vec<_> = t
.postings
.iter()
.filter(|p| p.account != "Equity:Reservations")
.collect();
for p in &real_postings {
assert_eq!(
p.kind,
PostingKind::Real as i32,
"non-virtual posting {} should carry PostingKind::Real",
p.account
);
}
}
#[test]
fn virtual_posting_balanced() {
let j = compile("virtual_posting_balanced.ledger");
let t = &j.transactions[0];
assert_eq!(t.postings.len(), 3);
let virt = t
.postings
.iter()
.find(|p| p.account == "Equity:Reservations")
.expect("virtual posting account name should have brackets stripped");
assert_eq!(virt.amount_in("$"), Some(dec!(25)));
let total: rust_decimal::Decimal = t.postings.iter().filter_map(|p| p.amount_in("$")).sum();
assert_eq!(
total,
dec!(0),
"virtual balanced posting should participate in balance"
);
use doppio::elaboration::PostingKind;
assert_eq!(
virt.kind,
PostingKind::VirtualBalanced as i32,
"virtual balanced posting should carry PostingKind::VirtualBalanced"
);
}
#[test]
fn fx_conversion_p_directive() {
let j = compile("fx_conversion_p_directive.ledger");
assert_eq!(j.prices.len(), 1);
assert_eq!(j.prices[0].commodity, "EUR");
assert_eq!(j.prices[0].price_commodity, "$");
let travel = j.transactions[0]
.postings
.iter()
.find(|p| p.account == "Expenses:Travel")
.expect("travel posting present");
assert_eq!(travel.amount_in("EUR"), Some(dec!(100)));
let rate = j
.exchange_rate_at("EUR", "$", None)
.expect("EUR->$ quote is present in the journal");
assert_eq!(rate, dec!(1.10));
let eur_balance = travel.amount_in("EUR").unwrap();
assert_eq!(eur_balance * rate, dec!(110));
}
#[test]
fn bare_d_directive() {
let j = compile("bare_d_directive.ledger");
let t = &j.transactions[0];
assert_eq!(t.postings[0].account, "Expenses:Food");
assert_eq!(t.postings[0].amount_in("$"), Some(dec!(50)));
assert_eq!(t.postings[1].account, "Assets:Checking");
assert_eq!(t.postings[1].amount_in("$"), Some(dec!(-50)));
let dollar = j.commodities.get("$").expect("D directive declares $");
assert_eq!(dollar.format.as_deref(), Some("$1,000.00"));
}
#[test]
fn bare_d_directive_postfix() {
let j = compile("bare_d_directive_postfix.ledger");
let t = &j.transactions[0];
assert_eq!(t.postings[0].account, "Expenses:Food");
assert_eq!(t.postings[0].amount_in("USD"), Some(dec!(50)));
assert_eq!(t.postings[1].account, "Assets:Checking");
assert_eq!(t.postings[1].amount_in("USD"), Some(dec!(-50)));
let usd = j.commodities.get("USD").expect("D directive declares USD");
assert_eq!(usd.format.as_deref(), Some("1,000.00 USD"));
}
#[test]
fn account_alias_subdir() {
let j = compile("account_alias_subdir.ledger");
let t = &j.transactions[0];
let checking = t
.postings
.iter()
.find(|p| p.account == "Assets:Checking")
.expect("alias `Checking` should resolve to `Assets:Checking`");
assert_eq!(checking.amount_in("$"), Some(dec!(1000)));
assert!(
!t.postings.iter().any(|p| p.account == "Checking"),
"alias should resolve at resolution time; unaliased `Checking` \
must not appear in the elaborated journal"
);
}
#[test]
fn account_alias_multiple_per_block() {
let j = compile("account_alias_multiple_per_block.ledger");
assert_eq!(j.transactions.len(), 2);
let t0 = &j.transactions[0];
assert!(
t0.postings
.iter()
.any(|p| p.account == "Assets:Checking" && p.amount_in("$") == Some(dec!(100))),
"long alias `Checking` should resolve to Assets:Checking; got {:?}",
t0.postings.iter().map(|p| &p.account).collect::<Vec<_>>()
);
let t1 = &j.transactions[1];
assert!(
t1.postings
.iter()
.any(|p| p.account == "Assets:Checking" && p.amount_in("$") == Some(dec!(50))),
"short alias `C` should resolve to Assets:Checking; got {:?}",
t1.postings.iter().map(|p| &p.account).collect::<Vec<_>>()
);
for t in &j.transactions {
for p in &t.postings {
assert_ne!(p.account, "Checking", "alias `Checking` not resolved");
assert_ne!(p.account, "C", "alias `C` not resolved");
}
}
}
#[test]
fn account_alias_across_blocks() {
let j = compile("account_alias_across_blocks.ledger");
let t = &j.transactions[0];
let checking = t
.postings
.iter()
.find(|p| p.account == "Assets:Checking")
.expect("Checking alias should resolve");
assert_eq!(checking.amount_in("$"), Some(dec!(-100)));
let savings = t
.postings
.iter()
.find(|p| p.account == "Assets:Savings")
.expect("Savings alias should resolve");
assert_eq!(savings.amount_in("$"), Some(dec!(100)));
}
#[test]
fn account_alias_forward_only() {
let j = compile("account_alias_forward_only.ledger");
assert_eq!(j.transactions.len(), 2);
let pre = &j.transactions[0];
assert!(
pre.postings.iter().any(|p| p.account == "Checking"),
"pre-declaration posting should keep literal `Checking` name; \
got {:?}",
pre.postings.iter().map(|p| &p.account).collect::<Vec<_>>()
);
assert!(
!pre.postings.iter().any(|p| p.account == "Assets:Checking"),
"alias must not retroactively apply"
);
let post = &j.transactions[1];
assert!(
post.postings.iter().any(|p| p.account == "Assets:Checking"),
"post-declaration posting should resolve via alias"
);
}
#[test]
fn account_alias_inside_block_assert() {
let j = compile("account_alias_inside_block_assert.ledger");
let t = &j.transactions[0];
assert!(
t.postings.iter().any(|p| p.account == "Assets:Checking"),
"alias should resolve and the assert should pass on the resolved posting"
);
}