envelope_cli/display/
account.rs

1//! Account display formatting
2//!
3//! Formats accounts for terminal output in table and detail views.
4
5use crate::models::Account;
6use crate::services::account::AccountSummary;
7use tabled::{
8    settings::{object::Columns, Alignment, Modify, Style},
9    Table, Tabled,
10};
11
12/// Row for account table display (used in pretty mode)
13#[derive(Tabled)]
14struct AccountRow {
15    #[tabled(rename = "Name")]
16    name: String,
17    #[tabled(rename = "Type")]
18    account_type: String,
19    #[tabled(rename = "Balance")]
20    balance: String,
21    #[tabled(rename = "Cleared")]
22    cleared: String,
23    #[tabled(rename = "Status")]
24    status: String,
25}
26
27/// Format a list of accounts with balances as a table
28///
29/// When `pretty` is true, uses a bordered table with rounded corners.
30/// When `pretty` is false, uses a simple text-based table format.
31pub fn format_account_list(summaries: &[AccountSummary], pretty: bool) -> String {
32    if summaries.is_empty() {
33        return "No accounts found.".to_string();
34    }
35
36    if pretty {
37        format_account_list_pretty(summaries)
38    } else {
39        format_account_list_plain(summaries)
40    }
41}
42
43/// Format account list with bordered table (pretty mode)
44fn format_account_list_pretty(summaries: &[AccountSummary]) -> String {
45    let mut rows: Vec<AccountRow> = summaries
46        .iter()
47        .map(|summary| {
48            let status = get_account_status(summary);
49            AccountRow {
50                name: summary.account.name.clone(),
51                account_type: summary.account.account_type.to_string(),
52                balance: summary.balance.to_string(),
53                cleared: summary.cleared_balance.to_string(),
54                status,
55            }
56        })
57        .collect();
58
59    // Add total row
60    let total_balance: crate::models::Money = summaries.iter().map(|s| s.balance).sum();
61    let total_cleared: crate::models::Money = summaries.iter().map(|s| s.cleared_balance).sum();
62
63    rows.push(AccountRow {
64        name: "TOTAL".to_string(),
65        account_type: String::new(),
66        balance: total_balance.to_string(),
67        cleared: total_cleared.to_string(),
68        status: String::new(),
69    });
70
71    Table::new(&rows)
72        .with(Style::rounded())
73        .with(Modify::new(Columns::new(2..=3)).with(Alignment::right()))
74        .to_string()
75}
76
77/// Format account list with simple text table (plain mode)
78/// Matches the budget overview formatting style (80-char width)
79fn format_account_list_plain(summaries: &[AccountSummary]) -> String {
80    let mut output = String::new();
81
82    // Header - matches budget overview style
83    output.push_str("Accounts\n");
84    output.push_str(&"=".repeat(80));
85    output.push('\n');
86
87    // Column headers - aligned with data rows
88    // Widths: Name=26, Type=14, Balance=12, Cleared=12, Status=12 (+ 4 spaces = 80)
89    output.push_str(&format!(
90        "{:<26} {:>14} {:>12} {:>12} {:>12}\n",
91        "Name", "Type", "Balance", "Cleared", "Status"
92    ));
93    output.push_str(&"-".repeat(80));
94    output.push('\n');
95
96    // Account rows - same column widths as header
97    // Note: account_type.to_string() is needed because AccountType's Display
98    // impl doesn't honor width specifiers, unlike Money which does
99    for summary in summaries {
100        let status = get_account_status(summary);
101        output.push_str(&format!(
102            "{:<26} {:>14} {:>12} {:>12} {:>12}\n",
103            truncate_str(&summary.account.name, 26),
104            summary.account.account_type.to_string(),
105            summary.balance,
106            summary.cleared_balance,
107            status,
108        ));
109    }
110
111    // Total row
112    let total_balance: crate::models::Money = summaries.iter().map(|s| s.balance).sum();
113    let total_cleared: crate::models::Money = summaries.iter().map(|s| s.cleared_balance).sum();
114
115    output.push('\n');
116    output.push_str(&"=".repeat(80));
117    output.push('\n');
118    output.push_str(&format!(
119        "{:<26} {:>14} {:>12} {:>12}\n",
120        "TOTALS:", "", total_balance, total_cleared
121    ));
122
123    output
124}
125
126/// Truncate a string to a maximum length, adding "..." if truncated
127fn truncate_str(s: &str, max_len: usize) -> String {
128    if s.len() <= max_len {
129        s.to_string()
130    } else if max_len > 3 {
131        format!("{}...", &s[..max_len - 3])
132    } else {
133        s[..max_len].to_string()
134    }
135}
136
137/// Get status string for an account
138fn get_account_status(summary: &AccountSummary) -> String {
139    if summary.account.archived {
140        "Archived".to_string()
141    } else if !summary.account.on_budget {
142        "Off-Budget".to_string()
143    } else if summary.uncleared_count > 0 {
144        format!("{} pending", summary.uncleared_count)
145    } else {
146        "Active".to_string()
147    }
148}
149
150/// Format a single account's details
151pub fn format_account_details(summary: &AccountSummary) -> String {
152    let account = &summary.account;
153
154    let mut output = String::new();
155
156    output.push_str(&format!("Account: {}\n", account.name));
157    output.push_str(&format!("  Type:           {}\n", account.account_type));
158    output.push_str(&format!("  ID:             {}\n", account.id));
159    output.push_str(&format!(
160        "  On Budget:      {}\n",
161        if account.on_budget { "Yes" } else { "No" }
162    ));
163    output.push_str(&format!(
164        "  Archived:       {}\n",
165        if account.archived { "Yes" } else { "No" }
166    ));
167    output.push('\n');
168    output.push_str(&format!(
169        "  Starting Balance: {}\n",
170        account.starting_balance
171    ));
172    output.push_str(&format!("  Current Balance:  {}\n", summary.balance));
173    output.push_str(&format!(
174        "  Cleared Balance:  {}\n",
175        summary.cleared_balance
176    ));
177    output.push_str(&format!(
178        "  Uncleared Count:  {}\n",
179        summary.uncleared_count
180    ));
181
182    if let Some(date) = account.last_reconciled_date {
183        output.push('\n');
184        output.push_str(&format!("  Last Reconciled:  {}\n", date));
185        if let Some(balance) = account.last_reconciled_balance {
186            output.push_str(&format!("  Reconciled Balance: {}\n", balance));
187        }
188    }
189
190    if !account.notes.is_empty() {
191        output.push('\n');
192        output.push_str(&format!("  Notes: {}\n", account.notes));
193    }
194
195    output.push('\n');
196    output.push_str(&format!(
197        "  Created:  {}\n",
198        account.created_at.format("%Y-%m-%d %H:%M UTC")
199    ));
200    output.push_str(&format!(
201        "  Modified: {}\n",
202        account.updated_at.format("%Y-%m-%d %H:%M UTC")
203    ));
204
205    output
206}
207
208/// Format a simple account list (name and type only)
209pub fn format_account_list_simple(accounts: &[Account]) -> String {
210    if accounts.is_empty() {
211        return "No accounts found.".to_string();
212    }
213
214    let mut output = String::new();
215    for account in accounts {
216        let status = if account.archived { " (archived)" } else { "" };
217        output.push_str(&format!(
218            "  {} - {}{}\n",
219            account.name, account.account_type, status
220        ));
221    }
222    output
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::models::{AccountType, Money};
229
230    fn create_test_summary(name: &str, balance: i64, cleared: i64) -> AccountSummary {
231        let account =
232            Account::with_starting_balance(name, AccountType::Checking, Money::from_cents(0));
233        AccountSummary {
234            account,
235            balance: Money::from_cents(balance),
236            cleared_balance: Money::from_cents(cleared),
237            uncleared_count: if balance != cleared { 1 } else { 0 },
238        }
239    }
240
241    #[test]
242    fn test_format_account_list_plain() {
243        let summaries = vec![
244            create_test_summary("Checking", 100000, 95000),
245            create_test_summary("Savings", 500000, 500000),
246        ];
247
248        let output = format_account_list(&summaries, false);
249        assert!(output.contains("Accounts"));
250        assert!(output.contains("====")); // 80-char header separator
251        assert!(output.contains("----")); // 80-char row separator
252        assert!(output.contains("Checking"));
253        assert!(output.contains("Savings"));
254        assert!(output.contains("TOTALS:")); // Matches budget overview style
255    }
256
257    #[test]
258    fn test_format_account_list_pretty() {
259        let summaries = vec![
260            create_test_summary("Checking", 100000, 95000),
261            create_test_summary("Savings", 500000, 500000),
262        ];
263
264        let output = format_account_list(&summaries, true);
265        assert!(output.contains("Checking"));
266        assert!(output.contains("Savings"));
267        assert!(output.contains("TOTAL"));
268        assert!(output.contains("│")); // Pretty mode has box drawing chars
269    }
270
271    #[test]
272    fn test_format_empty_list() {
273        let output = format_account_list(&[], false);
274        assert!(output.contains("No accounts found"));
275    }
276
277    #[test]
278    fn test_format_account_details() {
279        let summary = create_test_summary("My Account", 100000, 90000);
280        let output = format_account_details(&summary);
281
282        assert!(output.contains("My Account"));
283        assert!(output.contains("Checking"));
284        assert!(output.contains("Current Balance"));
285        assert!(output.contains("Cleared Balance"));
286    }
287}