Skip to main content

okane_core/report/
query.rs

1//! Provides query of transactions / balances on the processed [Ledger] instance.
2
3use std::{borrow::Cow, collections::HashSet};
4
5use bumpalo::collections as bcc;
6use chrono::NaiveDate;
7use lender::{FallibleLender, FallibleLending, check_covariance_fallible};
8
9use crate::{
10    parse,
11    report::{commodity::CommodityTag, eval::OwnedEvalError},
12    syntax,
13};
14
15use super::{
16    balance::Balance,
17    commodity::OwnedCommodity,
18    context::{Account, ReportContext},
19    eval::{Amount, EvalError, Evaluable},
20    price_db::{self, ConversionError, PriceRepository},
21    transaction::{Posting, Transaction},
22};
23
24/// Contains processed transactions, so that users can query information.
25#[derive(Debug)]
26pub struct Ledger<'ctx> {
27    /// Arena from the report context — needed to allocate new `Posting`
28    /// slice boxes when building the date-sorted clone cache.
29    pub(super) arena: &'ctx bumpalo::Bump,
30    pub(super) transactions: Vec<Transaction<'ctx>>,
31    /// Lazily-computed clone of `transactions` sorted by date. `None` until
32    /// the first query that needs date ordering.
33    ///
34    /// We store actual `Transaction` clones (not indices into `transactions`)
35    /// so the date-ordered iteration is sequential — sequential reads are
36    /// L1-cache-friendly, whereas the previous index-based design did a
37    /// random-access lookup into `transactions[i]` per yielded row.
38    pub(super) date_sorted_txns: Option<Vec<Transaction<'ctx>>>,
39    pub(super) raw_balance: Balance<'ctx>,
40    pub(super) price_repos: PriceRepository<'ctx>,
41}
42
43/// Error type for [`Ledger`] methods.
44// TODO: Organize errors.
45// TODO: non_exhaustive
46#[derive(Debug, thiserror::Error)]
47pub enum QueryError {
48    #[error("failed to parse the given value")]
49    ParseFailed(#[from] parse::ParseError),
50    #[error("failed to evaluate the expr")]
51    EvalFailed(#[from] OwnedEvalError),
52    #[error("commodity {0} not found")]
53    CommodityNotFound(OwnedCommodity),
54    #[error("cannot convert amount: {0}")]
55    CommodityConversionFailure(#[from] ConversionError),
56    #[error("unsupported conversion strategy for this query")]
57    UnsupportedConversionStrategy,
58}
59
60/// Specifies the iteration order of `register` rows.
61#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
62pub enum Sort {
63    /// Preserve the order of appearance in the source file(s).
64    #[default]
65    Original,
66    /// Stable sort by transaction date, ascending.
67    Date,
68}
69
70/// Query to drive [`Ledger::register_entries`].
71#[derive(Debug, Default)]
72// TODO: non_exhaustive
73pub struct RegisterQuery<'ctx> {
74    /// Select the specified account if specified.
75    /// Note this will be changed to a list of regex eventually.
76    pub account: AccountFilter<'ctx>,
77    /// Half-open date range to restrict transactions.
78    pub date_range: DateRange,
79    /// Optional currency conversion applied to every yielded amount and to the
80    /// running total. Only [`ConversionStrategy::Historical`] is supported.
81    /// See https://github.com/xkikeg/okane/issues/313.
82    pub conversion: Option<Conversion<'ctx>>,
83    /// Order in which the matching postings are yielded.
84    pub sort: Sort,
85}
86
87/// A row of the register report.
88///
89/// Yielded by [`RegisterEntries`]. `total` is the running cumulative amount
90/// across all matched postings up to and including this one; the borrow is
91/// invalidated by the next call to [`Lender::next`].
92#[derive(Debug)]
93#[non_exhaustive]
94pub struct RegisterEntry<'lend, 'ctx> {
95    /// Date of the enclosing transaction.
96    pub date: NaiveDate,
97    /// Payee of the posting (per-posting `; Payee:` override falls back to the
98    /// enclosing transaction's payee).
99    pub payee: &'ctx str,
100    /// Account of the matched posting.
101    pub account: Account<'ctx>,
102    /// Amount of the matched posting. If conversion is configured this is the
103    /// converted amount.
104    pub amount: &'lend Amount<'ctx>,
105    /// Running cumulative amount across all matched postings up to and
106    /// including this row.
107    pub total: &'lend Amount<'ctx>,
108}
109
110/// Specifies the conversion strategy.
111#[derive(Debug, PartialEq, Eq, Clone, Copy)]
112pub enum ConversionStrategy {
113    /// Converts the amount on the date of transaction.
114    Historical,
115    /// Converts the amount on the given date's rate.
116    /// Not implemented for register report.
117    /// https://github.com/xkikeg/okane/issues/313
118    UpToDate {
119        /// Date for the conversion to be _up-to-date_.
120        today: NaiveDate,
121    },
122}
123
124/// Instruction about commodity conversion.
125#[derive(Debug, Clone, Copy)]
126// TODO: non_exhaustive
127pub struct Conversion<'ctx> {
128    pub strategy: ConversionStrategy,
129    pub target: CommodityTag<'ctx>,
130}
131
132/// Half-open range of the date for the query result.
133/// If any of `start` or `end` is set as [`None`],
134/// those are treated as -infinity, +infinity respectively.
135#[derive(Debug, Default, Clone, Copy)]
136pub struct DateRange {
137    /// Start of the range (inclusive), if exists.
138    pub start: Option<NaiveDate>,
139    /// End of the range (exclusive), if exists.
140    pub end: Option<NaiveDate>,
141}
142
143impl DateRange {
144    fn is_bypass(&self) -> bool {
145        self.start.is_none() && self.end.is_none()
146    }
147
148    fn contains(&self, date: NaiveDate) -> bool {
149        match (self.start, self.end) {
150            (Some(start), _) if date < start => false,
151            (_, Some(end)) if end <= date => false,
152            _ => true,
153        }
154    }
155}
156
157/// Query for [`Ledger::balance()`].
158#[derive(Debug, Default)]
159// TODO: non_exhaustive
160pub struct BalanceQuery<'ctx> {
161    /// Select which accounts to include. Defaults to [`AccountFilter::All`]
162    /// (every account).
163    pub account: AccountFilter<'ctx>,
164    pub conversion: Option<Conversion<'ctx>>,
165    pub date_range: DateRange,
166}
167
168impl BalanceQuery<'_> {
169    fn require_recompute(&self) -> bool {
170        if !self.date_range.is_bypass() {
171            return true;
172        }
173        if matches!(&self.conversion, Some(conv) if conv.strategy == ConversionStrategy::Historical)
174        {
175            return true;
176        }
177        // at this case, we can reuse the balance for the whole range data.
178        false
179    }
180}
181
182/// Context passed to [`Ledger::eval()`].
183#[derive(Debug)]
184// TODO: non_exhaustive
185pub struct EvalContext {
186    pub date: NaiveDate,
187    pub exchange: Option<String>,
188}
189
190impl<'ctx> Ledger<'ctx> {
191    /// Returns iterator for all transactions.
192    pub fn transactions(&self) -> impl Iterator<Item = &Transaction<'ctx>> {
193        self.transactions.iter()
194    }
195
196    /// Builds the date-sorted transaction clone cache on first call; a
197    /// no-op on every subsequent call. Clones each `Transaction` (postings
198    /// re-allocated in the same arena) into a new `Vec`, then stable-sorts
199    /// the copy by date. The result is a sequentially-iterable view that
200    /// preserves the original `transactions` Vec in file order.
201    fn ensure_date_sorted_txns(&mut self) {
202        if self.date_sorted_txns.is_some() {
203            return;
204        }
205        let arena = self.arena;
206        let mut sorted: Vec<Transaction<'ctx>> = self
207            .transactions
208            .iter()
209            .map(|txn| {
210                let mut postings = bcc::Vec::with_capacity_in(txn.postings.len(), arena);
211                for p in txn.postings.iter() {
212                    postings.push(p.clone());
213                }
214                Transaction {
215                    date: txn.date,
216                    postings: postings.into_boxed_slice(),
217                }
218            })
219            .collect();
220        sorted.sort_by_key(|t| t.date);
221        self.date_sorted_txns = Some(sorted);
222    }
223
224    /// Returns a [`FallibleLender`] over [`RegisterEntry`] rows matching the
225    /// query.
226    ///
227    /// The iterator owns the running cumulative [`Amount`], and each lent
228    /// `RegisterEntry` borrows from it. This makes the per-row cost a few
229    /// reference fields rather than an `Amount` clone, which matters for UI
230    /// consumers that just want to display the rows as they stream by.
231    ///
232    /// Per-row conversion may fail (missing rate at the transaction date), so
233    /// iteration is fallible via [`FallibleLender`] and yields
234    /// [`QueryError`] on error.
235    ///
236    /// Only [`ConversionStrategy::Historical`] is supported when conversion is
237    /// requested; [`ConversionStrategy::UpToDate`] returns
238    /// [`QueryError::UnsupportedConversionStrategy`]. See #313.
239    pub fn register_entries<'a>(
240        &'a mut self,
241        ctx: &'a ReportContext<'ctx>,
242        query: &RegisterQuery<'ctx>,
243    ) -> Result<RegisterEntries<'a, 'ctx>, QueryError> {
244        let conversion = match query.conversion {
245            None => None,
246            Some(c) => match c.strategy {
247                ConversionStrategy::Historical => Some(c),
248                ConversionStrategy::UpToDate { .. } => {
249                    return Err(QueryError::UnsupportedConversionStrategy);
250                }
251            },
252        };
253        let account_filter = query.account.clone();
254        // When the account filter can never match, return an iterator that is
255        // already exhausted instead of scanning everything.
256        let txns: TxnIter<'a, 'ctx> = if account_filter.is_exhaustive_empty() {
257            TxnIter::linear(&[])
258        } else {
259            match query.sort {
260                Sort::Original => TxnIter::linear(&self.transactions),
261                // Build (or reuse) the date-sorted clone cache. Bounds are
262                // applied via `date_range_iter` (binary-search lo, incremental
263                // `end` check), so the inner `date_range.contains` check below
264                // is a no-op on every txn yielded here — it's kept for the
265                // `Linear` arms above.
266                Sort::Date => {
267                    self.ensure_date_sorted_txns();
268                    date_range_iter(
269                        self.date_sorted_txns
270                            .as_deref()
271                            .expect("just built by ensure_date_sorted_txns"),
272                        query.date_range,
273                    )
274                }
275            }
276        };
277        Ok(RegisterEntries {
278            ctx,
279            txns,
280            current: [].iter(),
281            account_filter,
282            date_range: query.date_range,
283            conversion,
284            price_repos: &mut self.price_repos,
285            current_date: NaiveDate::MIN,
286            current_amount: Cow::Owned(Amount::default()),
287            total: Amount::default(),
288        })
289    }
290
291    /// Returns a balance matching the given query.
292    /// Note that currently we don't have the query,
293    /// that will be added soon.
294    pub fn balance(
295        &mut self,
296        ctx: &ReportContext<'ctx>,
297        query: &BalanceQuery<'ctx>,
298    ) -> Result<Cow<'_, Balance<'ctx>>, QueryError> {
299        let balance = if !query.require_recompute() {
300            Cow::Borrowed(&self.raw_balance)
301        } else {
302            // Accumulating into Balance is commutative, so we are free to
303            // pick the iteration order. When the date range is bounded we
304            // use the date-sorted permutation (binary-search lower bound,
305            // incremental upper bound); otherwise we walk every transaction
306            // in source order.
307            let txns = if query.date_range.is_bypass() {
308                TxnIter::linear(&self.transactions)
309            } else {
310                self.ensure_date_sorted_txns();
311                date_range_iter(
312                    self.date_sorted_txns
313                        .as_deref()
314                        .expect("just built by ensure_date_sorted_txns"),
315                    query.date_range,
316                )
317            };
318            Cow::Owned(compute_balance(
319                ctx,
320                &mut self.price_repos,
321                txns,
322                query.conversion,
323            )?)
324        };
325        let balance = match query.conversion {
326            None
327            | Some(Conversion {
328                strategy: ConversionStrategy::Historical,
329                ..
330            }) => balance,
331            Some(Conversion {
332                strategy: ConversionStrategy::UpToDate { today },
333                target,
334            }) => {
335                let mut converted = Balance::default();
336                for (account, original_amount) in balance.iter() {
337                    converted.add_amount(
338                        *account,
339                        price_db::convert_amount(
340                            ctx,
341                            &mut self.price_repos,
342                            original_amount,
343                            target,
344                            today,
345                        )
346                        .map_err(QueryError::CommodityConversionFailure)?,
347                    );
348                }
349                converted.round(ctx);
350                Cow::Owned(converted)
351            }
352        };
353        // Restrict to the requested accounts. `Any` short-circuits so the
354        // common unfiltered query keeps returning the borrowed/cached balance
355        // without an extra copy.
356        match &query.account {
357            AccountFilter::All => Ok(balance),
358            filter => {
359                let mut filtered = Balance::default();
360                for (account, amount) in balance.iter() {
361                    if filter.is_match(account) {
362                        filtered.add_amount(*account, amount.clone());
363                    }
364                }
365                Ok(Cow::Owned(filtered))
366            }
367        }
368    }
369
370    /// Evals given `expression` with the given condition.
371    pub fn eval(
372        &mut self,
373        ctx: &ReportContext<'ctx>,
374        expression: &str,
375        eval_ctx: &EvalContext,
376    ) -> Result<Amount<'ctx>, QueryError> {
377        let exchange = eval_ctx
378            .exchange
379            .as_ref()
380            .map(|x| {
381                ctx.commodities.resolve(x).ok_or_else(|| {
382                    QueryError::CommodityNotFound(OwnedCommodity::from_string(x.to_owned()))
383                })
384            })
385            .transpose()?;
386        let parsed: syntax::expr::ValueExpr =
387            expression.try_into().map_err(QueryError::ParseFailed)?;
388        let evaled: Amount<'ctx> = parsed
389            .eval(ctx)
390            .map_err(|e| e.into_owned(ctx))?
391            .try_into()
392            .map_err(|e: EvalError<'_>| e.into_owned(ctx))?;
393        let evaled = match exchange {
394            None => evaled,
395            Some(price_with) => price_db::convert_amount(
396                ctx,
397                &mut self.price_repos,
398                &evaled,
399                price_with,
400                eval_ctx.date,
401            )
402            .map_err(QueryError::CommodityConversionFailure)?,
403        };
404        Ok(evaled)
405    }
406}
407
408/// Iterator over transactions used internally by [`RegisterEntries`] and
409/// [`Ledger::balance`].
410///
411/// Both the linear (file-order) and sorted (date-order) paths walk a
412/// `slice::Iter<Transaction>` — the only difference is whether to
413/// short-circuit on an upper-bound date. So we collapse them into a single
414/// struct rather than an enum: no tag dispatch, no payload-size waste from
415/// the larger variant, and the `end == None` case compiles to a single
416/// well-predicted branch on the hot path.
417pub(super) struct TxnIter<'a, 'ctx> {
418    it: std::slice::Iter<'a, Transaction<'ctx>>,
419    /// Upper bound for sorted iteration: once a yielded transaction's date
420    /// is `>= end`, iteration stops. `None` for the linear / unbounded case.
421    end: Option<NaiveDate>,
422}
423
424impl<'a, 'ctx> TxnIter<'a, 'ctx> {
425    /// Walk `slice` in source order. No early-exit.
426    fn linear(slice: &'a [Transaction<'ctx>]) -> Self {
427        Self {
428            it: slice.iter(),
429            end: None,
430        }
431    }
432
433    /// Walk `slice` (assumed to be in ascending date order) and stop as
434    /// soon as a transaction's date is `>= end`. Pass `end = None` to
435    /// disable the upper-bound check.
436    fn sorted(slice: &'a [Transaction<'ctx>], end: Option<NaiveDate>) -> Self {
437        Self {
438            it: slice.iter(),
439            end,
440        }
441    }
442}
443
444impl<'a, 'ctx> Iterator for TxnIter<'a, 'ctx> {
445    type Item = &'a Transaction<'ctx>;
446    #[inline]
447    fn next(&mut self) -> Option<Self::Item> {
448        let txn = self.it.next()?;
449        if let Some(end) = self.end
450            && txn.date >= end
451        {
452            return None;
453        }
454        Some(txn)
455    }
456}
457
458/// [`FallibleLender`] returned by [`Ledger::register_entries`].
459///
460/// Holds the running cumulative [`Amount`] and a borrow into the underlying
461/// transactions; each call to [`FallibleLender::next`] advances to the next
462/// matching posting, updates the running total, and lends a borrow. Errors
463/// from currency conversion are surfaced as [`QueryError`].
464pub struct RegisterEntries<'a, 'ctx> {
465    ctx: &'a ReportContext<'ctx>,
466    txns: TxnIter<'a, 'ctx>,
467    current: std::slice::Iter<'a, Posting<'ctx>>,
468    account_filter: AccountFilter<'ctx>,
469    date_range: DateRange,
470    conversion: Option<Conversion<'ctx>>,
471    price_repos: &'a mut PriceRepository<'ctx>,
472    current_date: NaiveDate,
473    /// Buffer holding the amount lent in the most recent yield. Storing this
474    /// inside the lender keeps `RegisterEntry::amount` a `&Amount` even when
475    /// conversion produces a fresh value.
476    current_amount: Cow<'a, Amount<'ctx>>,
477    total: Amount<'ctx>,
478}
479
480impl<'a, 'ctx> RegisterEntries<'a, 'ctx> {
481    fn advance_to_next_posting(&mut self) -> Option<&'a Posting<'ctx>> {
482        loop {
483            if let Some(posting) = self.current.next() {
484                if self.account_filter.is_match(&posting.account) {
485                    return Some(posting);
486                }
487                continue;
488            }
489            let txn = self.txns.next()?;
490            if !self.date_range.contains(txn.date) {
491                continue;
492            }
493            self.current_date = txn.date;
494            self.current = txn.postings.iter();
495        }
496    }
497}
498
499impl<'a, 'ctx, 'lend> FallibleLending<'lend> for RegisterEntries<'a, 'ctx> {
500    type Lend = RegisterEntry<'lend, 'ctx>;
501}
502
503impl<'a, 'ctx> FallibleLender for RegisterEntries<'a, 'ctx> {
504    type Error = QueryError;
505
506    check_covariance_fallible!();
507
508    fn next(&mut self) -> Result<Option<lender::FallibleLend<'_, Self>>, Self::Error> {
509        let Some(posting) = self.advance_to_next_posting() else {
510            return Ok(None);
511        };
512        self.current_amount = match self.conversion {
513            None => Cow::Borrowed(&posting.amount),
514            Some(conv) => {
515                // ConversionStrategy::UpToDate is rejected up-front by
516                // register_entries(); only Historical reaches this point.
517                debug_assert!(matches!(conv.strategy, ConversionStrategy::Historical));
518                Cow::Owned(
519                    price_db::convert_amount(
520                        self.ctx,
521                        self.price_repos,
522                        &posting.amount,
523                        conv.target,
524                        self.current_date,
525                    )
526                    .map_err(QueryError::CommodityConversionFailure)?,
527                )
528            }
529        };
530        self.total += self.current_amount.as_ref();
531        Ok(Some(RegisterEntry {
532            date: self.current_date,
533            payee: posting.payee,
534            account: posting.account,
535            amount: &self.current_amount,
536            total: &self.total,
537        }))
538    }
539}
540
541/// Builds a sorted [`TxnIter`] over the transactions matching `date_range`,
542/// in ascending date order. The lower bound is found by binary search; the
543/// upper bound is enforced incrementally by [`TxnIter::next`].
544///
545/// Free function (rather than a `&self` method on `Ledger`) so its borrow
546/// of the cache is disjoint from `&mut self.price_repos` at the call site —
547/// through a method we'd hold a shared borrow on all of `self`.
548fn date_range_iter<'a, 'ctx>(
549    sorted_txns: &'a [Transaction<'ctx>],
550    date_range: DateRange,
551) -> TxnIter<'a, 'ctx> {
552    let lo = match date_range.start {
553        None => 0,
554        Some(start) => sorted_txns.partition_point(|t| t.date < start),
555    };
556    TxnIter::sorted(&sorted_txns[lo..], date_range.end)
557}
558
559/// Accumulates the balance for every posting yielded by `txns`, applying
560/// `conversion` if requested.
561///
562/// Free function so the caller can split borrows: `price_repos` and the
563/// iterator borrow disjoint fields of `Ledger`, but the borrow checker
564/// only sees that split through a free function signature.
565fn compute_balance<'a, 'ctx>(
566    ctx: &ReportContext<'ctx>,
567    price_repos: &mut PriceRepository<'ctx>,
568    txns: TxnIter<'a, 'ctx>,
569    conversion: Option<Conversion<'ctx>>,
570) -> Result<Balance<'ctx>, QueryError> {
571    let mut bal = Balance::default();
572    for txn in txns {
573        for posting in txn.postings.iter() {
574            let delta = match conversion {
575                Some(Conversion {
576                    strategy: ConversionStrategy::Historical,
577                    target,
578                }) => Cow::Owned(
579                    price_db::convert_amount(ctx, price_repos, &posting.amount, target, txn.date)
580                        .map_err(QueryError::CommodityConversionFailure)?,
581                ),
582                None
583                | Some(Conversion {
584                    strategy: ConversionStrategy::UpToDate { .. },
585                    ..
586                }) => Cow::Borrowed(&posting.amount),
587            };
588            bal.add_amount(posting.account, delta.into_owned());
589        }
590    }
591    bal.round(ctx);
592    Ok(bal)
593}
594
595/// Describes which accounts to include in a query.
596#[derive(Debug, Clone, Default)]
597pub enum AccountFilter<'ctx> {
598    /// Matches every account.
599    #[default]
600    All,
601    /// Matches exactly one account (O(1) equality check).
602    Exact(Account<'ctx>),
603    /// Matches any account in the set.
604    Set(HashSet<Account<'ctx>>),
605}
606
607impl<'ctx> AccountFilter<'ctx> {
608    /// Returns a filter matching exactly one already-resolved account.
609    pub fn single(account: Account<'ctx>) -> Self {
610        Self::Exact(account)
611    }
612
613    /// Constructs a filter matching any of the given set of accounts.
614    ///
615    /// Note this may return the `Exact` variant for optimization.
616    fn from_set(accounts: HashSet<Account<'ctx>>) -> Self {
617        if accounts.len() == 1 {
618            Self::Exact(accounts.into_iter().next().unwrap())
619        } else {
620            Self::Set(accounts)
621        }
622    }
623
624    /// Constructs a filter matching any of the given account names verbatim.
625    ///
626    /// Names that don't resolve to a known account simply contribute nothing;
627    /// if none of them resolve, the returned filter matches no account.
628    pub fn from_exact_accounts(ctx: &ReportContext<'ctx>, accounts: &[impl AsRef<str>]) -> Self {
629        let mut matched = HashSet::new();
630        for name in accounts {
631            if let Some(account) = ctx.account(name.as_ref()) {
632                matched.insert(account);
633            }
634        }
635        Self::from_set(matched)
636    }
637
638    /// Builds a filter matching every known account that matches any of the
639    /// given regex `patterns`.
640    ///
641    /// Each entry of `patterns` is a regular expression matched (unanchored,
642    /// like `ledger`) against every known account name. With an empty
643    /// `patterns`, the returned filter matches no account (callers that want
644    /// "match every account" should use [`AccountFilter::All`] directly).
645    ///
646    /// Returns [`regex::Error`] if any pattern fails to compile.
647    pub fn from_regex_patterns(
648        ctx: &ReportContext<'ctx>,
649        patterns: &[impl AsRef<str>],
650    ) -> Result<Self, regex::Error> {
651        let mut matched: HashSet<Account<'ctx>> = HashSet::new();
652        let set = regex::RegexSet::new(patterns.iter().map(AsRef::as_ref))?;
653        for account in ctx.all_accounts_unsorted() {
654            if set.is_match(account.as_str()) {
655                matched.insert(account);
656            }
657        }
658        Ok(Self::from_set(matched))
659    }
660
661    /// Returns `true` when the filter can never match any account.
662    fn is_exhaustive_empty(&self) -> bool {
663        matches!(self, AccountFilter::Set(s) if s.is_empty())
664    }
665
666    fn is_match(&self, account: &Account<'ctx>) -> bool {
667        match self {
668            AccountFilter::All => true,
669            AccountFilter::Exact(target) => account == target,
670            AccountFilter::Set(targets) => targets.contains(account),
671        }
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    use std::path::PathBuf;
680
681    use assert_matches::assert_matches;
682    use bumpalo::Bump;
683    use indoc::indoc;
684    use maplit::{hashmap, hashset};
685    use pretty_assertions::assert_eq;
686    use rust_decimal_macros::dec;
687
688    use crate::{load, report, testing::recursive_print};
689
690    fn fake_loader() -> load::Loader<load::FakeFileSystem> {
691        let content = indoc! {"
692            commodity JPY
693                format 1,000 JPY
694
695            2023/12/31 rate
696                Equity                         0.00 CHF @ 168.24 JPY
697                Equity                         0.00 EUR @ 157.12 JPY
698
699            2024/01/01 Initial
700                Assets:J 銀行             1,000,000 JPY
701                Assets:CH Bank             2,000.00 CHF
702                Liabilities:EUR Card        -300.00 EUR
703                Assets:Broker            5,000.0000 OKANE {80 JPY}
704                Equity
705
706            2024/01/05 Shopping
707                Expenses:Grocery             100.00 CHF @ 171.50 JPY
708                Assets:J 銀行               -17,150 JPY
709
710            2024/01/09 Buy Stock
711                Assets:Broker               23.0000 OKANE {120 JPY}
712                Assets:J 銀行                -2,760 JPY
713
714            2024/01/15 Sell Stock
715                Assets:Broker                12,300 JPY
716                Assets:Broker             -100.0000 OKANE {80 JPY} @ 100 JPY
717                Assets:Broker              -23.0000 OKANE {120 JPY} @ 100 JPY
718                Income:Capital Gain           -1540 JPY
719
720            2024/01/20 Shopping
721                Expenses:Food                150.00 EUR @ 0.9464 CHF
722                Assets:CH Bank              -141.96 CHF
723        "};
724        let fake = hashmap! {
725            PathBuf::from("path/to/file.ledger") => content.as_bytes().to_vec(),
726        };
727        load::Loader::new(PathBuf::from("path/to/file.ledger"), fake.into())
728    }
729
730    fn create_ledger(arena: &Bump) -> (ReportContext<'_>, Ledger<'_>) {
731        let mut ctx = ReportContext::new(arena);
732        let ledger =
733            match report::process(&mut ctx, fake_loader(), &report::ProcessOptions::default()) {
734                Ok(v) => v,
735                Err(err) => panic!(
736                    "failed to create the testing Ledger: {}",
737                    recursive_print(err),
738                ),
739            };
740        (ctx, ledger)
741    }
742
743    #[test]
744    fn balance_default() {
745        let arena = Bump::new();
746        let (ctx, mut ledger) = create_ledger(&arena);
747
748        log::info!("all_accounts: {:?}", ctx.all_accounts());
749        let chf = ctx.commodities.resolve("CHF").unwrap();
750        let eur = ctx.commodities.resolve("EUR").unwrap();
751        let jpy = ctx.commodities.resolve("JPY").unwrap();
752        let okane = ctx.commodities.resolve("OKANE").unwrap();
753
754        let got = ledger.balance(&ctx, &BalanceQuery::default()).unwrap();
755
756        let want: Balance = [
757            (
758                ctx.account("Assets:CH Bank").unwrap(),
759                Amount::from_value(chf, dec!(1858.04)),
760            ),
761            (
762                ctx.account("Assets:Broker").unwrap(),
763                Amount::from_iter([(okane, dec!(4900.0000)), (jpy, dec!(12300))]),
764            ),
765            (
766                ctx.account("Assets:J 銀行").unwrap(),
767                Amount::from_value(jpy, dec!(980090)),
768            ),
769            (
770                ctx.account("Liabilities:EUR Card").unwrap(),
771                Amount::from_value(eur, dec!(-300.00)),
772            ),
773            (
774                ctx.account("Income:Capital Gain").unwrap(),
775                Amount::from_value(jpy, dec!(-1540)),
776            ),
777            (
778                ctx.account("Expenses:Food").unwrap(),
779                Amount::from_value(eur, dec!(150.00)),
780            ),
781            (
782                ctx.account("Expenses:Grocery").unwrap(),
783                Amount::from_value(chf, dec!(100.00)),
784            ),
785            (
786                ctx.account("Equity").unwrap(),
787                Amount::from_iter([
788                    (jpy, dec!(-1_400_000)),
789                    (chf, dec!(-2000.00)),
790                    (eur, dec!(300.00)),
791                ]),
792            ),
793        ]
794        .into_iter()
795        .collect();
796
797        assert_eq!(want.into_vec(), got.into_owned().into_vec());
798    }
799
800    #[test]
801    fn balance_conversion_historical() {
802        // actually it doesn't quite make sense to have balance with
803        // either --historical or --up-to-date,
804        // because up-to-date makes sense for Assets & Liabilities
805        // while historical does for Income / Expenses.
806        let arena = Bump::new();
807        let (ctx, mut ledger) = create_ledger(&arena);
808
809        let jpy = ctx.commodities.resolve("JPY").unwrap();
810
811        let got = ledger
812            .balance(
813                &ctx,
814                &BalanceQuery {
815                    account: AccountFilter::All,
816                    conversion: Some(Conversion {
817                        strategy: ConversionStrategy::Historical,
818                        target: jpy,
819                    }),
820                    date_range: DateRange::default(),
821                },
822            )
823            .unwrap();
824
825        let want: Balance = [
826            (
827                ctx.account("Assets:CH Bank").unwrap(),
828                // 2000.00 * 168.24 - 141.96 * 171.50
829                Amount::from_value(jpy, dec!(312_134)),
830            ),
831            (
832                ctx.account("Assets:Broker").unwrap(),
833                // 5_000 * 80 = 400_000
834                // 23 * 120 = 2_760
835                // -100 * 100 = -10_000
836                // -23 * 100 = -2_300
837                Amount::from_iter([
838                    (jpy, dec!(400_000)),
839                    (jpy, dec!(2_760)),
840                    (jpy, dec!(-10_000)),
841                    (jpy, dec!(-2_300)),
842                    (jpy, dec!(12_300)),
843                ]),
844            ),
845            (
846                ctx.account("Assets:J 銀行").unwrap(),
847                Amount::from_value(jpy, dec!(980090)),
848            ),
849            (
850                ctx.account("Liabilities:EUR Card").unwrap(),
851                Amount::from_value(jpy, dec!(-47_136)),
852            ),
853            (
854                ctx.account("Income:Capital Gain").unwrap(),
855                Amount::from_value(jpy, dec!(-1_540)),
856            ),
857            (
858                ctx.account("Expenses:Grocery").unwrap(),
859                Amount::from_value(jpy, dec!(17_150)),
860            ),
861            (
862                ctx.account("Expenses:Food").unwrap(),
863                Amount::from_value(jpy, dec!(23_568)),
864            ),
865            (
866                ctx.account("Equity").unwrap(),
867                Amount::from_iter([
868                    (jpy, dec!(-1_400_000)),
869                    // -2000 * 168.24, historical rate.
870                    (jpy, dec!(-336_480)),
871                    // 300 * 157.12
872                    (jpy, dec!(47_136)),
873                ]),
874            ),
875        ]
876        .into_iter()
877        .collect();
878
879        assert_eq!(want.into_vec(), got.into_owned().into_vec());
880    }
881
882    #[test]
883    fn balance_conversion_up_to_date() {
884        let arena = Bump::new();
885        let (ctx, mut ledger) = create_ledger(&arena);
886
887        let jpy = ctx.commodities.resolve("JPY").unwrap();
888
889        let got = ledger
890            .balance(
891                &ctx,
892                &BalanceQuery {
893                    account: AccountFilter::All,
894                    conversion: Some(Conversion {
895                        strategy: ConversionStrategy::UpToDate {
896                            today: NaiveDate::from_ymd_opt(2024, 1, 16).unwrap(),
897                        },
898                        target: jpy,
899                    }),
900                    date_range: DateRange::default(),
901                },
902            )
903            .unwrap();
904
905        let want: Balance = [
906            (
907                ctx.account("Assets:CH Bank").unwrap(),
908                // 1858.04 * 171.50
909                Amount::from_value(jpy, dec!(318_654)),
910            ),
911            (
912                ctx.account("Assets:Broker").unwrap(),
913                Amount::from_value(jpy, dec!(502_300)),
914            ),
915            (
916                ctx.account("Assets:J 銀行").unwrap(),
917                Amount::from_value(jpy, dec!(980090)),
918            ),
919            (
920                ctx.account("Liabilities:EUR Card").unwrap(),
921                // -300 * 157.12, EUR/JPY rate won't use EUR/CHF CHF/JPY.
922                Amount::from_value(jpy, dec!(-47_136)),
923            ),
924            (
925                ctx.account("Income:Capital Gain").unwrap(),
926                Amount::from_value(jpy, dec!(-1540)),
927            ),
928            (
929                ctx.account("Expenses:Food").unwrap(),
930                // 150.00 EUR * 157.12
931                Amount::from_value(jpy, dec!(23_568)),
932            ),
933            (
934                ctx.account("Expenses:Grocery").unwrap(),
935                Amount::from_value(jpy, dec!(17_150)),
936            ),
937            (
938                ctx.account("Equity").unwrap(),
939                Amount::from_iter([
940                    (jpy, dec!(-1_400_000)),
941                    // -2000 CHF * 171.50
942                    (jpy, dec!(-343_000)),
943                    // 300 EUR * 157.12
944                    (jpy, dec!(47_136)),
945                ]),
946            ),
947        ]
948        .into_iter()
949        .collect();
950
951        assert_eq!(want.into_vec(), got.into_owned().into_vec());
952    }
953
954    #[test]
955    fn balance_date_range() {
956        let arena = Bump::new();
957        let (ctx, mut ledger) = create_ledger(&arena);
958
959        log::info!("all_accounts: {:?}", ctx.all_accounts());
960        let chf = ctx.commodities.resolve("CHF").unwrap();
961        let jpy = ctx.commodities.resolve("JPY").unwrap();
962
963        let got = ledger
964            .balance(
965                &ctx,
966                &BalanceQuery {
967                    account: AccountFilter::All,
968                    conversion: None,
969                    date_range: DateRange {
970                        start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
971                        end: Some(NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()),
972                    },
973                },
974            )
975            .unwrap();
976
977        let want: Balance = [
978            (
979                ctx.account("Assets:J 銀行").unwrap(),
980                Amount::from_value(jpy, dec!(-17150)),
981            ),
982            (
983                ctx.account("Expenses:Grocery").unwrap(),
984                Amount::from_value(chf, dec!(100.00)),
985            ),
986        ]
987        .into_iter()
988        .collect();
989
990        assert_eq!(want.into_vec(), got.into_owned().into_vec());
991    }
992
993    #[test]
994    fn eval_default_context() {
995        let arena = Bump::new();
996        let (ctx, mut ledger) = create_ledger(&arena);
997
998        let evaluated = ledger
999            .eval(
1000                &ctx,
1001                "1 JPY",
1002                &EvalContext {
1003                    date: NaiveDate::from_ymd_opt(2024, 10, 1).unwrap(),
1004                    exchange: None,
1005                },
1006            )
1007            .unwrap();
1008
1009        assert_eq!(
1010            Amount::from_value(ctx.commodities.resolve("JPY").unwrap(), dec!(1)),
1011            evaluated
1012        );
1013    }
1014
1015    /// Owned snapshot of a `RegisterEntry`, used in tests to compare against
1016    /// expected rows without juggling the lender's borrow.
1017    type Row<'ctx> = (NaiveDate, String, Account<'ctx>, Amount<'ctx>, Amount<'ctx>);
1018
1019    /// Drains the lender into an owned vec for easy assertion. The `total`
1020    /// borrow is materialized as an owned `Amount` snapshot per row.
1021    fn collect_register<'ctx>(
1022        mut entries: RegisterEntries<'_, 'ctx>,
1023    ) -> Result<Vec<Row<'ctx>>, QueryError> {
1024        let mut out = Vec::new();
1025        while let Some(entry) = entries.next()? {
1026            out.push((
1027                entry.date,
1028                entry.payee.to_owned(),
1029                entry.account,
1030                entry.amount.clone(),
1031                entry.total.clone(),
1032            ));
1033        }
1034        Ok(out)
1035    }
1036
1037    #[test]
1038    fn register_entries_filter_by_account() {
1039        let arena = Bump::new();
1040        let (ctx, mut ledger) = create_ledger(&arena);
1041        let jpy = ctx.commodities.resolve("JPY").unwrap();
1042
1043        let bank = ctx.account("Assets:J 銀行").unwrap();
1044        let got = collect_register(
1045            ledger
1046                .register_entries(
1047                    &ctx,
1048                    &RegisterQuery {
1049                        account: AccountFilter::single(bank),
1050                        date_range: DateRange::default(),
1051                        conversion: None,
1052                        sort: Sort::Original,
1053                    },
1054                )
1055                .unwrap(),
1056        )
1057        .unwrap();
1058
1059        let want = vec![
1060            (
1061                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1062                "Initial".to_string(),
1063                bank,
1064                Amount::from_value(jpy, dec!(1000000)),
1065                Amount::from_value(jpy, dec!(1000000)),
1066            ),
1067            (
1068                NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1069                "Shopping".to_string(),
1070                bank,
1071                Amount::from_value(jpy, dec!(-17150)),
1072                Amount::from_value(jpy, dec!(982850)),
1073            ),
1074            (
1075                NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(),
1076                "Buy Stock".to_string(),
1077                bank,
1078                Amount::from_value(jpy, dec!(-2760)),
1079                Amount::from_value(jpy, dec!(980090)),
1080            ),
1081        ];
1082        assert_eq!(want, got);
1083    }
1084
1085    #[test]
1086    fn register_entries_filter_by_date_range() {
1087        let arena = Bump::new();
1088        let (ctx, mut ledger) = create_ledger(&arena);
1089        let jpy = ctx.commodities.resolve("JPY").unwrap();
1090        let chf = ctx.commodities.resolve("CHF").unwrap();
1091
1092        let grocery = ctx.account("Expenses:Grocery").unwrap();
1093        let bank = ctx.account("Assets:J 銀行").unwrap();
1094        let got = collect_register(
1095            ledger
1096                .register_entries(
1097                    &ctx,
1098                    &RegisterQuery {
1099                        account: AccountFilter::All,
1100                        date_range: DateRange {
1101                            start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
1102                            end: Some(NaiveDate::from_ymd_opt(2024, 1, 9).unwrap()),
1103                        },
1104                        conversion: None,
1105                        sort: Sort::Original,
1106                    },
1107                )
1108                .unwrap(),
1109        )
1110        .unwrap();
1111
1112        // Only the 2024/01/05 Shopping transaction is in range; the 2024/01/09
1113        // Buy Stock is excluded by the half-open end. Running total is the
1114        // cumulative sum of the displayed amounts, mixing commodities.
1115        let want = vec![
1116            (
1117                NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1118                "Shopping".to_string(),
1119                grocery,
1120                Amount::from_value(chf, dec!(100.00)),
1121                Amount::from_value(chf, dec!(100.00)),
1122            ),
1123            (
1124                NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1125                "Shopping".to_string(),
1126                bank,
1127                Amount::from_value(jpy, dec!(-17150)),
1128                Amount::from_iter([(chf, dec!(100.00)), (jpy, dec!(-17150))]),
1129            ),
1130        ];
1131        assert_eq!(want, got);
1132    }
1133
1134    #[test]
1135    fn register_entries_unknown_account_is_empty() {
1136        let arena = Bump::new();
1137        let (ctx, mut ledger) = create_ledger(&arena);
1138
1139        let got = collect_register(
1140            ledger
1141                .register_entries(
1142                    &ctx,
1143                    &RegisterQuery {
1144                        account: AccountFilter::from_exact_accounts(&ctx, &["Does:Not:Exist"]),
1145                        date_range: DateRange::default(),
1146                        conversion: None,
1147                        sort: Sort::Original,
1148                    },
1149                )
1150                .unwrap(),
1151        )
1152        .unwrap();
1153        assert!(got.is_empty(), "expected empty register, got {:?}", got);
1154    }
1155
1156    #[test]
1157    fn register_entries_historical_conversion() {
1158        let arena = Bump::new();
1159        let (ctx, mut ledger) = create_ledger(&arena);
1160        let jpy = ctx.commodities.resolve("JPY").unwrap();
1161        let grocery = ctx.account("Expenses:Grocery").unwrap();
1162        let bank = ctx.account("Assets:J 銀行").unwrap();
1163
1164        // Restrict to the single 2024/01/05 Shopping transaction so the
1165        // assertion stays small. Conversion is to JPY at the historical rate
1166        // captured by the @ price (171.50 JPY per CHF on 2024-01-05).
1167        let got = collect_register(
1168            ledger
1169                .register_entries(
1170                    &ctx,
1171                    &RegisterQuery {
1172                        account: AccountFilter::All,
1173                        date_range: DateRange {
1174                            start: Some(NaiveDate::from_ymd_opt(2024, 1, 5).unwrap()),
1175                            end: Some(NaiveDate::from_ymd_opt(2024, 1, 6).unwrap()),
1176                        },
1177                        conversion: Some(Conversion {
1178                            strategy: ConversionStrategy::Historical,
1179                            target: jpy,
1180                        }),
1181                        sort: Sort::Original,
1182                    },
1183                )
1184                .unwrap(),
1185        )
1186        .unwrap();
1187
1188        let want = vec![
1189            (
1190                NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1191                "Shopping".to_string(),
1192                grocery,
1193                // 100.00 CHF * 171.50 JPY/CHF.
1194                Amount::from_value(jpy, dec!(17150.00)),
1195                Amount::from_value(jpy, dec!(17150.00)),
1196            ),
1197            (
1198                NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(),
1199                "Shopping".to_string(),
1200                bank,
1201                Amount::from_value(jpy, dec!(-17150)),
1202                Amount::from_value(jpy, dec!(0.00)),
1203            ),
1204        ];
1205        assert_eq!(want, got);
1206    }
1207
1208    #[test]
1209    fn register_entries_rejects_up_to_date_conversion() {
1210        let arena = Bump::new();
1211        let (ctx, mut ledger) = create_ledger(&arena);
1212        let jpy = ctx.commodities.resolve("JPY").unwrap();
1213
1214        let err = ledger
1215            .register_entries(
1216                &ctx,
1217                &RegisterQuery {
1218                    account: AccountFilter::All,
1219                    date_range: DateRange::default(),
1220                    conversion: Some(Conversion {
1221                        strategy: ConversionStrategy::UpToDate {
1222                            today: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(),
1223                        },
1224                        target: jpy,
1225                    }),
1226                    sort: Sort::Original,
1227                },
1228            )
1229            .err()
1230            .expect("UpToDate is not supported yet");
1231        assert_matches!(
1232            err,
1233            QueryError::UnsupportedConversionStrategy,
1234            "unexpected error: {:?}",
1235            err
1236        );
1237    }
1238
1239    #[test]
1240    fn register_entries_running_total_accumulates_across_accounts() {
1241        // With no account filter we walk every posting; the cumulative total
1242        // across all yielded rows must equal the per-commodity sum of every
1243        // amount in the ledger. The Initial transaction is balanced via
1244        // Equity so the grand total ends up at absolute zero across
1245        // commodities, but with rounding artifacts it's safer to compare
1246        // against the explicit sum.
1247        let arena = Bump::new();
1248        let (ctx, mut ledger) = create_ledger(&arena);
1249
1250        let entries = ledger
1251            .register_entries(&ctx, &RegisterQuery::default())
1252            .unwrap();
1253        let rows = collect_register(entries).unwrap();
1254        assert!(!rows.is_empty());
1255
1256        let final_total = rows.last().map(|r| r.4.clone()).unwrap();
1257        let mut summed = Amount::default();
1258        for r in &rows {
1259            summed += &r.3;
1260        }
1261        assert_eq!(summed, final_total);
1262    }
1263
1264    /// Ledger fixture whose transactions are intentionally out of date order
1265    /// so we can tell `Sort::Original` apart from `Sort::Date`.
1266    fn unordered_loader() -> load::Loader<load::FakeFileSystem> {
1267        // Dates: 2024/03/01, 2024/01/01, 2024/02/01 — i.e. neither
1268        // ascending nor descending.
1269        let content = indoc! {"
1270            2024/03/01 Third by file
1271                Assets:Bank      300 JPY
1272                Equity
1273
1274            2024/01/01 First by date
1275                Assets:Bank      100 JPY
1276                Equity
1277
1278            2024/02/01 Middle by date
1279                Assets:Bank      200 JPY
1280                Equity
1281        "};
1282        let fake = hashmap! {
1283            PathBuf::from("path/to/file.ledger") => content.as_bytes().to_vec(),
1284        };
1285        load::Loader::new(PathBuf::from("path/to/file.ledger"), fake.into())
1286    }
1287
1288    #[test]
1289    fn register_entries_sort_original_preserves_file_order() {
1290        let arena = Bump::new();
1291        let mut ctx = report::ReportContext::new(&arena);
1292        let mut ledger = report::process(
1293            &mut ctx,
1294            unordered_loader(),
1295            &report::ProcessOptions::default(),
1296        )
1297        .unwrap();
1298        let bank = ctx.account("Assets:Bank").unwrap();
1299        let rows = collect_register(
1300            ledger
1301                .register_entries(
1302                    &ctx,
1303                    &RegisterQuery {
1304                        account: AccountFilter::single(bank),
1305                        sort: Sort::Original,
1306                        ..Default::default()
1307                    },
1308                )
1309                .unwrap(),
1310        )
1311        .unwrap();
1312        let dates: Vec<NaiveDate> = rows.iter().map(|r| r.0).collect();
1313        assert_eq!(
1314            dates,
1315            vec![
1316                NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
1317                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1318                NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1319            ]
1320        );
1321        // Every row is on the same account, so the filter is exercised.
1322        assert!(rows.iter().all(|r| r.2 == bank));
1323    }
1324
1325    #[test]
1326    fn register_entries_sort_date_yields_ascending_order() {
1327        let arena = Bump::new();
1328        let mut ctx = report::ReportContext::new(&arena);
1329        let mut ledger = report::process(
1330            &mut ctx,
1331            unordered_loader(),
1332            &report::ProcessOptions::default(),
1333        )
1334        .unwrap();
1335        let bank = ctx.account("Assets:Bank").unwrap();
1336        let rows = collect_register(
1337            ledger
1338                .register_entries(
1339                    &ctx,
1340                    &RegisterQuery {
1341                        account: AccountFilter::single(bank),
1342                        sort: Sort::Date,
1343                        ..Default::default()
1344                    },
1345                )
1346                .unwrap(),
1347        )
1348        .unwrap();
1349        let dates: Vec<NaiveDate> = rows.iter().map(|r| r.0).collect();
1350        assert_eq!(
1351            dates,
1352            vec![
1353                NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1354                NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
1355                NaiveDate::from_ymd_opt(2024, 3, 1).unwrap(),
1356            ]
1357        );
1358    }
1359
1360    #[test]
1361    fn register_entries_sort_date_running_total_matches_chronology() {
1362        // Running total must follow the visit order: 100 → 300 → 600.
1363        // (cf. Sort::Original would visit 300 → 400 → 600.)
1364        let arena = Bump::new();
1365        let mut ctx = report::ReportContext::new(&arena);
1366        let mut ledger = report::process(
1367            &mut ctx,
1368            unordered_loader(),
1369            &report::ProcessOptions::default(),
1370        )
1371        .unwrap();
1372        let jpy = ctx.commodities.resolve("JPY").unwrap();
1373        let bank = ctx.account("Assets:Bank").unwrap();
1374        let rows = collect_register(
1375            ledger
1376                .register_entries(
1377                    &ctx,
1378                    &RegisterQuery {
1379                        account: AccountFilter::single(bank),
1380                        sort: Sort::Date,
1381                        ..Default::default()
1382                    },
1383                )
1384                .unwrap(),
1385        )
1386        .unwrap();
1387        let totals: Vec<Amount> = rows.into_iter().map(|r| r.4).collect();
1388        assert_eq!(
1389            totals,
1390            vec![
1391                Amount::from_value(jpy, dec!(100)),
1392                Amount::from_value(jpy, dec!(300)),
1393                Amount::from_value(jpy, dec!(600)),
1394            ]
1395        );
1396    }
1397
1398    #[test]
1399    fn account_filter_from_regex_patterns_empty_matches_nothing() {
1400        let arena = Bump::new();
1401        let (ctx, _ledger) = create_ledger(&arena);
1402
1403        let filter = AccountFilter::from_regex_patterns(&ctx, &[] as &[&str]).unwrap();
1404        assert_matches!(filter, AccountFilter::Set(s) if s.is_empty());
1405    }
1406
1407    #[test]
1408    fn account_filter_from_regex_patterns_matches_substring() {
1409        let arena = Bump::new();
1410        let (ctx, _ledger) = create_ledger(&arena);
1411
1412        // Unanchored regex: "Assets" matches every account starting with it.
1413        let filter = AccountFilter::from_regex_patterns(&ctx, &["Assets"]).unwrap();
1414        let matched = assert_matches!(filter, AccountFilter::Set(s) => s);
1415        assert_eq!(
1416            matched,
1417            HashSet::from([
1418                ctx.account("Assets:CH Bank").unwrap(),
1419                ctx.account("Assets:Broker").unwrap(),
1420                ctx.account("Assets:J 銀行").unwrap()
1421            ]),
1422        );
1423    }
1424
1425    #[test]
1426    fn account_filter_from_regex_patterns_multiple_patterns_are_unioned() {
1427        let arena = Bump::new();
1428        let (ctx, _ledger) = create_ledger(&arena);
1429
1430        let filter = AccountFilter::from_regex_patterns(&ctx, &["^Income", "Card$"]).unwrap();
1431        assert_matches!(filter, AccountFilter::Set(s) => {
1432            assert_eq!(s, hashset![
1433                ctx.account("Income:Capital Gain").unwrap(),
1434                ctx.account("Liabilities:EUR Card").unwrap(),
1435                ]);
1436        });
1437    }
1438
1439    #[test]
1440    fn account_filter_from_regex_patterns_single_match_collapses_to_exact() {
1441        let arena = Bump::new();
1442        let (ctx, _ledger) = create_ledger(&arena);
1443
1444        let filter = AccountFilter::from_regex_patterns(&ctx, &["Grocery"]).unwrap();
1445        let expected = ctx.account("Expenses:Grocery").unwrap();
1446        assert_matches!(filter, AccountFilter::Exact(a) if a == expected);
1447    }
1448
1449    #[test]
1450    fn account_filter_from_exact_accounts_matches_names_verbatim() {
1451        let arena = Bump::new();
1452        let (ctx, _ledger) = create_ledger(&arena);
1453
1454        // "." would match everything if treated as regex; as an exact name it
1455        // resolves to nothing. The known exact account is added; the unknown
1456        // ones contribute nothing.
1457        let filter =
1458            AccountFilter::from_exact_accounts(&ctx, &["Assets:Broker", ".", "Does:Not:Exist"]);
1459        let expected = ctx.account("Assets:Broker").unwrap();
1460        assert_matches!(filter, AccountFilter::Exact(a) if a == expected);
1461    }
1462
1463    #[test]
1464    fn account_filter_from_exact_accounts_no_matches_is_empty_set() {
1465        let arena = Bump::new();
1466        let (ctx, _ledger) = create_ledger(&arena);
1467
1468        let filter = AccountFilter::from_exact_accounts(&ctx, &["Does:Not:Exist"]);
1469        assert_matches!(filter, AccountFilter::Set(s) if s.is_empty());
1470    }
1471
1472    #[test]
1473    fn account_filter_from_regex_patterns_invalid_regex_errors() {
1474        let arena = Bump::new();
1475        let (ctx, _ledger) = create_ledger(&arena);
1476
1477        let err = AccountFilter::from_regex_patterns(&ctx, &["("]);
1478        assert!(err.is_err(), "expected regex compile error, got {err:?}");
1479    }
1480
1481    #[test]
1482    fn balance_with_account_filter() {
1483        let arena = Bump::new();
1484        let (ctx, mut ledger) = create_ledger(&arena);
1485        let chf = ctx.commodities.resolve("CHF").unwrap();
1486        let jpy = ctx.commodities.resolve("JPY").unwrap();
1487        let okane = ctx.commodities.resolve("OKANE").unwrap();
1488
1489        let got = ledger
1490            .balance(
1491                &ctx,
1492                &BalanceQuery {
1493                    account: AccountFilter::from_regex_patterns(&ctx, &["^Assets"]).unwrap(),
1494                    conversion: None,
1495                    date_range: DateRange::default(),
1496                },
1497            )
1498            .unwrap()
1499            .into_owned();
1500
1501        let want: Balance = [
1502            (
1503                ctx.account("Assets:CH Bank").unwrap(),
1504                Amount::from_value(chf, dec!(1858.04)),
1505            ),
1506            (
1507                ctx.account("Assets:Broker").unwrap(),
1508                Amount::from_iter([(okane, dec!(4900.0000)), (jpy, dec!(12300))]),
1509            ),
1510            (
1511                ctx.account("Assets:J 銀行").unwrap(),
1512                Amount::from_value(jpy, dec!(980090)),
1513            ),
1514        ]
1515        .into_iter()
1516        .collect();
1517        assert_eq!(want, got);
1518    }
1519}