envelope_cli/cli/
payee.rs

1//! Payee CLI commands
2//!
3//! Implements CLI commands for payee management.
4
5use clap::Subcommand;
6
7use crate::error::{EnvelopeError, EnvelopeResult};
8use crate::services::{CategoryService, PayeeService};
9use crate::storage::Storage;
10
11/// Payee subcommands
12#[derive(Subcommand)]
13pub enum PayeeCommands {
14    /// List all payees
15    List {
16        /// Search query to filter payees
17        #[arg(short, long)]
18        search: Option<String>,
19    },
20    /// Show payee details
21    Show {
22        /// Payee name or ID
23        payee: String,
24    },
25    /// Set default category for a payee
26    SetCategory {
27        /// Payee name or ID
28        payee: String,
29        /// Category name
30        category: String,
31    },
32    /// Clear default category for a payee
33    ClearCategory {
34        /// Payee name or ID
35        payee: String,
36    },
37    /// Delete a payee
38    Delete {
39        /// Payee name or ID
40        payee: String,
41        /// Skip confirmation
42        #[arg(short, long)]
43        force: bool,
44    },
45    /// Rename a payee
46    Rename {
47        /// Payee name or ID
48        payee: String,
49        /// New name
50        name: String,
51    },
52}
53
54/// Handle a payee command
55pub fn handle_payee_command(storage: &Storage, cmd: PayeeCommands) -> EnvelopeResult<()> {
56    let service = PayeeService::new(storage);
57    let category_service = CategoryService::new(storage);
58
59    match cmd {
60        PayeeCommands::List { search } => {
61            let payees = if let Some(query) = search {
62                service.search(&query, 50)?
63            } else {
64                service.list()?
65            };
66
67            if payees.is_empty() {
68                println!("No payees found.");
69                return Ok(());
70            }
71
72            println!("{:30} {:20} {:10}", "Name", "Default Category", "Usage");
73            println!("{}", "-".repeat(62));
74
75            for payee in &payees {
76                let cat_name = if let Some(cat_id) = payee.default_category_id {
77                    category_service
78                        .get_category(cat_id)?
79                        .map(|c| c.name)
80                        .unwrap_or_else(|| "(deleted)".to_string())
81                } else {
82                    "(auto)".to_string()
83                };
84
85                let usage: u32 = payee.category_frequency.values().sum();
86                let manual_indicator = if payee.manual { "*" } else { "" };
87
88                println!(
89                    "{:30} {:20} {:>10}{}",
90                    truncate(&payee.name, 30),
91                    truncate(&cat_name, 20),
92                    usage,
93                    manual_indicator
94                );
95            }
96
97            println!("\nTotal: {} payees", payees.len());
98            println!("* = manually configured default category");
99        }
100
101        PayeeCommands::Show { payee } => {
102            let p = service
103                .find(&payee)?
104                .ok_or_else(|| EnvelopeError::payee_not_found(&payee))?;
105
106            println!("Payee: {}", p.name);
107            println!("ID:    {}", p.id);
108
109            if let Some(cat_id) = p.default_category_id {
110                if let Some(cat) = category_service.get_category(cat_id)? {
111                    let source = if p.manual { "manual" } else { "learned" };
112                    println!("Default Category: {} ({})", cat.name, source);
113                }
114            } else if let Some(suggested) = p.suggested_category() {
115                if let Some(cat) = category_service.get_category(suggested)? {
116                    println!("Suggested Category: {} (learned)", cat.name);
117                }
118            } else {
119                println!("Default Category: (none)");
120            }
121
122            if !p.category_frequency.is_empty() {
123                println!("\nCategory Usage:");
124                let mut freq: Vec<_> = p.category_frequency.iter().collect();
125                freq.sort_by(|a, b| b.1.cmp(a.1));
126
127                for (cat_id, count) in freq.iter().take(5) {
128                    if let Some(cat) = category_service.get_category(**cat_id)? {
129                        println!("  {:20} {:>5} times", cat.name, count);
130                    }
131                }
132            }
133
134            println!("\nCreated:  {}", p.created_at.format("%Y-%m-%d %H:%M"));
135            println!("Updated:  {}", p.updated_at.format("%Y-%m-%d %H:%M"));
136        }
137
138        PayeeCommands::SetCategory { payee, category } => {
139            let p = service
140                .find(&payee)?
141                .ok_or_else(|| EnvelopeError::payee_not_found(&payee))?;
142
143            let cat = category_service
144                .find_category(&category)?
145                .ok_or_else(|| EnvelopeError::category_not_found(&category))?;
146
147            let updated = service.set_default_category(p.id, cat.id)?;
148            println!(
149                "Set default category for '{}' to '{}'",
150                updated.name, cat.name
151            );
152        }
153
154        PayeeCommands::ClearCategory { payee } => {
155            let p = service
156                .find(&payee)?
157                .ok_or_else(|| EnvelopeError::payee_not_found(&payee))?;
158
159            let updated = service.clear_default_category(p.id)?;
160            println!(
161                "Cleared default category for '{}' (will use learned suggestions)",
162                updated.name
163            );
164        }
165
166        PayeeCommands::Delete { payee, force } => {
167            let p = service
168                .find(&payee)?
169                .ok_or_else(|| EnvelopeError::payee_not_found(&payee))?;
170
171            if !force {
172                println!("About to delete payee: {}", p.name);
173                println!("Use --force to confirm deletion");
174                return Ok(());
175            }
176
177            let deleted = service.delete(p.id)?;
178            println!("Deleted payee: {}", deleted.name);
179        }
180
181        PayeeCommands::Rename { payee, name } => {
182            let p = service
183                .find(&payee)?
184                .ok_or_else(|| EnvelopeError::payee_not_found(&payee))?;
185
186            let old_name = p.name.clone();
187            let renamed = service.rename(p.id, &name)?;
188            println!("Renamed payee: '{}' -> '{}'", old_name, renamed.name);
189        }
190    }
191
192    Ok(())
193}
194
195/// Truncate a string to a maximum length
196fn truncate(s: &str, max_len: usize) -> String {
197    if s.len() <= max_len {
198        s.to_string()
199    } else {
200        format!("{}...", &s[..max_len - 3])
201    }
202}