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