Skip to main content

okane_core/report/
balance_tree.rs

1//! Defines the [`BalanceTree`], tree of balance.
2
3use std::collections::{HashMap, HashSet};
4
5use super::account::{Account, AccountAggregate, AccountTreeKey};
6use super::balance::Balance;
7use super::context::ReportContext;
8use super::eval::Amount;
9use super::query::QueryError;
10
11/// Accumulated balance, considering the account hierarchy.
12#[derive(Debug)]
13pub struct BalanceTree<'ctx> {
14    /// Amount labeled with AccountAggregate.
15    /// Sorted alphabetically, also stored in hierarchical.
16    values: Vec<BalanceTreeNode<'ctx>>,
17}
18
19impl<'ctx> BalanceTree<'ctx> {
20    pub fn create(ctx: &ReportContext<'ctx>, balance: Balance<'ctx>) -> Result<Self, QueryError> {
21        let mut values: Vec<BalanceTreeNode<'ctx>> = vec![BalanceTreeNode::placeholder(
22            AccountTreeKey::Root,
23            0,
24            Amount::zero(),
25        )];
26        // To tell if the account aggregate is required or not,
27        // we need to maintain `required` to avoid going into unnecessary branch in accumulate DFS.
28        let mut required: HashSet<AccountAggregate<'ctx>> = HashSet::new();
29        for (account, _) in balance.iter() {
30            let mut account = (*account).into();
31            required.insert(account);
32            while let Some(parent) = parent_of(ctx, account)? {
33                if !required.insert(parent) {
34                    // other already inserted, no need to do.
35                    break;
36                }
37                account = parent;
38            }
39        }
40        accumulate(&mut balance.into_map(), &mut values, ctx, &required, 0)?;
41        Ok(Self { values })
42    }
43
44    /// Returns all nodes in the flat vector.
45    ///
46    /// The order is both alphabetical (by account name) and a pre-order
47    /// (DFS) flattening of the account hierarchy; index 0 is always the
48    /// synthetic [`AccountTreeKey::Root`].
49    pub fn nodes(&self) -> &[BalanceTreeNode<'ctx>] {
50        &self.values
51    }
52
53    /// Consumes the tree and returns the owned node vector.
54    /// See [`Self::nodes`] for the ordering guarantees.
55    pub fn into_nodes(self) -> Vec<BalanceTreeNode<'ctx>> {
56        self.values
57    }
58}
59
60fn parent_of<'ctx>(
61    ctx: &ReportContext<'ctx>,
62    account: AccountAggregate<'ctx>,
63) -> Result<Option<AccountAggregate<'ctx>>, QueryError> {
64    let parent = ctx.account_tree.parent(account).ok_or_else(|| {
65        QueryError::Internal(format!(
66            "account {} isn't registered in the AccountTree",
67            account.as_str()
68        ))
69    })?;
70    Ok(match parent {
71        AccountTreeKey::Root => None,
72        AccountTreeKey::Descendant(parent) => Some(parent),
73    })
74}
75
76fn label(account: AccountTreeKey<'_>) -> &str {
77    account
78        .as_aggregate()
79        .map(|x| x.as_str())
80        .unwrap_or_default()
81}
82
83fn accumulate<'ctx>(
84    balance: &mut HashMap<Account<'ctx>, Amount<'ctx>>,
85    values: &mut Vec<BalanceTreeNode<'ctx>>,
86    ctx: &ReportContext<'ctx>,
87    required: &HashSet<AccountAggregate<'ctx>>,
88    i: usize,
89) -> Result<(Amount<'ctx>, NodeRange), QueryError> {
90    debug_assert!(i < values.len());
91    let account = values[i].account;
92    let depth = values[i].depth;
93    let mut total = values[i].self_amount.clone();
94    let mut children: Vec<NodeRange> = Vec::new();
95    for account in ctx.account_tree.children(account).ok_or_else(|| {
96        QueryError::Internal(format!(
97            "account {} isn't registered in the AccountTree",
98            label(account)
99        ))
100    })? {
101        if !required.contains(account) {
102            continue;
103        }
104        let j = values.len();
105        let amount = match account {
106            AccountAggregate::Account(account) => balance.remove(account).unwrap_or_default(),
107            AccountAggregate::Ancestor(_) => Amount::default(),
108        };
109        values.push(BalanceTreeNode::placeholder(
110            (*account).into(),
111            depth + 1,
112            amount,
113        ));
114        let (child_amount, child_range) = accumulate(balance, values, ctx, required, j)?;
115        total += child_amount;
116        children.push(child_range);
117    }
118    let cover = NodeRange::cover(i, &children)?;
119    total.remove_zero_entries();
120    values[i].subtree_amount = total.clone();
121    values[i].children = children;
122    values[i].range = cover;
123    Ok((total, cover))
124}
125
126/// Node of the each element.
127#[derive(Debug, PartialEq, Eq)]
128pub struct BalanceTreeNode<'ctx> {
129    /// Account key of this node ([`AccountTreeKey::Root`] for the synthetic root).
130    pub account: AccountTreeKey<'ctx>,
131    /// Depth in the tree: root is `0`, its children `1`, and so on.
132    pub depth: u16,
133    /// Amount of the node itself, excluding descendants.
134    pub self_amount: Amount<'ctx>,
135    /// Amount of the node and all its descendants.
136    pub subtree_amount: Amount<'ctx>,
137    /// Children, expressed as Vector index range.
138    children: Vec<NodeRange>,
139    /// Contiguous span of this node plus all its descendants in the flat vector.
140    range: NodeRange,
141}
142
143impl<'ctx> BalanceTreeNode<'ctx> {
144    /// Creates a new instance with place holder.
145    fn placeholder(account: AccountTreeKey<'ctx>, depth: u16, self_amount: Amount<'ctx>) -> Self {
146        Self {
147            account,
148            depth,
149            self_amount,
150            // all placeholders below
151            subtree_amount: Amount::zero(),
152            children: Vec::new(),
153            range: NodeRange { start: 0, size: 0 },
154        }
155    }
156
157    /// Returns `true` when the node has at least one child.
158    pub fn has_children(&self) -> bool {
159        !self.children.is_empty()
160    }
161
162    /// Range of this node and all its descendants in [`BalanceTree::nodes`].
163    ///
164    /// The node itself is at `range.start`; its descendants (if any) occupy
165    /// `range.start + 1 .. range.end`.
166    pub fn subtree_range(&self) -> std::ops::Range<usize> {
167        self.range.into()
168    }
169}
170
171/// Range with Copy dedicated for node range.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173struct NodeRange {
174    pub start: usize,
175    pub size: usize,
176}
177
178impl NodeRange {
179    /// Constructs a single NodeRange as a cover of given `[i]` + `ranges`.
180    /// Each range must be in order (smaller to larger), continuous.
181    fn cover(i: usize, ranges: &[NodeRange]) -> Result<Self, QueryError> {
182        let start = i;
183        let mut size = 1;
184        for range in ranges {
185            if start + size != range.start {
186                return Err(QueryError::Internal(format!(
187                    "NodeRange::cover can only cover continuous and mutually exclusive ranges: total start={start}, size={size}, current start={}",
188                    range.start
189                )));
190            }
191            size += range.size;
192        }
193        Ok(Self { start, size })
194    }
195}
196
197impl From<std::ops::Range<usize>> for NodeRange {
198    fn from(value: std::ops::Range<usize>) -> Self {
199        Self {
200            start: value.start,
201            size: value.end.checked_sub(value.start).unwrap(),
202        }
203    }
204}
205
206impl From<NodeRange> for std::ops::Range<usize> {
207    fn from(value: NodeRange) -> Self {
208        Self {
209            start: value.start,
210            end: value.start + value.size,
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    use assert_matches::assert_matches;
220    use bumpalo::Bump;
221    use maplit::hashmap;
222    use pretty_assertions::assert_eq;
223    use rust_decimal::Decimal;
224    use rust_decimal_macros::dec;
225
226    #[test]
227    fn construct_fails_on_unknown_account() {
228        let arena = Bump::new();
229        let mut ctx = ReportContext::new(&arena);
230        let usd = ctx.commodities.ensure("USD");
231
232        // balance is constructed after tree construction.
233        // this will make the balance creation in invalid state.
234        ctx.account_tree.construct(&ctx.accounts);
235        let balance = Balance::from_iter(hashmap! {
236            ctx.accounts.ensure("Assets:Bank") => Amount::from_value(usd, dec!(1)),
237            ctx.accounts.ensure("Assets:Bank:Checking") => Amount::from_value(usd, dec!(3)),
238        });
239
240        let got_err = BalanceTree::create(&ctx, balance).unwrap_err();
241        assert_matches!(got_err, QueryError::Internal(msg) if msg.contains("isn't registered in the AccountTree"));
242    }
243
244    #[test]
245    fn simple_tree_for_non_overlapping_balance() {
246        let arena = Bump::new();
247        let mut ctx = ReportContext::new(&arena);
248        let chf = ctx.commodities.ensure("CHF");
249        let jpy = ctx.commodities.ensure("JPY");
250        let usd = ctx.commodities.ensure("USD");
251
252        let balance = Balance::from_iter(hashmap! {
253            ctx.accounts.ensure("Assets") =>
254                Amount::from_iter([(chf, dec!(10)), (usd, dec!(1))]),
255            ctx.accounts.ensure("Expenses") =>
256                Amount::from_value(jpy, Decimal::ZERO),
257        });
258        ctx.account_tree.construct(&ctx.accounts);
259
260        let tree = BalanceTree::create(&ctx, balance).unwrap();
261        let got = tree.into_nodes();
262        let want = vec![
263            BalanceTreeNode {
264                account: AccountTreeKey::Root,
265                depth: 0,
266                self_amount: Amount::zero(),
267                subtree_amount: Amount::from_iter([(chf, dec!(10)), (usd, dec!(1))]),
268                children: vec![(1..2).into(), (2..3).into()],
269                range: (0..3).into(),
270            },
271            BalanceTreeNode {
272                account: ctx.accounts.resolve("Assets").unwrap().into(),
273                depth: 1,
274                self_amount: Amount::from_iter([(chf, dec!(10)), (usd, dec!(1))]),
275                subtree_amount: Amount::from_iter([(chf, dec!(10)), (usd, dec!(1))]),
276                children: vec![],
277                range: (1..2).into(),
278            },
279            BalanceTreeNode {
280                account: ctx.accounts.resolve("Expenses").unwrap().into(),
281                depth: 1,
282                self_amount: Amount::zero(),
283                subtree_amount: Amount::zero(),
284                children: vec![],
285                range: (2..3).into(),
286            },
287        ];
288        assert_eq!(want, got);
289
290        assert_eq!(2..3, got[2].subtree_range());
291        assert!(got[0].has_children());
292        assert!(!got[2].has_children());
293    }
294
295    /// Resolves `name` to its tree key, whether it is a posted account or an
296    /// ancestor-only prefix.
297    fn account_key<'a>(ctx: &ReportContext<'a>, name: &str) -> AccountTreeKey<'a> {
298        if let Some(account) = ctx.accounts.resolve(name) {
299            account.into()
300        } else {
301            ctx.account_tree
302                .resolve_ancestor(name)
303                .expect("account or prefix")
304                .into()
305        }
306    }
307
308    #[test]
309    fn tree_with_overlapping_and_ancestor_accounts() {
310        let arena = Bump::new();
311        let mut ctx = ReportContext::new(&arena);
312        let usd = ctx.commodities.ensure("USD");
313
314        let balance = Balance::from_iter(hashmap! {
315            ctx.accounts.ensure("Assets:Bank") => Amount::from_value(usd, dec!(1)),
316            ctx.accounts.ensure("Assets:Bank:Checking") => Amount::from_value(usd, dec!(3)),
317            ctx.accounts.ensure("Assets:Cash") => Amount::from_value(usd, dec!(5)),
318            ctx.accounts.ensure("Expenses:Property") => Amount::from_value(usd, dec!(1_000)),
319            ctx.accounts.ensure("Expenses:Property:Maintenance") => Amount::from_value(usd, dec!(23)),
320            ctx.accounts.ensure("Expenses:Property Tax") => Amount::from_value(usd, dec!(13)),
321            ctx.accounts.ensure("Expenses:Travel:Taxi") => Amount::from_value(usd, dec!(11)),
322            // want to see if Expenses:Travel gets Amount::zero.
323            ctx.accounts.ensure("Expenses:Travel:Train") => Amount::from_value(usd, dec!(-11)),
324        });
325        // this account exists, but not included in the balance.
326        ctx.accounts.ensure("Income:Salary");
327        ctx.account_tree.construct(&ctx.accounts);
328
329        let amount = |v| Amount::from_value(usd, v);
330        let key = |name: &str| account_key(&ctx, name);
331
332        let tree = BalanceTree::create(&ctx, balance).unwrap();
333        let got = tree.into_nodes();
334        let want = vec![
335            // 0: Root — spans the whole vector.
336            BalanceTreeNode {
337                account: AccountTreeKey::Root,
338                depth: 0,
339                self_amount: Amount::zero(),
340                subtree_amount: amount(dec!(1045)),
341                children: vec![(1..5).into(), (5..12).into()],
342                range: (0..12).into(),
343            },
344            // 1: Assets — ancestor with no posting of its own.
345            BalanceTreeNode {
346                account: key("Assets"),
347                depth: 1,
348                self_amount: Amount::zero(),
349                subtree_amount: amount(dec!(9)),
350                children: vec![(2..4).into(), (4..5).into()],
351                range: (1..5).into(),
352            },
353            // 2: Assets:Bank — posted *and* a parent; keeps its own 1 USD.
354            BalanceTreeNode {
355                account: key("Assets:Bank"),
356                depth: 2,
357                self_amount: amount(dec!(1)),
358                subtree_amount: amount(dec!(4)),
359                children: vec![(3..4).into()],
360                range: (2..4).into(),
361            },
362            // 3: Assets:Bank:Checking — leaf.
363            BalanceTreeNode {
364                account: key("Assets:Bank:Checking"),
365                depth: 3,
366                self_amount: amount(dec!(3)),
367                subtree_amount: amount(dec!(3)),
368                children: vec![],
369                range: (3..4).into(),
370            },
371            // 4: Assets:Cash — leaf sibling of Assets:Bank.
372            BalanceTreeNode {
373                account: key("Assets:Cash"),
374                depth: 2,
375                self_amount: amount(dec!(5)),
376                subtree_amount: amount(dec!(5)),
377                children: vec![],
378                range: (4..5).into(),
379            },
380            // 5: Expenses — ancestor.
381            BalanceTreeNode {
382                account: key("Expenses"),
383                depth: 1,
384                self_amount: Amount::zero(),
385                subtree_amount: amount(dec!(1036)),
386                children: vec![(6..8).into(), (8..9).into(), (9..12).into()],
387                range: (5..12).into(),
388            },
389            // 6: Expenses:Property — posted and the parent of Maintenance.
390            BalanceTreeNode {
391                account: key("Expenses:Property"),
392                depth: 2,
393                self_amount: amount(dec!(1_000)),
394                subtree_amount: amount(dec!(1_023)),
395                children: vec![(7..8).into()],
396                range: (6..8).into(),
397            },
398            // 7: Expenses:Property:Maintenance — child of Property; stays grouped
399            // under it even though "Property Tax" sorts before it by full name.
400            BalanceTreeNode {
401                account: key("Expenses:Property:Maintenance"),
402                depth: 3,
403                self_amount: amount(dec!(23)),
404                subtree_amount: amount(dec!(23)),
405                children: vec![],
406                range: (7..8).into(),
407            },
408            // 8: Expenses:Property Tax — sibling leaf of Property (not its child).
409            BalanceTreeNode {
410                account: key("Expenses:Property Tax"),
411                depth: 2,
412                self_amount: amount(dec!(13)),
413                subtree_amount: amount(dec!(13)),
414                children: vec![],
415                range: (8..9).into(),
416            },
417            // 9: Expenses:Travel — ancestor with a single posted descendant.
418            BalanceTreeNode {
419                account: key("Expenses:Travel"),
420                depth: 2,
421                self_amount: Amount::zero(),
422                subtree_amount: Amount::zero(),
423                children: vec![(10..11).into(), (11..12).into()],
424                range: (9..12).into(),
425            },
426            // 10: Expenses:Travel:Taxi — leaf.
427            BalanceTreeNode {
428                account: key("Expenses:Travel:Taxi"),
429                depth: 3,
430                self_amount: amount(dec!(11)),
431                subtree_amount: amount(dec!(11)),
432                children: vec![],
433                range: (10..11).into(),
434            },
435            // 11: Expenses:Travel:Train — leaf.
436            BalanceTreeNode {
437                account: key("Expenses:Travel:Train"),
438                depth: 3,
439                self_amount: amount(dec!(-11)),
440                subtree_amount: amount(dec!(-11)),
441                children: vec![],
442                range: (11..12).into(),
443            },
444        ];
445        assert_eq!(want, got);
446    }
447}