envelope_cli/display/
category.rs

1//! Category display formatting
2//!
3//! Formats categories and groups for terminal output in tree and table views.
4
5use crate::models::{Category, CategoryGroup};
6use crate::services::category::CategoryGroupWithCategories;
7
8/// Format categories as a tree structure grouped by category group
9pub fn format_category_tree(groups_with_categories: &[CategoryGroupWithCategories]) -> String {
10    if groups_with_categories.is_empty() {
11        return "No categories found.\n\nRun 'envelope init' to create default categories."
12            .to_string();
13    }
14
15    let mut output = String::new();
16
17    for (i, gwc) in groups_with_categories.iter().enumerate() {
18        // Group header
19        output.push_str(&format!("{}\n", gwc.group.name));
20
21        // Categories in group
22        if gwc.categories.is_empty() {
23            output.push_str("  (no categories)\n");
24        } else {
25            for (j, category) in gwc.categories.iter().enumerate() {
26                let is_last = j == gwc.categories.len() - 1;
27                let prefix = if is_last { "└── " } else { "├── " };
28
29                let goal_str = if let Some(goal) = category.goal_amount {
30                    format!(" (goal: {})", crate::models::Money::from_cents(goal))
31                } else {
32                    String::new()
33                };
34
35                output.push_str(&format!("  {}{}{}\n", prefix, category.name, goal_str));
36            }
37        }
38
39        // Add blank line between groups (except after last)
40        if i < groups_with_categories.len() - 1 {
41            output.push('\n');
42        }
43    }
44
45    output
46}
47
48/// Format a simple list of groups
49pub fn format_group_list(groups: &[CategoryGroup]) -> String {
50    if groups.is_empty() {
51        return "No category groups found.".to_string();
52    }
53
54    let mut output = String::new();
55    output.push_str("Category Groups:\n");
56
57    for group in groups {
58        let hidden = if group.hidden { " (hidden)" } else { "" };
59        output.push_str(&format!(
60            "  {} - order: {}{}\n",
61            group.name, group.sort_order, hidden
62        ));
63    }
64
65    output
66}
67
68/// Format a simple list of categories
69pub fn format_category_list(categories: &[Category]) -> String {
70    if categories.is_empty() {
71        return "No categories found.".to_string();
72    }
73
74    let name_width = categories
75        .iter()
76        .map(|c| c.name.len())
77        .max()
78        .unwrap_or(4)
79        .max(4);
80
81    let mut output = String::new();
82    output.push_str(&format!(
83        "{:<width$}  {:>10}  {}\n",
84        "Category",
85        "Goal",
86        "ID",
87        width = name_width
88    ));
89    output.push_str(&format!(
90        "{:-<width$}  {:->10}  {:-<12}\n",
91        "",
92        "",
93        "",
94        width = name_width
95    ));
96
97    for category in categories {
98        let goal_str = category
99            .goal_amount
100            .map(|g| crate::models::Money::from_cents(g).to_string())
101            .unwrap_or_else(|| "-".to_string());
102
103        output.push_str(&format!(
104            "{:<width$}  {:>10}  {}\n",
105            category.name,
106            goal_str,
107            category.id,
108            width = name_width
109        ));
110    }
111
112    output
113}
114
115/// Format category details
116pub fn format_category_details(category: &Category, group: Option<&CategoryGroup>) -> String {
117    let mut output = String::new();
118
119    output.push_str(&format!("Category: {}\n", category.name));
120    output.push_str(&format!("  ID:         {}\n", category.id));
121
122    if let Some(g) = group {
123        output.push_str(&format!("  Group:      {}\n", g.name));
124    }
125
126    output.push_str(&format!(
127        "  Hidden:     {}\n",
128        if category.hidden { "Yes" } else { "No" }
129    ));
130    output.push_str(&format!("  Sort Order: {}\n", category.sort_order));
131
132    if let Some(goal) = category.goal_amount {
133        output.push_str(&format!(
134            "  Goal:       {}\n",
135            crate::models::Money::from_cents(goal)
136        ));
137    }
138
139    if !category.notes.is_empty() {
140        output.push_str(&format!("  Notes:      {}\n", category.notes));
141    }
142
143    output.push('\n');
144    output.push_str(&format!(
145        "  Created:  {}\n",
146        category.created_at.format("%Y-%m-%d %H:%M UTC")
147    ));
148    output.push_str(&format!(
149        "  Modified: {}\n",
150        category.updated_at.format("%Y-%m-%d %H:%M UTC")
151    ));
152
153    output
154}
155
156/// Format group details
157pub fn format_group_details(group: &CategoryGroup, categories: &[Category]) -> String {
158    let mut output = String::new();
159
160    output.push_str(&format!("Category Group: {}\n", group.name));
161    output.push_str(&format!("  ID:         {}\n", group.id));
162    output.push_str(&format!(
163        "  Hidden:     {}\n",
164        if group.hidden { "Yes" } else { "No" }
165    ));
166    output.push_str(&format!("  Sort Order: {}\n", group.sort_order));
167    output.push_str(&format!("  Categories: {}\n", categories.len()));
168
169    if !categories.is_empty() {
170        output.push_str("\n  Categories in this group:\n");
171        for category in categories {
172            output.push_str(&format!("    - {}\n", category.name));
173        }
174    }
175
176    output.push('\n');
177    output.push_str(&format!(
178        "  Created:  {}\n",
179        group.created_at.format("%Y-%m-%d %H:%M UTC")
180    ));
181    output.push_str(&format!(
182        "  Modified: {}\n",
183        group.updated_at.format("%Y-%m-%d %H:%M UTC")
184    ));
185
186    output
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_format_empty_tree() {
195        let output = format_category_tree(&[]);
196        assert!(output.contains("No categories found"));
197    }
198
199    #[test]
200    fn test_format_category_tree() {
201        let group = CategoryGroup::new("Bills");
202        let cat1 = Category::new("Rent", group.id);
203        let cat2 = Category::new("Electric", group.id);
204
205        let gwc = CategoryGroupWithCategories {
206            group,
207            categories: vec![cat1, cat2],
208        };
209
210        let output = format_category_tree(&[gwc]);
211        assert!(output.contains("Bills"));
212        assert!(output.contains("Rent"));
213        assert!(output.contains("Electric"));
214        assert!(output.contains("├──"));
215        assert!(output.contains("└──"));
216    }
217
218    #[test]
219    fn test_format_category_with_goal() {
220        let group = CategoryGroup::new("Savings");
221        let mut cat = Category::new("Emergency Fund", group.id);
222        cat.set_goal(100000); // $1000
223
224        let gwc = CategoryGroupWithCategories {
225            group,
226            categories: vec![cat],
227        };
228
229        let output = format_category_tree(&[gwc]);
230        assert!(output.contains("Emergency Fund"));
231        assert!(output.contains("goal:"));
232        assert!(output.contains("$1000.00"));
233    }
234}