Skip to content

Commit 0fa680d

Browse files
committed
Add account hierarchy to balance commands
Expand colon-separated account hierarchies so parent accounts aggregate their children's balances. Group entries are displayed after leaf accounts, with children nested below their parent group. Group labels are styled with a colored background in terminal output.
1 parent 75c0a97 commit 0fa680d

5 files changed

Lines changed: 242 additions & 18 deletions

File tree

src/lib.rs

Lines changed: 106 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -594,14 +594,19 @@ pub fn show_account_aligned(
594594
width_rec: &WidthRecord,
595595
account_id: &str,
596596
map: &CommodityMap,
597+
is_group: bool,
597598
) -> String {
598599
let gap = 2;
599600
let account_width = width_rec.account.max(account_id.len());
600601
// Right-align (pad on left) to match PureScript Text.Format behavior
601602
let acc_name = pad_start(account_width, account_id);
602603

603604
let colored_acc = if color {
604-
acc_name.blue().to_string()
605+
if is_group {
606+
acc_name.black().on_cyan().to_string()
607+
} else {
608+
acc_name.blue().to_string()
609+
}
605610
} else {
606611
acc_name
607612
};
@@ -849,6 +854,43 @@ pub enum BalanceFilter {
849854
All,
850855
}
851856

857+
/// Normalize account IDs (strip `:_default_`) and create parent entries
858+
/// that aggregate the balances of all their children.
859+
///
860+
/// For example, given leaf accounts `john:visa`, `john:bank:savings`, and
861+
/// `john:bank:depot`, this produces additional entries for `john:bank`
862+
/// (sum of savings + depot) and `john` (sum of all three).
863+
pub fn expand_account_hierarchy(balance_map: BalanceMap) -> BalanceMap {
864+
// Step 1: Normalize all keys (remove :_default_ suffix)
865+
let mut normalized: BalanceMap = BalanceMap::new();
866+
for (acc_id, com_map) in balance_map {
867+
let norm_id = norm_acc_id(&acc_id);
868+
let entry = normalized.entry(norm_id).or_default();
869+
for amount in com_map.into_values() {
870+
commodity_map_add(entry, amount);
871+
}
872+
}
873+
874+
// Step 2: For each account, add its amounts to all ancestor prefixes
875+
let leaves: Vec<(String, CommodityMap)> = normalized
876+
.iter()
877+
.map(|(k, v)| (k.clone(), v.clone()))
878+
.collect();
879+
880+
for (acc_id, com_map) in &leaves {
881+
let parts: Vec<&str> = acc_id.split(':').collect();
882+
for i in 1..parts.len() {
883+
let ancestor = parts[..i].join(":");
884+
let entry = normalized.entry(ancestor).or_default();
885+
for amount in com_map.values() {
886+
commodity_map_add(entry, amount.clone());
887+
}
888+
}
889+
}
890+
891+
normalized
892+
}
893+
852894
pub fn show_balance(
853895
filter: BalanceFilter,
854896
color: bool,
@@ -875,6 +917,10 @@ pub fn show_balance(
875917
}
876918
}
877919

920+
// Expand the account hierarchy so that parent accounts
921+
// (e.g. "john", "john:bank") aggregate their children.
922+
let balance_map = expand_account_hierarchy(balance_map);
923+
878924
// Apply filter and remove zero amounts
879925
let mut filtered: Vec<(String, CommodityMap)> = balance_map
880926
.into_iter()
@@ -901,14 +947,64 @@ pub fn show_balance(
901947
})
902948
.collect();
903949

904-
// Sort by account id
950+
// Determine which accounts are groups (have children in the list)
951+
let account_ids: Vec<String> =
952+
filtered.iter().map(|(id, _)| id.clone()).collect();
953+
let is_group = |acc_id: &str| -> bool {
954+
let prefix = format!("{}:", acc_id);
955+
account_ids.iter().any(|id| id.starts_with(&prefix))
956+
};
957+
// Find the most specific (deepest) parent group for an account
958+
let parent_group = |acc_id: &str| -> Option<String> {
959+
account_ids
960+
.iter()
961+
.filter(|g| is_group(g) && acc_id.starts_with(&format!("{}:", g)))
962+
.max_by_key(|g| g.len())
963+
.cloned()
964+
};
965+
966+
// Sort alphabetically first
905967
filtered.sort_by(|a, b| a.0.cmp(&b.0));
906968

