accounting/src/output/cli/balance.rs
2026-01-05 14:19:32 -05:00

621 lines
19 KiB
Rust

use std::{
cmp::{max, Ordering},
collections::{HashMap, HashSet},
};
use ratatui::{style::{Color, Style}, text::{Line, Span, Text}};
use crate::{core::{combine_amounts, Account, Amount, Ledger}, output::cli::tui_to_ansi::text_to_ansi};
#[derive(Debug)]
struct BalanceTree {
name: String,
children: Vec<BalanceTree>,
amounts: Option<Vec<Amount>>,
}
#[derive(Debug)]
struct BalanceTreeStr {
name: String,
children: Vec<BalanceTreeStr>,
amounts: Option<Vec<(String, usize)>>,
}
struct AccountInfo<'a> {
account_path: Vec<&'a str>,
amounts: Vec<Amount>,
}
fn construct_tree(ledger: &Ledger, account_balances: &HashMap<u32, Vec<Amount>>) -> BalanceTree {
let account_info: Vec<AccountInfo> = account_balances
.iter()
.map(|(&account_id, amounts)| {
let account = ledger.get_account(account_id).unwrap();
AccountInfo {
account_path: account.split_name_groups(),
amounts: amounts.clone(),
}
})
.collect();
let root_children = construct_tree_children(account_info.iter().collect(), 0);
BalanceTree {
name: "(Total)".into(),
children: root_children,
amounts: None,
}
}
// fn construct_subtree(accounts: Vec<AccountInfo>, level: usize, parent: &BalanceTree) {
// for account in accounts
// .iter()
// .filter(|v| v.account_path[level] == parent.name)
// {}
// }
fn construct_tree_children(accounts: Vec<&AccountInfo>, level: usize) -> Vec<BalanceTree> {
let mut sub_trees = Vec::new();
let names = HashSet::<&str>::from_iter(accounts.iter().map(|v| v.account_path[level]));
for name in names {
let leaf_account = accounts
.iter()
.find(|v| v.account_path.len() - 1 == level && v.account_path[level] == name);
let sub_accounts: Vec<&AccountInfo> = accounts
.clone()
.into_iter()
.filter(|v| v.account_path.len() - 1 != level && v.account_path[level] == name)
.collect();
let mut sub_tree = BalanceTree {
name: name.into(),
children: Vec::new(),
amounts: None,
};
if sub_accounts.len() != 0 {
sub_tree.children = construct_tree_children(sub_accounts, level + 1);
if let Some(leaf_account) = leaf_account {
sub_tree.children.push(BalanceTree {
name: "...".into(),
children: Vec::new(),
amounts: Some(leaf_account.amounts.clone()),
});
}
} else if let Some(leaf_account) = leaf_account {
sub_tree.amounts = Some(leaf_account.amounts.clone());
} else {
panic!("No account");
}
sub_trees.push(sub_tree)
}
sub_trees
}
fn set_tree_totals(tree: &mut BalanceTree) {
if tree.amounts.is_some() {
return;
}
for child in &mut tree.children {
set_tree_totals(child);
}
let total_amounts = combine_amounts(
tree.children
.iter()
.map(|v| v.amounts.clone().unwrap().into_iter())
.flatten(),
);
tree.amounts = Some(total_amounts);
}
const STYLE_LINE: Style = Style::new().fg(Color::LightBlue);
const STYLE_AMOUNT_LINE: Style = Style::new().fg(Color::DarkGray);
const STYLE_ACCOUNT: Style = Style::new().fg(Color::LightBlue);
fn tree_to_text(tree: &BalanceTreeStr, ledger: &Ledger, base_amount_pos: usize, max_decimal_pos: usize) -> Text<'static> {
let mut text = Text::default();
// let tree_amounts = tree.amounts.as_ref().unwrap().iter().filter(|v| !ledger.round_amount(v).value.is_zero());
let tree_amounts = tree.amounts.as_ref().unwrap().iter();
let tree_amounts_count = tree_amounts.clone().count();
for (i, (amount, decimal_pos)) in tree_amounts.enumerate() {
let mut line = Line::default();
let amount_padding_count = max_decimal_pos - decimal_pos;
if i == 0 {
let amount_pos = base_amount_pos - tree.name.chars().count();
line.push_span(Span::styled(format!("{} ", tree.name), STYLE_ACCOUNT));
let mut line_str = "".repeat(amount_pos);
if tree_amounts_count > 1 {
line_str += ""
} else {
line_str += ""
}
line_str += &"".repeat(amount_padding_count);
line.push_span(Span::styled(line_str, STYLE_AMOUNT_LINE));
} else {
let line_str = if tree.children.len() > 0 {
""
} else {
" "
};
line.push_span(Span::styled(line_str, STYLE_LINE));
let mut line_str = String::new();
line_str += &" ".repeat(base_amount_pos - 2);
if i == tree_amounts_count - 1 {
line_str += "";
line_str += &"".repeat(amount_padding_count);
} else {
line_str += "";
line_str += &" ".repeat(amount_padding_count);
}
line.push_span(Span::styled(line_str, STYLE_AMOUNT_LINE));
}
line.push_span(Span::raw(format!(" {}", amount)));
text.push_line(line);
}
let mut children: Vec<&BalanceTreeStr> = tree.children.iter().collect();
let children_len = children.len();
children.sort_by(|a, b| a.name.cmp(&b.name));
for (i_c, child) in children.into_iter().enumerate() {
let mut child_text = tree_to_text(&child, ledger, base_amount_pos - 4, max_decimal_pos);
for (i, line) in child_text.lines.into_iter().enumerate() {
let mut whole_line = Line::default();
if i_c == children_len - 1 {
if i == 0 {
whole_line.push_span(Span::styled(" └─ ", STYLE_LINE));
} else {
whole_line.push_span(Span::styled(" ", STYLE_LINE));
}
} else {
if i == 0 {
whole_line.push_span(Span::styled(" ├─ ", STYLE_LINE));
} else {
whole_line.push_span(Span::styled("", STYLE_LINE));
}
}
whole_line.extend(line);
text.push_line(whole_line);
}
}
text
}
// fn print_tree(tree: &BalanceTree, ledger: &Ledger, level: usize, amount_pos: usize) {
// let relative_amount_pos = amount_pos - (level*2 + tree.name.len());
// let main_line = format!("{}{} {}", " ".repeat(level), tree.name, "─".repeat(relative_amount_pos));
// let tree_amounts = tree.amounts.as_ref().unwrap().iter().filter(|v| !ledger.round_amount(v).value.is_zero());
// let tree_amounts_count = tree_amounts.clone().count();
// for (i, amount) in tree_amounts.enumerate() {
// let mut line = String::new();
// if i == 0 {
// line += &main_line;
// if tree_amounts_count > 1 {
// line += "┬"
// } else {
// line += "─"
// }
// } else {
// line += &" ".repeat(amount_pos);
// if i == tree_amounts_count - 1 {
// line += " └"
// } else {
// line += " │"
// }
// }
// line += &format!(" {}", ledger.format_amount(amount));
// println!("{}", line);
// }
// // println!("{}{} {} {:?}", " ".repeat(level), tree.name, "-".repeat(relative_amount_pos), tree.amounts);
// let mut children: Vec<&BalanceTree> = tree.children.iter().collect();
// children.sort_by(|a, b| a.name.cmp(&b.name));
// for child in children {
// print_tree(&child, ledger, level + 1, amount_pos);
// }
// }
fn balance_tree_to_str_tree(tree: BalanceTree, ledger: &Ledger) -> BalanceTreeStr {
let amounts = tree.amounts.map(|v| v.iter().map(|a| ledger.format_amount(a).unwrap()).collect());
let children = tree.children.into_iter().map(|c| balance_tree_to_str_tree(c, ledger)).collect();
BalanceTreeStr{amounts, name: tree.name, children}
}
fn calculate_max_account_len(tree: &BalanceTreeStr, indent_amount: usize, indent_level: usize) -> usize {
let current_len = tree.name.len() + indent_amount * indent_level;
let mut max_length = current_len;
for child in &tree.children {
let child_max_len = calculate_max_account_len(child, indent_amount, indent_level+1);
max_length = max(max_length, child_max_len);
}
max_length
}
fn calculate_max_decimal_pos(tree: &BalanceTreeStr) -> usize {
let mut max_decimal_pos = 0;
if let Some(amounts) = &tree.amounts {
for (_, decimal_pos) in amounts {
max_decimal_pos = max(max_decimal_pos, *decimal_pos);
}
};
for child in &tree.children {
let child_max = calculate_max_decimal_pos(child);
max_decimal_pos = max(max_decimal_pos, child_max);
}
max_decimal_pos
}
pub fn format_balance(ledger: &Ledger, account_balances: &HashMap<u32, Vec<Amount>>) -> String {
let mut output = String::new();
let mut tree = construct_tree(ledger, account_balances);
set_tree_totals(&mut tree);
let str_tree = balance_tree_to_str_tree(tree, &ledger);
let max_account_len = calculate_max_account_len(&str_tree, 4, 0);
let max_decimal_pos = calculate_max_decimal_pos(&str_tree);
let text = tree_to_text(&str_tree, &ledger, max_account_len, max_decimal_pos);
println!("{}", text_to_ansi(&text));
// println!("{}", max_account_len);
// print_tree(&tree, &ledger, 0, max_account_len + 5);
// println!("{:?}", tree);
// let base_account_info: Vec<AccountInfo> = account_balances
// .iter()
// .map(|(&account_id, amounts)| {
// let account = ledger.get_account(account_id).unwrap();
// AccountInfo {
// account_path: account.split_name_groups(),
// is_group: false,
// amounts: amounts.clone(),
// }
// })
// .collect();
// let mut account_info: Vec<AccountInfo> = Vec::new();
// for base_account in &base_account_info {}
// let mut group_account_info = Vec::new();
// for account in &account_info {
// let mut account_path = account.account_path.clone();
// loop {
// account_path.pop();
// if account_path.len() == 0 {
// break;
// }
// if !group_account_info
// .iter()
// .any(|v: &AccountInfo| v.account_path == account_path)
// {
// let amount_totals = combine_amounts(
// account_info
// .iter()
// .filter(|v| v.account_path.starts_with(&account_path))
// .map(|v| v.amounts.iter())
// .flatten()
// .map(|v| *v),
// );
// group_account_info.push(AccountInfo {
// account_path: account_path.clone(),
// is_group: true,
// amounts: amount_totals,
// })
// }
// }
// }
// account_info.extend(group_account_info.into_iter());
// sort_account_info(&mut account_info);
// for i in account_info {
// println!("{} {:?}", i.account_path.join(":"), i.amounts);
// }
output
}
// fn sort_account_info(account_info: &mut Vec<AccountInfo>) {
// account_info.sort_by(|a, b| {
// let ordering = a
// .account_path
// .iter()
// .zip(b.account_path.iter())
// .map(|(&a, &b)| a.cmp(b))
// .find(|val| *val != Ordering::Equal);
// ordering.unwrap_or_else(|| a.account_path.len().cmp(&b.account_path.len()))
// });
// }
// /////////////
// // Tests //
// /////////////
// #[cfg(test)]
// mod tests {
// use super::*;
// #[test]
// fn test_sort_account_info() {
// let mut account_info = vec![
// AccountInfo {
// account_path: vec!["a2", "b1"],
// is_group: false,
// amounts: vec![],
// },
// AccountInfo {
// account_path: vec!["a1", "b2"],
// is_group: false,
// amounts: vec![],
// },
// AccountInfo {
// account_path: vec!["a1", "b1"],
// is_group: false,
// amounts: vec![],
// },
// AccountInfo {
// account_path: vec!["a1"],
// is_group: false,
// amounts: vec![],
// },
// ];
// sort_account_info(&mut account_info);
// println!("{:?}", account_info[0].account_path);
// println!("{:?}", account_info[1].account_path);
// println!("{:?}", account_info[2].account_path);
// println!("{:?}", account_info[3].account_path);
// }
// }
/*
Assets
├─ Bank
│ ├─ Checking
│ └─ Savings
└─ Broker
├─ Brokerage
└─ Retirement
Liabilities
├─ Credit Card 1
└─ Credit Card 2
Income
├─ Salary
├─ Capital Gains
│ ├─ Long
│ └─ Short
└─ Other
Expenses
├─ Taxes
├─ Utilities
│ ├─ Power
│ └─ Cellular
├─ Rent
├─ Food
│ ├─ Fast Food
│ ├─ Restaurants
│ └─ Groceries
└─ Entertainment
Equity
├─ Opening Balance
└─ Unrealized Gain
*/
/*
Total ($0.00)
├─ Assets ($10,000.00)
│ ├─ Bank ($2,000.00)
│ │ ├─ Checking $700.00
│ │ └─ Savings $1,300.00
│ └─ Broker ($8,000.00)
│ ├─ Brokerage $3,000.00
│ └─ Retirement $5,000.00
├─ Liabilities (-$1,123.11)
│ ├─ Credit Card 1 -$478.20
│ └─ Credit Card 2 -$644.91
├─ Income (-$10,182.00)
│ ├─ Salary
│ ├─ Capital Gains
│ │ ├─ Long
│ │ └─ Short
│ └─ Other
├─ Expenses
│ ├─ Taxes
│ ├─ Utilities
│ │ ├─ Power
│ │ └─ Cellular
│ ├─ Rent
│ ├─ Food
│ │ ├─ Fast Food
│ │ ├─ Restaurants
│ │ └─ Groceries
│ ├─ Entertainment
│ └─ ...
└─ Equity
├─ Opening Balance
└─ Unrealized Gain
*/
/*
Total ...................... ($0.00)
├─ Assets ............. ($10,000.00)
│ ├─ Bank ............. ($2,000.00)
│ │ ├─ Checking ........ $700.00
│ │ └─ Savings ....... $1,300.00
│ └─ Broker ........... ($8,000.00)
│ ├─ Brokerage ..... $3,000.00
│ └─ Retirement .... $5,000.00
├─ Liabilities ........ (-$1,123.11)
│ ├─ Credit Card 1 ...... -$478.20
│ └─ Credit Card 2 ...... -$644.91
├─ Income ............ (-$10,182.00)
│ ├─ Salary
│ ├─ Capital Gains
│ │ ├─ Long
│ │ └─ Short
│ └─ Other
├─ Expenses
│ ├─ Taxes
│ ├─ Utilities
│ │ ├─ Power
│ │ └─ Cellular
│ ├─ Rent
│ ├─ Food
│ │ ├─ Fast Food
│ │ ├─ Restaurants
│ │ └─ Groceries
│ └─ Entertainment
└─ Equity
├─ Opening Balance
└─ Unrealized Gain
*/
/*
Total........................($0.00)
├─ Assets...............($10,000.00)
│ ├─ Bank...............($2,000.00)
│ │ ├─ Checking..........$700.00
│ │ └─ Savings.........$1,300.00
│ └─ Broker.............($8,000.00)
│ ├─ Brokerage.......$3,000.00
│ └─ Retirement......$5,000.00
├─ Liabilities..........(-$1,123.11)
│ ├─ Credit Card 1........-$478.20
│ └─ Credit Card 2........-$644.91
├─ Income..............(-$10,182.00)
│ ├─ Salary
│ ├─ Capital Gains
│ │ ├─ Long
│ │ └─ Short
│ └─ Other
├─ Expenses
│ ├─ Taxes
│ ├─ Utilities
│ │ ├─ Power
│ │ └─ Cellular
│ ├─ Rent
│ ├─ Food
│ │ ├─ Fast Food
│ │ ├─ Restaurants
│ │ └─ Groceries
│ └─ Entertainment
└─ Equity
├─ Opening Balance
└─ Unrealized Gain
*/
/*
Total ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ($0.00)
├─ Assets ╌╌╌╌╌╌╌╌╌╌╌╌╌ ($10,000.00)
│ ├─ Bank ╌╌╌╌╌╌╌╌╌╌╌╌╌ ($2,000.00)
│ │ ├─ Checking ╌╌╌╌╌╌╌╌ $700.00
│ │ └─ Savings ╌╌╌╌╌╌╌ $1,300.00
│ └─ Broker ╌╌╌╌╌╌╌╌╌╌╌ ($8,000.00)
│ ├─ Brokerage ╌╌╌╌╌ $3,000.00
│ └─ Retirement ╌╌╌╌ $5,000.00
├─ Liabilities ╌╌╌╌╌╌╌╌ (-$1,123.11)
│ ├─ Credit Card 1 ╌╌╌╌╌╌ -$478.20
│ └─ Credit Card 2 ╌╌╌╌╌╌ -$644.91
├─ Income ╌╌╌╌╌╌╌╌╌╌╌╌ (-$10,182.00)
│ ├─ Salary
│ ├─ Capital Gains
│ │ ├─ Long
│ │ └─ Short
│ └─ Other
├─ Expenses
│ ├─ Taxes
│ ├─ Utilities
│ │ ├─ Power
│ │ └─ Cellular
│ ├─ Rent
│ ├─ Food
│ │ ├─ Fast Food
│ │ ├─ Restaurants
│ │ └─ Groceries
│ └─ Entertainment
└─ Equity
├─ Opening Balance
└─ Unrealized Gain
*/
/*
Total
├─ Assets
│ ├─ Bank
│ │ ├─ Checking $700.00
│ │ └─ Savings $1,300.00
│ └─ Broker
│ ├─ Brokerage $3,000.00
│ └─ Retirement $5,000.00
├─ Liabilities
│ ├─ Credit Card 1 -$478.20
│ └─ Credit Card 2 -$644.91
├─ Income
│ ├─ Salary
│ ├─ Capital Gains
│ │ ├─ Long
│ │ └─ Short
│ └─ Other
├─ Expenses
│ ├─ Taxes
│ ├─ Utilities
│ │ ├─ Power
│ │ └─ Cellular
│ ├─ Rent
│ ├─ Food
│ │ ├─ Fast Food
│ │ ├─ Restaurants
│ │ └─ Groceries
│ └─ Entertainment
└─ Equity
├─ Opening Balance
└─ Unrealized Gain
*/
/*
Assets ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┬ 1.00000000 ETH
│ 248.6010 FSCSX
┆ 40.979 SmallCapCore
┆ 159.122 FSMEX
┆ $25063.25
┆ 47.185 FBGRX
┆ 260.9010 FSCHX
┆ 367.5600 FSRPX
┆ 51.910 FNCMX
┆ 100.151 VanguardTargetSixty
└ 2.933 VanguardIndexPlus
BofA ----------------- $16922.08
Checking ----------- $5165.22
Savings ------------ $11756.86
Coinbase ------------- 1.00000000 ETH
ESPP ----------------- $1071.00
*/