use std::{borrow::Cow, collections::HashSet};
use bumpalo::collections as bcc;
use chrono::NaiveDate;
use lender::{FallibleLender, FallibleLending, check_covariance_fallible};
use crate::{
parse,
report::{commodity::CommodityTag, eval::OwnedEvalError},
syntax,
};
use super::{
account::{Account, AccountAggregate},
balance::Balance,
commodity::OwnedCommodity,
context::ReportContext,
eval::{Amount, EvalError, Evaluable},
price_db::{self, ConversionError, PriceRepository},
transaction::{Posting, Transaction},
};
#[derive(Debug)]
pub struct Ledger<'ctx> {
pub(super) arena: &'ctx bumpalo::Bump,
pub(super) transactions: Vec<Transaction<'ctx>>,
pub(super) date_sorted_txns: Option<Vec<Transaction<'ctx>>>,
pub(super) raw_balance: Balance<'ctx>,
pub(super) price_repos: PriceRepository<'ctx>,
}
#[derive(Debug, thiserror::Error)]
pub enum QueryError {
#[error("failed to parse the given value")]
ParseFailed(#[from] parse::ParseError),
#[error("failed to evaluate the expr")]
EvalFailed(#[from] OwnedEvalError),
#[error("commodity {0} not found")]
CommodityNotFound(OwnedCommodity),
#[error("cannot convert amount: {0}")]
CommodityConversionFailure(#[from] ConversionError),
#[error("unsupported conversion strategy for this query")]
UnsupportedConversionStrategy,
#[error("internal failure: {0}")]
Internal(String),
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Sort {
#[default]
Original,
Date,
}
#[derive(Debug, Default)]
pub struct RegisterQuery<'ctx> {
pub account: AccountFilter<'ctx>,
pub date_range: DateRange,
pub conversion: Option<Conversion<'ctx>>,
pub sort: Sort,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct RegisterEntry<'lend, 'ctx> {
pub date: NaiveDate,
pub payee: &'ctx str,
pub account: Account<'ctx>,
pub amount: &'lend Amount<'ctx>,
pub total: &'lend Amount<'ctx>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ConversionStrategy {
Historical,
UpToDate {
today: NaiveDate,
},
}
#[derive(Debug, Clone, Copy)]
pub struct Conversion<'ctx> {
pub strategy: ConversionStrategy,
pub target: CommodityTag<'ctx>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct DateRange {
pub start: Option<NaiveDate>,
pub end: Option<NaiveDate>,
}
impl DateRange {
fn is_bypass(&self) -> bool {
self.start.is_none() && self.end.is_none()
}
#[inline]
fn contains(&self, date: NaiveDate) -> bool {
match (self.start, self.end) {
(Some(start), _) if date < start => false,
(_, Some(end)) if end <= date => false,
_ => true,
}
}
}
#[derive(Debug, Default)]
pub struct BalanceQuery<'ctx> {
pub account: AccountFilter<'ctx>,
pub conversion: Option<Conversion<'ctx>>,
pub date_range: DateRange,
}
impl BalanceQuery<'_> {
fn require_recompute(&self) -> bool {
if !self.date_range.is_bypass() {
return true;
}
if matches!(&self.conversion, Some(conv) if conv.strategy == ConversionStrategy::Historical)
{
return true;
}
false
}
}
#[derive(Debug)]
pub struct EvalContext {
pub date: NaiveDate,
pub exchange: Option<String>,
}
impl<'ctx> Ledger<'ctx> {
pub fn empty(ctx: &ReportContext<'ctx>) -> Self {
Self {
arena: ctx.arena,
transactions: Vec::new(),
date_sorted_txns: None,
raw_balance: Balance::default(),
price_repos: price_db::PriceRepositoryBuilder::default().build(),
}
}
pub fn transactions(&self) -> impl Iterator<Item = &Transaction<'ctx>> {
self.transactions.iter()
}
fn ensure_date_sorted_txns(&mut self) {
if self.date_sorted_txns.is_some() {
return;
}
let arena = self.arena;
let mut sorted: Vec<Transaction<'ctx>> = self
.transactions
.iter()
.map(|txn| {
let mut postings = bcc::Vec::with_capacity_in(txn.postings.len(), arena);
for p in txn.postings.iter() {
postings.push(p.clone());
}
Transaction {
date: txn.date,
postings: postings.into_boxed_slice(),
}
})
.collect();
sorted.sort_by_key(|t| t.date);
self.date_sorted_txns = Some(sorted);
}
pub fn register_entries<'a>(
&'a mut self,
ctx: &'a ReportContext<'ctx>,
query: &RegisterQuery<'ctx>,
) -> Result<RegisterEntries<'a, 'ctx>, QueryError> {
let conversion = match query.conversion {
None => None,
Some(c) => match c.strategy {
ConversionStrategy::Historical => Some(c),
ConversionStrategy::UpToDate { .. } => {
return Err(QueryError::UnsupportedConversionStrategy);
}
},
};
let account_filter = query.account.clone();
let txns: TxnIter<'a, 'ctx> = if account_filter.is_empty() {
TxnIter::linear(&[])
} else {
match query.sort {
Sort::Original => TxnIter::linear(&self.transactions),
Sort::Date => {
self.ensure_date_sorted_txns();
date_range_iter(
self.date_sorted_txns
.as_deref()
.expect("just built by ensure_date_sorted_txns"),
query.date_range,
)
}
}
};
Ok(RegisterEntries {
ctx,
txns,
current: [].iter(),
account_filter,
date_range: query.date_range,
conversion,
price_repos: &mut self.price_repos,
current_date: NaiveDate::MIN,
current_amount: Cow::Owned(Amount::default()),
total: Amount::default(),
})
}
pub fn balance(
&mut self,
ctx: &ReportContext<'ctx>,
query: &BalanceQuery<'ctx>,
) -> Result<Cow<'_, Balance<'ctx>>, QueryError> {
let balance = if !query.require_recompute() {
Cow::Borrowed(&self.raw_balance)
} else {
let txns = if query.date_range.is_bypass() {
TxnIter::linear(&self.transactions)
} else {
self.ensure_date_sorted_txns();
date_range_iter(
self.date_sorted_txns
.as_deref()
.expect("just built by ensure_date_sorted_txns"),
query.date_range,
)
};
Cow::Owned(compute_balance(
ctx,
&mut self.price_repos,
txns,
query.conversion,
)?)
};
let balance = match query.conversion {
None
| Some(Conversion {
strategy: ConversionStrategy::Historical,
..
}) => balance,
Some(Conversion {
strategy: ConversionStrategy::UpToDate { today },
target,
}) => {
let mut converted = Balance::default();
for (account, original_amount) in balance.iter() {
converted.add_amount(
*account,
price_db::convert_amount(
ctx,
&mut self.price_repos,
original_amount,
target,
today,
)
.map_err(QueryError::CommodityConversionFailure)?,
);
}
converted.round(ctx);
Cow::Owned(converted)
}
};
match &query.account {
AccountFilter::All => Ok(balance),
filter => {
let mut filtered = Balance::default();
for (account, amount) in balance.iter() {
if filter.is_match(account) {
filtered.add_amount(*account, amount.clone());
}
}
Ok(Cow::Owned(filtered))
}
}
}
pub fn eval(
&mut self,
ctx: &ReportContext<'ctx>,
expression: &str,
eval_ctx: &EvalContext,
) -> Result<Amount<'ctx>, QueryError> {
let exchange = eval_ctx
.exchange
.as_ref()
.map(|x| {
ctx.commodities.resolve(x).ok_or_else(|| {
QueryError::CommodityNotFound(OwnedCommodity::from_string(x.to_owned()))
})
})
.transpose()?;
let parsed: syntax::expr::ValueExpr =
expression.try_into().map_err(QueryError::ParseFailed)?;
let evaled: Amount<'ctx> = parsed
.eval(ctx)
.map_err(|e| e.into_owned(ctx))?
.try_into()
.map_err(|e: EvalError<'_>| e.into_owned(ctx))?;
let evaled = match exchange {
None => evaled,
Some(price_with) => price_db::convert_amount(
ctx,
&mut self.price_repos,
&evaled,
price_with,
eval_ctx.date,
)
.map_err(QueryError::CommodityConversionFailure)?,
};
Ok(evaled)
}
}
pub(super) struct TxnIter<'a, 'ctx> {
it: std::slice::Iter<'a, Transaction<'ctx>>,
end: Option<NaiveDate>,
}
impl<'a, 'ctx> TxnIter<'a, 'ctx> {
fn linear(slice: &'a [Transaction<'ctx>]) -> Self {
Self {
it: slice.iter(),
end: None,
}
}
fn sorted(slice: &'a [Transaction<'ctx>], end: Option<NaiveDate>) -> Self {
Self {
it: slice.iter(),
end,
}
}
}
impl<'a, 'ctx> Iterator for TxnIter<'a, 'ctx> {
type Item = &'a Transaction<'ctx>;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
let txn = self.it.next()?;
if let Some(end) = self.end
&& txn.date >= end
{
return None;
}
Some(txn)
}
}
pub struct RegisterEntries<'a, 'ctx> {
ctx: &'a ReportContext<'ctx>,
txns: TxnIter<'a, 'ctx>,
current: std::slice::Iter<'a, Posting<'ctx>>,
account_filter: AccountFilter<'ctx>,
date_range: DateRange,
conversion: Option<Conversion<'ctx>>,
price_repos: &'a mut PriceRepository<'ctx>,
current_date: NaiveDate,
current_amount: Cow<'a, Amount<'ctx>>,
total: Amount<'ctx>,
}
impl<'a, 'ctx> RegisterEntries<'a, 'ctx> {
fn advance_to_next_posting(&mut self) -> Option<&'a Posting<'ctx>> {
loop {
if let Some(posting) = self.current.next() {
if self.account_filter.is_match(&posting.account) {
return Some(posting);
}
continue;
}
let txn = self.txns.next()?;
if !self.date_range.contains(txn.date) {
continue;
}
self.current_date = txn.date;
self.current = txn.postings.iter();
}
}
}
impl<'a, 'ctx, 'lend> FallibleLending<'lend> for RegisterEntries<'a, 'ctx> {
type Lend = RegisterEntry<'lend, 'ctx>;
}
impl<'a, 'ctx> FallibleLender for RegisterEntries<'a, 'ctx> {
type Error = QueryError;
check_covariance_fallible!();
fn next(&mut self) -> Result<Option<lender::FallibleLend<'_, Self>>, Self::Error> {
let Some(posting) = self.advance_to_next_posting() else {
return Ok(None);
};
self.current_amount = match self.conversion {
None => Cow::Borrowed(&posting.amount),
Some(conv) => {
debug_assert!(matches!(conv.strategy, ConversionStrategy::Historical));
Cow::Owned(
price_db::convert_amount(
self.ctx,
self.price_repos,
&posting.amount,
conv.target,
self.current_date,
)
.map_err(QueryError::CommodityConversionFailure)?,
)
}
};
self.total += self.current_amount.as_ref();
Ok(Some(RegisterEntry {
date: self.current_date,
payee: posting.payee,
account: posting.account,
amount: &self.current_amount,
total: &self.total,
}))
}
}
fn date_range_iter<'a, 'ctx>(
sorted_txns: &'a [Transaction<'ctx>],
date_range: DateRange,
) -> TxnIter<'a, 'ctx> {
let lo = match date_range.start {
None => 0,
Some(start) => sorted_txns.partition_point(|t| t.date < start),
};
TxnIter::sorted(&sorted_txns[lo..], date_range.end)
}
fn compute_balance<'a, 'ctx>(
ctx: &ReportContext<'ctx>,
price_repos: &mut PriceRepository<'ctx>,
txns: TxnIter<'a, 'ctx>,
conversion: Option<Conversion<'ctx>>,
) -> Result<Balance<'ctx>, QueryError> {
let mut bal = Balance::default();
for txn in txns {
for posting in txn.postings.iter() {
let delta = match conversion {
Some(Conversion {
strategy: ConversionStrategy::Historical,
target,
}) => Cow::Owned(
price_db::convert_amount(ctx, price_repos, &posting.amount, target, txn.date)
.map_err(QueryError::CommodityConversionFailure)?,
),
None
| Some(Conversion {
strategy: ConversionStrategy::UpToDate { .. },
..
}) => Cow::Borrowed(&posting.amount),
};
bal.add_amount(posting.account, delta.into_owned());
}
}
bal.round(ctx);
Ok(bal)
}
#[derive(Debug, Clone, Default)]
pub enum AccountFilter<'ctx> {
#[default]
All,
Exact(Account<'ctx>),
Set(HashSet<Account<'ctx>>),
}
impl<'ctx> AccountFilter<'ctx> {
pub fn single(account: Account<'ctx>) -> Self {
Self::Exact(account)
}
fn from_set(accounts: HashSet<Account<'ctx>>) -> Self {
if accounts.len() == 1 {
Self::Exact(accounts.into_iter().next().unwrap())
} else {
Self::Set(accounts)
}
}
pub fn from_exact_accounts(ctx: &ReportContext<'ctx>, accounts: &[impl AsRef<str>]) -> Self {
let mut matched = HashSet::new();
for name in accounts {
if let Some(account) = ctx.account(name.as_ref()) {
matched.insert(account);
}
}
Self::from_set(matched)
}
pub fn from_regex_patterns(
ctx: &ReportContext<'ctx>,
patterns: &[impl AsRef<str>],
) -> Result<Self, regex::Error> {
let mut matched: HashSet<Account<'ctx>> = HashSet::new();
let set = regex::RegexSet::new(patterns.iter().map(AsRef::as_ref))?;
for account in ctx.all_accounts_unsorted() {
if set.is_match(account.as_str()) {
matched.insert(account);
}
}
Ok(Self::from_set(matched))
}
pub fn descendants_of(ctx: &ReportContext<'ctx>, prefix: AccountAggregate<'ctx>) -> Self {
let mut matched: HashSet<Account<'ctx>> = HashSet::new();
let mut targets = vec![prefix];
while let Some(current) = targets.pop() {
if let Some(account) = current.as_account() {
matched.insert(account);
}
for child in ctx.account_tree.children(current).unwrap_or_default() {
targets.push(*child);
}
}
Self::from_set(matched)
}
fn is_empty(&self) -> bool {
matches!(self, AccountFilter::Set(s) if s.is_empty())
}
#[inline]
fn is_match(&self, account: &Account<'ctx>) -> bool {
match self {
AccountFilter::All => true,
AccountFilter::Exact(target) => account == target,
AccountFilter::Set(targets) => targets.contains(account),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use assert_matches::assert_matches;
use bumpalo::Bump;
use indoc::indoc;
use maplit::{hashmap, hashset};
use pretty_assertions::assert_eq;
use rust_decimal_macros::dec;
use crate::{load, report, testing::recursive_print};
fn fake_loader() -> load::Loader<load::FakeFileSystem> {
let content = indoc! {"
commodity JPY
format 1,000 JPY
2023/12/31 rate
Equity 0.00 CHF @ 168.24 JPY
Equity 0.00 EUR @ 157.12 JPY
2024/01/01 Initial
Assets:J 銀行:普通 1,000,000 JPY
Assets:CH Bank 2,000.00 CHF
Liabilities:EUR Card -300.00 EUR
Assets:Broker 5,000.0000 OKANE {80 JPY}
Equity
2024/01/05 Shopping
Expenses:Grocery 100.00 CHF @ 171.50 JPY
Assets:J 銀行:普通 -17,150 JPY
2024/01/09 Buy Stock
Assets:Broker 23.0000 OKANE {120 JPY}
Assets:J 銀行:普通 -2,760 JPY
2024/01/15 Sell Stock
Assets:Broker 12,300 JPY
Assets:Broker -100.0000 OKANE {80 JPY} @ 100 JPY
Assets:Broker -23.0000 OKANE {120 JPY} @ 100 JPY
Income:Capital Gain -1540 JPY
2024/01/20 Restaurant
Expenses:Food 150.00 EUR @ 0.9464 CHF
Assets:CH Bank -141.96 CHF
2025/10/30 event
; Expenses:Foodをprefixに持つけど関係ないアカウント
Expenses:Food Fight 1,000 JPY
Assets:J 銀行:普通
2026/01/30 Ramen
Expenses:Food:Ramen 650 JPY
Assets:CH Bank -3.25 CHF @ 200 JPY
"};
let fake = hashmap! {
PathBuf::from("path/to/file.ledger") => content.as_bytes().to_vec(),
};
load::Loader::new(PathBuf::from("path/to/file.ledger"), fake.into())
}
fn create_ledger(arena: &Bump) -> (ReportContext<'_>, Ledger<'_>) {
let mut ctx = ReportContext::new(arena);
let ledger =
match report::process(&mut ctx, fake_loader(), &report::ProcessOptions::default()) {
Ok(v) => v,
Err(err) => panic!(
"failed to create the testing Ledger: {}",
recursive_print(err),
),
};
(ctx, ledger)
}
#[test]
fn balance_default() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
log::info!("all_accounts: {:?}", ctx.all_accounts());
let chf = ctx.commodities.resolve("CHF").unwrap();
let eur = ctx.commodities.resolve("EUR").unwrap();
let jpy = ctx.commodities.resolve("JPY").unwrap();
let okane = ctx.commodities.resolve("OKANE").unwrap();
let got = ledger.balance(&ctx, &BalanceQuery::default()).unwrap();
let want: Balance = [
(
ctx.account("Assets:CH Bank").unwrap(),
Amount::from_value(chf, dec!(1854.79)),
),
(
ctx.account("Assets:Broker").unwrap(),
Amount::from_iter([(okane, dec!(4900.0000)), (jpy, dec!(12300))]),
),
(
ctx.account("Assets:J 銀行:普通").unwrap(),
Amount::from_value(jpy, dec!(979_090)),
),
(
ctx.account("Liabilities:EUR Card").unwrap(),
Amount::from_value(eur, dec!(-300.00)),
),
(
ctx.account("Income:Capital Gain").unwrap(),
Amount::from_value(jpy, dec!(-1540)),
),
(
ctx.account("Expenses:Food").unwrap(),
Amount::from_value(eur, dec!(150.00)),
),
(
ctx.account("Expenses:Food:Ramen").unwrap(),
Amount::from_value(jpy, dec!(650)),
),
(
ctx.account("Expenses:Food Fight").unwrap(),
Amount::from_value(jpy, dec!(1_000)),
),
(
ctx.account("Expenses:Grocery").unwrap(),
Amount::from_value(chf, dec!(100.00)),
),
(
ctx.account("Equity").unwrap(),
Amount::from_iter([
(jpy, dec!(-1_400_000)),
(chf, dec!(-2000.00)),
(eur, dec!(300.00)),
]),
),
]
.into_iter()
.collect();
assert_eq!(want.into_vec(), got.into_owned().into_vec());
}
#[test]
fn balance_conversion_historical() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let jpy = ctx.commodities.resolve("JPY").unwrap();
let got = ledger
.balance(
&ctx,
&BalanceQuery {
account: AccountFilter::All,
conversion: Some(Conversion {
strategy: ConversionStrategy::Historical,
target: jpy,
}),
date_range: DateRange::default(),
},
)
.unwrap();
let want: Balance = [
(
ctx.account("Assets:CH Bank").unwrap(),
Amount::from_value(jpy, dec!(311_484)),
),
(
ctx.account("Assets:Broker").unwrap(),
Amount::from_iter([
(jpy, dec!(400_000)),
(jpy, dec!(2_760)),
(jpy, dec!(-10_000)),
(jpy, dec!(-2_300)),
(jpy, dec!(12_300)),
]),
),
(
ctx.account("Assets:J 銀行:普通").unwrap(),
Amount::from_value(jpy, dec!(979_090)),
),
(
ctx.account("Liabilities:EUR Card").unwrap(),
Amount::from_value(jpy, dec!(-47_136)),
),
(
ctx.account("Income:Capital Gain").unwrap(),
Amount::from_value(jpy, dec!(-1_540)),
),
(
ctx.account("Expenses:Grocery").unwrap(),
Amount::from_value(jpy, dec!(17_150)),
),
(
ctx.account("Expenses:Food").unwrap(),
Amount::from_value(jpy, dec!(23_568)),
),
(
ctx.account("Expenses:Food:Ramen").unwrap(),
Amount::from_value(jpy, dec!(650)),
),
(
ctx.account("Expenses:Food Fight").unwrap(),
Amount::from_value(jpy, dec!(1_000)),
),
(
ctx.account("Equity").unwrap(),
Amount::from_iter([
(jpy, dec!(-1_400_000)),
(jpy, dec!(-336_480)),
(jpy, dec!(47_136)),
]),
),
]
.into_iter()
.collect();
assert_eq!(want.into_vec(), got.into_owned().into_vec());
}
#[test]
fn balance_conversion_up_to_date() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let jpy = ctx.commodities.resolve("JPY").unwrap();
let got = ledger
.balance(
&ctx,
&BalanceQuery {
account: AccountFilter::All,
conversion: Some(Conversion {
strategy: ConversionStrategy::UpToDate {
today: NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
},
target: jpy,
}),
date_range: DateRange::default(),
},
)
.unwrap();
let want: Balance = [
(
ctx.account("Assets:CH Bank").unwrap(),
Amount::from_value(jpy, dec!(318_096)),
),
(
ctx.account("Assets:Broker").unwrap(),
Amount::from_value(jpy, dec!(502_300)),
),
(
ctx.account("Assets:J 銀行:普通").unwrap(),
Amount::from_value(jpy, dec!(979_090)),
),
(
ctx.account("Liabilities:EUR Card").unwrap(),
Amount::from_value(jpy, dec!(-47_136)),
),
(
ctx.account("Income:Capital Gain").unwrap(),
Amount::from_value(jpy, dec!(-1540)),
),
(
ctx.account("Expenses:Food").unwrap(),
Amount::from_value(jpy, dec!(23_568)),
),
(
ctx.account("Expenses:Food:Ramen").unwrap(),
Amount::from_value(jpy, dec!(650)),
),
(
ctx.account("Expenses:Food Fight").unwrap(),
Amount::from_value(jpy, dec!(1_000)),
),
(
ctx.account("Expenses:Grocery").unwrap(),
Amount::from_value(jpy, dec!(17_150)),
),
(
ctx.account("Equity").unwrap(),
Amount::from_iter([
(jpy, dec!(-1_400_000)),
(jpy, dec!(-343_000)),
(jpy, dec!(47_136)),
]),
),
]
.into_iter()
.collect();
assert_eq!(want.into_vec(), got.into_owned().into_vec());
}
#[test]
fn balance_date_range() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
log::info!("all_accounts: {:?}", ctx.all_accounts());
let chf = ctx.commodities.resolve("CHF").unwrap();
let jpy = ctx.commodities.resolve("JPY").unwrap();
let got = ledger
.balance(
&ctx,
&BalanceQuery {
account: AccountFilter::All,
conversion: None,
date_range: DateRange {
start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
end: Some(NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()),
},
},
)
.unwrap();
let want: Balance = [
(
ctx.account("Assets:J 銀行:普通").unwrap(),
Amount::from_value(jpy, dec!(-17150)),
),
(
ctx.account("Expenses:Grocery").unwrap(),
Amount::from_value(chf, dec!(100.00)),
),
]
.into_iter()
.collect();
assert_eq!(want.into_vec(), got.into_owned().into_vec());
}
#[test]
fn balance_with_account_filter() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let chf = ctx.commodities.resolve("CHF").unwrap();
let jpy = ctx.commodities.resolve("JPY").unwrap();
let okane = ctx.commodities.resolve("OKANE").unwrap();
let got = ledger
.balance(
&ctx,
&BalanceQuery {
account: AccountFilter::from_regex_patterns(&ctx, &["^Assets"]).unwrap(),
conversion: None,
date_range: DateRange::default(),
},
)
.unwrap()
.into_owned();
let want: Balance = [
(
ctx.account("Assets:CH Bank").unwrap(),
Amount::from_value(chf, dec!(1854.79)),
),
(
ctx.account("Assets:Broker").unwrap(),
Amount::from_iter([(okane, dec!(4900.0000)), (jpy, dec!(12300))]),
),
(
ctx.account("Assets:J 銀行:普通").unwrap(),
Amount::from_value(jpy, dec!(979_090)),
),
]
.into_iter()
.collect();
assert_eq!(want, got);
}
#[test]
fn eval_default_context() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let evaluated = ledger
.eval(
&ctx,
"1 JPY",
&EvalContext {
date: NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
exchange: None,
},
)
.unwrap();
assert_eq!(
Amount::from_value(ctx.commodities.resolve("JPY").unwrap(), dec!(1)),
evaluated
);
}
type Row<'ctx> = (NaiveDate, String, Account<'ctx>, Amount<'ctx>, Amount<'ctx>);
fn collect_register<'ctx>(
mut entries: RegisterEntries<'_, 'ctx>,
) -> Result<Vec<Row<'ctx>>, QueryError> {
let mut out = Vec::new();
while let Some(entry) = entries.next()? {
out.push((
entry.date,
entry.payee.to_owned(),
entry.account,
entry.amount.clone(),
entry.total.clone(),
));
}
Ok(out)
}
#[test]
fn register_entries_filter_by_account() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let jpy = ctx.commodities.resolve("JPY").unwrap();
let bank = ctx.account("Assets:J 銀行:普通").unwrap();
let got = collect_register(
ledger
.register_entries(
&ctx,
&RegisterQuery {
account: AccountFilter::single(bank),
date_range: DateRange::default(),
conversion: None,
sort: Sort::Original,
},
)
.unwrap(),
)
.unwrap();
let want = vec![
(
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
"Initial".to_string(),
bank,
Amount::from_value(jpy, dec!(1_000_000)),
Amount::from_value(jpy, dec!(1_000_000)),
),
(
NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
"Shopping".to_string(),
bank,
Amount::from_value(jpy, dec!(-17_150)),
Amount::from_value(jpy, dec!(982_850)),
),
(
NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(),
"Buy Stock".to_string(),
bank,
Amount::from_value(jpy, dec!(-2760)),
Amount::from_value(jpy, dec!(980_090)),
),
(
NaiveDate::from_ymd_opt(2025, 10, 30).unwrap(),
"event".to_string(),
bank,
Amount::from_value(jpy, dec!(-1000)),
Amount::from_value(jpy, dec!(979_090)),
),
];
assert_eq!(want, got);
}
#[test]
fn register_entries_filter_by_date_range() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let jpy = ctx.commodities.resolve("JPY").unwrap();
let chf = ctx.commodities.resolve("CHF").unwrap();
let grocery = ctx.account("Expenses:Grocery").unwrap();
let bank = ctx.account("Assets:J 銀行:普通").unwrap();
let got = collect_register(
ledger
.register_entries(
&ctx,
&RegisterQuery {
account: AccountFilter::All,
date_range: DateRange {
start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
end: Some(NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()),
},
conversion: None,
sort: Sort::Original,
},
)
.unwrap(),
)
.unwrap();
let want = vec![
(
NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
"Shopping".to_string(),
grocery,
Amount::from_value(chf, dec!(100.00)),
Amount::from_value(chf, dec!(100.00)),
),
(
NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
"Shopping".to_string(),
bank,
Amount::from_value(jpy, dec!(-17150)),
Amount::from_iter([(chf, dec!(100.00)), (jpy, dec!(-17150))]),
),
];
assert_eq!(want, got);
}
#[test]
fn register_entries_unknown_account_is_empty() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let got = collect_register(
ledger
.register_entries(
&ctx,
&RegisterQuery {
account: AccountFilter::from_exact_accounts(&ctx, &["Does:Not:Exist"]),
date_range: DateRange::default(),
conversion: None,
sort: Sort::Original,
},
)
.unwrap(),
)
.unwrap();
assert!(got.is_empty(), "expected empty register, got {:?}", got);
}
#[test]
fn register_entries_historical_conversion() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let jpy = ctx.commodities.resolve("JPY").unwrap();
let grocery = ctx.account("Expenses:Grocery").unwrap();
let bank = ctx.account("Assets:J 銀行:普通").unwrap();
let got = collect_register(
ledger
.register_entries(
&ctx,
&RegisterQuery {
account: AccountFilter::All,
date_range: DateRange {
start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
end: Some(NaiveDate::from_ymd_opt(2024, 1, 6).unwrap()),
},
conversion: Some(Conversion {
strategy: ConversionStrategy::Historical,
target: jpy,
}),
sort: Sort::Original,
},
)
.unwrap(),
)
.unwrap();
let want = vec![
(
NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
"Shopping".to_string(),
grocery,
Amount::from_value(jpy, dec!(17150.00)),
Amount::from_value(jpy, dec!(17150.00)),
),
(
NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
"Shopping".to_string(),
bank,
Amount::from_value(jpy, dec!(-17150)),
Amount::from_value(jpy, dec!(0.00)),
),
];
assert_eq!(want, got);
}
#[test]
fn register_entries_rejects_up_to_date_conversion() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let jpy = ctx.commodities.resolve("JPY").unwrap();
let err = ledger
.register_entries(
&ctx,
&RegisterQuery {
account: AccountFilter::All,
date_range: DateRange::default(),
conversion: Some(Conversion {
strategy: ConversionStrategy::UpToDate {
today: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
},
target: jpy,
}),
sort: Sort::Original,
},
)
.err()
.expect("UpToDate is not supported yet");
assert_matches!(
err,
QueryError::UnsupportedConversionStrategy,
"unexpected error: {:?}",
err
);
}
#[test]
fn register_entries_running_total_accumulates_across_accounts() {
let arena = Bump::new();
let (ctx, mut ledger) = create_ledger(&arena);
let entries = ledger
.register_entries(&ctx, &RegisterQuery::default())
.unwrap();
let rows = collect_register(entries).unwrap();
assert!(!rows.is_empty());
let final_total = rows.last().map(|r| r.4.clone()).unwrap();
let mut summed = Amount::default();
for r in &rows {
summed += &r.3;
}
assert_eq!(summed, final_total);
}
fn unordered_loader() -> load::Loader<load::FakeFileSystem> {
let content = indoc! {"
2024/03/01 Third by file
Assets:Bank 300 JPY
Equity
2024/01/01 First by date
Assets:Bank 100 JPY
Equity
2024/02/01 Middle by date
Assets:Bank 200 JPY
Equity
"};
let fake = hashmap! {
PathBuf::from("path/to/file.ledger") => content.as_bytes().to_vec(),
};
load::Loader::new(PathBuf::from("path/to/file.ledger"), fake.into())
}
#[test]
fn register_entries_sort_original_preserves_file_order() {
let arena = Bump::new();
let mut ctx = report::ReportContext::new(&arena);
let mut ledger = report::process(
&mut ctx,
unordered_loader(),
&report::ProcessOptions::default(),
)
.unwrap();
let bank = ctx.account("Assets:Bank").unwrap();
let rows = collect_register(
ledger
.register_entries(
&ctx,
&RegisterQuery {
account: AccountFilter::single(bank),
sort: Sort::Original,
..Default::default()
},
)
.unwrap(),
)
.unwrap();
let dates: Vec<NaiveDate> = rows.iter().map(|r| r.0).collect();
assert_eq!(
dates,
vec![
NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
]
);
assert!(rows.iter().all(|r| r.2 == bank));
}
#[test]
fn register_entries_sort_date_yields_ascending_order() {
let arena = Bump::new();
let mut ctx = report::ReportContext::new(&arena);
let mut ledger = report::process(
&mut ctx,
unordered_loader(),
&report::ProcessOptions::default(),
)
.unwrap();
let bank = ctx.account("Assets:Bank").unwrap();
let rows = collect_register(
ledger
.register_entries(
&ctx,
&RegisterQuery {
account: AccountFilter::single(bank),
sort: Sort::Date,
..Default::default()
},
)
.unwrap(),
)
.unwrap();
let dates: Vec<NaiveDate> = rows.iter().map(|r| r.0).collect();
assert_eq!(
dates,
vec![
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
]
);
}
#[test]
fn register_entries_sort_date_running_total_matches_chronology() {
let arena = Bump::new();
let mut ctx = report::ReportContext::new(&arena);
let mut ledger = report::process(
&mut ctx,
unordered_loader(),
&report::ProcessOptions::default(),
)
.unwrap();
let jpy = ctx.commodities.resolve("JPY").unwrap();
let bank = ctx.account("Assets:Bank").unwrap();
let rows = collect_register(
ledger
.register_entries(
&ctx,
&RegisterQuery {
account: AccountFilter::single(bank),
sort: Sort::Date,
..Default::default()
},
)
.unwrap(),
)
.unwrap();
let totals: Vec<Amount> = rows.into_iter().map(|r| r.4).collect();
assert_eq!(
totals,
vec![
Amount::from_value(jpy, dec!(100)),
Amount::from_value(jpy, dec!(300)),
Amount::from_value(jpy, dec!(600)),
]
);
}
mod account_filter {
use super::assert_eq;
use super::*;
#[test]
fn from_regex_patterns_empty_matches_nothing() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let filter = AccountFilter::from_regex_patterns(&ctx, &[] as &[&str]).unwrap();
assert_matches!(filter, AccountFilter::Set(s) if s.is_empty());
}
#[test]
fn from_regex_patterns_matches_substring() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let filter = AccountFilter::from_regex_patterns(&ctx, &["Assets"]).unwrap();
let matched = assert_matches!(filter, AccountFilter::Set(s) => s);
assert_eq!(
matched,
HashSet::from([
ctx.account("Assets:CH Bank").unwrap(),
ctx.account("Assets:Broker").unwrap(),
ctx.account("Assets:J 銀行:普通").unwrap()
]),
);
}
#[test]
fn from_regex_patterns_multiple_patterns_are_unioned() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let filter = AccountFilter::from_regex_patterns(&ctx, &["^Income", "Card$"]).unwrap();
assert_matches!(filter, AccountFilter::Set(s) => {
assert_eq!(s, hashset![
ctx.account("Income:Capital Gain").unwrap(),
ctx.account("Liabilities:EUR Card").unwrap(),
]);
});
}
#[test]
fn from_regex_patterns_single_match_collapses_to_exact() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let filter = AccountFilter::from_regex_patterns(&ctx, &["Grocery"]).unwrap();
let expected = ctx.account("Expenses:Grocery").unwrap();
assert_matches!(filter, AccountFilter::Exact(a) if a == expected);
}
#[test]
fn from_regex_patterns_invalid_regex_errors() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let err = AccountFilter::from_regex_patterns(&ctx, &["("]);
assert!(err.is_err(), "expected regex compile error, got {err:?}");
}
#[test]
fn from_exact_accounts_matches_names_verbatim() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let filter =
AccountFilter::from_exact_accounts(&ctx, &["Assets:Broker", ".", "Does:Not:Exist"]);
let expected = ctx.account("Assets:Broker").unwrap();
assert_matches!(filter, AccountFilter::Exact(a) if a == expected);
}
#[test]
fn from_exact_accounts_no_matches_is_empty_set() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let filter = AccountFilter::from_exact_accounts(&ctx, &["Does:Not:Exist"]);
assert_matches!(filter, AccountFilter::Set(s) if s.is_empty());
}
#[test]
fn descendants_of_ancestor_prefix_matches_subtree() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let assets =
AccountAggregate::Ancestor(ctx.account_tree.resolve_ancestor("Assets").unwrap());
let filter = AccountFilter::descendants_of(&ctx, assets);
assert_matches!(filter, AccountFilter::Set(s) => {
assert_eq!(s, hashset![
ctx.account("Assets:CH Bank").unwrap(),
ctx.account("Assets:Broker").unwrap(),
ctx.account("Assets:J 銀行:普通").unwrap(),
]);
});
}
#[test]
fn descendants_of_leaf_collapses_to_exact() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let broker = ctx.account("Assets:Broker").unwrap();
let filter = AccountFilter::descendants_of(&ctx, broker.into());
assert_matches!(filter, AccountFilter::Exact(a) if a == broker);
}
#[test]
fn descendants_of_account_with_child() {
let arena = Bump::new();
let (ctx, _ledger) = create_ledger(&arena);
let food = ctx.account("Expenses:Food").unwrap();
let filter = AccountFilter::descendants_of(&ctx, food.into());
assert_matches!(filter, AccountFilter::Set(s) => {
assert_eq!(s, hashset![
ctx.account("Expenses:Food").unwrap(),
ctx.account("Expenses:Food:Ramen").unwrap(),
]);
});
}
}
}