969+
// Partition into ungrouped leaves, groups, and their children
970+
let mut ungrouped: Vec<(String, CommodityMap)> = Vec::new();
971+
let mut groups: std::collections::BTreeMap<
972+
String,
973+
Vec<(String, CommodityMap)>,
974+
> = std::collections::BTreeMap::new();
975+
let mut group_entries: std::collections::BTreeMap<String, CommodityMap> =
976+
std::collections::BTreeMap::new();
977+
978+
for (acc_id, com_map) in filtered {
979+
if is_group(&acc_id) {
980+
groups.entry(acc_id.clone()).or_default();
981+
group_entries.insert(acc_id, com_map);
982+
} else if let Some(g) = parent_group(&acc_id) {
983+
groups.entry(g).or_default().push((acc_id, com_map));
984+
} else {
985+
ungrouped.push((acc_id, com_map));
986+
}
987+
}
988+
989+
// Reassemble: ungrouped leaves, then each group with its children
990+
let mut ordered: Vec<(String, CommodityMap, bool)> = Vec::new();
991+
for (acc_id, com_map) in ungrouped {
992+
ordered.push((acc_id, com_map, false));
993+
}
994+
for (group_id, children) in &groups {
995+
if let Some(com_map) = group_entries.remove(group_id) {
996+
ordered.push((group_id.clone(), com_map, true));
997+
}
998+
for (acc_id, com_map) in children {
999+
ordered.push((acc_id.clone(), com_map.clone(), false));
1000+
}
1001+
}
1002+
9071003
// Compute global width record
9081004
let global_wr =
909-
filtered
1005+
ordered
9101006
.iter()
911-
.fold(WidthRecord::zero(), |acc, (acc_id, map)| {
1007+
.fold(WidthRecord::zero(), |acc, (acc_id, map, _)| {
9121008
acc.merge(&account_to_width_record(&norm_acc_id(acc_id), map))
9131009
});
9141010
let margin_left = 2;
@@ -918,12 +1014,13 @@ pub fn show_balance(
9181014
};
9191015

9201016
let mut result = String::new();
921-
for (acc_id, map) in &filtered {
1017+
for (acc_id, map, group) in &ordered {
1018+
let norm_id = norm_acc_id(acc_id);
1019+
if *group {
1020+
result.push('\n');
1021+
}
9221022
result.push_str(&show_account_aligned(
923-
color,
924-
&global_wr,
925-
&norm_acc_id(acc_id),
926-
map,
1023+
color, &global_wr, &norm_id, map, *group,
9271024
));
9281025
}
9291026
result.push('\n');

src/main.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1124,6 +1124,131 @@ transactions:
11241124
assert!(verify_ledger_balances(&ledger).is_ok());
11251125
}
11261126

1127+
// ─── expand_account_hierarchy ──────────────────────────────────────────────
1128+
1129+
#[test]
1130+
fn hierarchy_creates_parent_from_single_child() {
1131+
let mut map = BalanceMap::new();
1132+
let mut child = CommodityMap::new();
1133+
commodity_map_add(&mut child, make_amount(100, 1, "€"));
1134+
map.insert("john:giro".to_string(), child);
1135+
1136+
let result = expand_account_hierarchy(map);
1137+
1138+
assert!(result.contains_key("john"), "Expected parent 'john'");
1139+
assert!(
1140+
result.contains_key("john:giro"),
1141+
"Expected leaf 'john:giro'"
1142+
);
1143+
assert_eq!(
1144+
result["john"]["€"].quantity, result["john:giro"]["€"].quantity,
1145+
"Parent should equal child when there's only one child"
1146+
);
1147+
}
1148+
1149+
#[test]
1150+
fn hierarchy_aggregates_multiple_children() {
1151+
let mut map = BalanceMap::new();
1152+
1153+
let mut savings = CommodityMap::new();
1154+
commodity_map_add(&mut savings, make_amount(50, 1, "€"));
1155+
map.insert("john:bank:savings".to_string(), savings);
1156+
1157+
let mut depot = CommodityMap::new();
1158+
commodity_map_add(&mut depot, make_amount(30, 1, "€"));
1159+
map.insert("john:bank:depot".to_string(), depot);
1160+
1161+
let mut visa = CommodityMap::new();
1162+
commodity_map_add(&mut visa, make_amount(20, 1, "€"));
1163+
map.insert("john:visa".to_string(), visa);
1164+
1165+
let result = expand_account_hierarchy(map);
1166+
1167+
// john:bank = savings + depot = 80
1168+
assert_eq!(result["john:bank"]["€"], make_amount(80, 1, "€"));
1169+
// john = visa + savings + depot = 100
1170+
assert_eq!(result["john"]["€"], make_amount(100, 1, "€"));
1171+
// Leaves unchanged
1172+
assert_eq!(result["john:bank:savings"]["€"], make_amount(50, 1, "€"));
1173+
assert_eq!(result["john:bank:depot"]["€"], make_amount(30, 1, "€"));
1174+
assert_eq!(result["john:visa"]["€"], make_amount(20, 1, "€"));
1175+
}
1176+
1177+
#[test]
1178+
fn hierarchy_merges_default_suffix_into_parent() {
1179+
// A single-segment account like "shop" becomes "shop:_default_"
1180+
// in the balance map. The hierarchy should normalize it to "shop"
1181+
// and not create a separate parent.
1182+
let mut map = BalanceMap::new();
1183+
let mut entry = CommodityMap::new();
1184+
commodity_map_add(&mut entry, make_amount(10, 1, "€"));
1185+
map.insert("shop:_default_".to_string(), entry);
1186+
1187+
let result = expand_account_hierarchy(map);
1188+
1189+
assert!(result.contains_key("shop"), "Expected normalized 'shop'");
1190+
assert!(
1191+
!result.contains_key("shop:_default_"),
1192+
"_default_ key should be normalized away"
1193+
);
1194+
assert_eq!(result["shop"]["€"], make_amount(10, 1, "€"));
1195+
}
1196+
1197+
#[test]
1198+
fn hierarchy_merges_default_with_explicit_children() {
1199+
// Direct transfers to "john" (stored as john:_default_) plus
1200+
// transfers to john:giro should both roll up into "john".
1201+
let mut map = BalanceMap::new();
1202+
1203+
let mut direct = CommodityMap::new();
1204+
commodity_map_add(&mut direct, make_amount(40, 1, "€"));
1205+
map.insert("john:_default_".to_string(), direct);
1206+
1207+
let mut giro = CommodityMap::new();
1208+
commodity_map_add(&mut giro, make_amount(60, 1, "€"));
1209+
map.insert("john:giro".to_string(), giro);
1210+
1211+
let result = expand_account_hierarchy(map);
1212+
1213+
// john = direct (40) + giro (60) = 100
1214+
assert_eq!(result["john"]["€"], make_amount(100, 1, "€"));
1215+
// john:giro is unchanged
1216+
assert_eq!(result["john:giro"]["€"], make_amount(60, 1, "€"));
1217+
}
1218+
1219+
#[test]
1220+
fn hierarchy_handles_multiple_commodities() {
1221+
let mut map = BalanceMap::new();
1222+
1223+
let mut giro = CommodityMap::new();
1224+
commodity_map_add(&mut giro, make_amount(50, 1, "€"));
1225+
commodity_map_add(&mut giro, make_amount(3, 1, "BTC"));
1226+
map.insert("john:giro".to_string(), giro);
1227+
1228+
let mut wallet = CommodityMap::new();
1229+
commodity_map_add(&mut wallet, make_amount(20, 1, "€"));
1230+
map.insert("john:wallet".to_string(), wallet);
1231+
1232+
let result = expand_account_hierarchy(map);
1233+
1234+
assert_eq!(result["john"]["€"], make_amount(70, 1, "€"));
1235+
assert_eq!(result["john"]["BTC"], make_amount(3, 1, "BTC"));
1236+
}
1237+
1238+
#[test]
1239+
fn hierarchy_no_parent_for_top_level_account() {
1240+
// A top-level account should not create any parents.
1241+
let mut map = BalanceMap::new();
1242+
let mut entry = CommodityMap::new();
1243+
commodity_map_add(&mut entry, make_amount(10, 1, "€"));
1244+
map.insert("shop".to_string(), entry);
1245+
1246+
let result = expand_account_hierarchy(map);
1247+
1248+
assert_eq!(result.len(), 1);
1249+
assert!(result.contains_key("shop"));
1250+
}
1251+
11271252
// ─── show_balance ─────────────────────────────────────────────────────────
11281253

11291254
fn simple_ledger() -> Ledger {

tests/snapshots/cli_snapshots__balance.snap

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
source: tests/cli_snapshots.rs
33
expression: output
44
---
5-
john 212.19
5+
john 50 $
6+
60.752114 BTC
7+
363.24
68
john:giro 50 $
79
60.752114 BTC
810
85
911
john:wallet 66.05
10-
11-

tests/snapshots/cli_snapshots__balance_all.snap

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ expression: output
1212
315
1313
good-inc -820
1414
grocery-shop 11.97
15-
john 212.19
15+
16+
john 50 $
17+
60.752114 BTC
18+
363.24
1619
john:giro 50 $
1720
60.752114 BTC
1821
85
1922
john:wallet 66.05
20-
21-

tests/snapshots/cli_snapshots__balance_multi.snap

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@
22
source: tests/cli_snapshots.rs
33
expression: output
44
---
5-
john 212.19
5+
john 50 $
6+
60.752114 BTC
7+
-7.64 £
8+
363.24
69
john:giro 50 $
710
60.752114 BTC
811
-7.64 £
912
85
1013
john:wallet 66.05
11-
12-

0 commit comments

Comments
 (0)