envelope_cli/cli/
target.rs

1//! Budget target CLI commands
2//!
3//! Implements CLI commands for managing recurring budget targets on categories.
4
5use chrono::NaiveDate;
6use clap::Subcommand;
7
8use crate::config::settings::Settings;
9use crate::error::{EnvelopeError, EnvelopeResult};
10use crate::models::{Money, TargetCadence};
11use crate::services::{BudgetService, CategoryService, PeriodService};
12use crate::storage::Storage;
13
14/// Target subcommands
15#[derive(Subcommand)]
16pub enum TargetCommands {
17    /// Set a budget target for a category
18    Set {
19        /// Category name or ID
20        category: String,
21        /// Target amount (e.g., "500" or "500.00")
22        amount: String,
23        /// Target cadence: weekly, monthly, yearly, custom, or by-date
24        #[arg(short, long, default_value = "monthly")]
25        cadence: String,
26        /// Number of days for custom cadence (required when cadence is "custom")
27        #[arg(long)]
28        days: Option<u32>,
29        /// Target date for by-date cadence (YYYY-MM-DD, required when cadence is "by-date")
30        #[arg(long)]
31        date: Option<String>,
32    },
33
34    /// List all active budget targets
35    List,
36
37    /// Show the target for a specific category
38    Show {
39        /// Category name or ID
40        category: String,
41    },
42
43    /// Delete the target for a category
44    Delete {
45        /// Category name or ID
46        category: String,
47    },
48
49    /// Auto-fill budgets from targets for a period
50    #[command(name = "auto-fill")]
51    AutoFill {
52        /// Budget period (e.g., "2025-01", "current")
53        #[arg(short, long)]
54        period: Option<String>,
55    },
56}
57
58/// Handle a target command
59pub fn handle_target_command(
60    storage: &Storage,
61    settings: &Settings,
62    cmd: TargetCommands,
63) -> EnvelopeResult<()> {
64    let period_service = PeriodService::new(settings);
65
66    match cmd {
67        TargetCommands::Set {
68            category,
69            amount,
70            cadence,
71            days,
72            date,
73        } => {
74            let category_service = CategoryService::new(storage);
75            let cat = category_service
76                .find_category(&category)?
77                .ok_or_else(|| EnvelopeError::category_not_found(&category))?;
78
79            let amount = Money::parse(&amount)
80                .map_err(|e| EnvelopeError::Validation(format!("Invalid amount: {}", e)))?;
81
82            let cadence = parse_cadence(&cadence, days, date.as_deref())?;
83
84            let budget_service = BudgetService::new(storage);
85            let target = budget_service.set_target(cat.id, amount, cadence.clone())?;
86
87            println!(
88                "Set target for '{}': {} {}",
89                cat.name, target.amount, cadence
90            );
91
92            // Show what the suggested amount would be for the current period
93            let current_period = period_service.current_period();
94            let suggested = target.calculate_for_period(&current_period);
95            println!(
96                "  Suggested for {}: {}",
97                period_service.format_period_friendly(&current_period),
98                suggested
99            );
100        }
101
102        TargetCommands::List => {
103            let budget_service = BudgetService::new(storage);
104            let category_service = CategoryService::new(storage);
105            let targets = budget_service.get_all_targets()?;
106
107            if targets.is_empty() {
108                println!("No budget targets set.");
109                println!();
110                println!("Use 'envelope target set <category> <amount>' to create a target.");
111            } else {
112                println!("Budget Targets:");
113                println!("{}", "-".repeat(60));
114                println!("{:25} {:>12} {:>15}", "Category", "Amount", "Cadence");
115                println!("{}", "-".repeat(60));
116
117                let current_period = period_service.current_period();
118
119                for target in &targets {
120                    let cat_name = category_service
121                        .get_category(target.category_id)?
122                        .map(|c| c.name)
123                        .unwrap_or_else(|| "Unknown".to_string());
124
125                    let suggested = target.calculate_for_period(&current_period);
126
127                    println!(
128                        "{:25} {:>12} {:>15}",
129                        cat_name, target.amount, target.cadence
130                    );
131
132                    // If the cadence results in a different amount for the current period, show it
133                    if suggested != target.amount {
134                        println!(
135                            "{:25} {:>12} (for {})",
136                            "",
137                            suggested,
138                            period_service.format_period_friendly(&current_period)
139                        );
140                    }
141                }
142
143                println!("{}", "-".repeat(60));
144                println!("{} target(s) total", targets.len());
145            }
146        }
147
148        TargetCommands::Show { category } => {
149            let category_service = CategoryService::new(storage);
150            let cat = category_service
151                .find_category(&category)?
152                .ok_or_else(|| EnvelopeError::category_not_found(&category))?;
153
154            let budget_service = BudgetService::new(storage);
155            let target = budget_service.get_target(cat.id)?;
156
157            match target {
158                Some(t) => {
159                    println!("Target for '{}':", cat.name);
160                    println!("  Amount:  {}", t.amount);
161                    println!("  Cadence: {}", t.cadence);
162                    println!("  Active:  {}", if t.active { "Yes" } else { "No" });
163                    if !t.notes.is_empty() {
164                        println!("  Notes:   {}", t.notes);
165                    }
166                    println!("  Created: {}", t.created_at.format("%Y-%m-%d %H:%M"));
167
168                    // Show suggested amounts for a few periods
169                    println!();
170                    println!("Suggested amounts:");
171                    let current = period_service.current_period();
172                    for i in 0..3 {
173                        let period = if i == 0 {
174                            current.clone()
175                        } else {
176                            let mut p = current.clone();
177                            for _ in 0..i {
178                                p = p.next();
179                            }
180                            p
181                        };
182                        let suggested = t.calculate_for_period(&period);
183                        let label = if i == 0 { " (current)" } else { "" };
184                        println!(
185                            "  {}{}: {}",
186                            period_service.format_period_friendly(&period),
187                            label,
188                            suggested
189                        );
190                    }
191                }
192                None => {
193                    println!("No target set for '{}'.", cat.name);
194                    println!();
195                    println!(
196                        "Use 'envelope target set {} <amount>' to create one.",
197                        cat.name
198                    );
199                }
200            }
201        }
202
203        TargetCommands::Delete { category } => {
204            let category_service = CategoryService::new(storage);
205            let cat = category_service
206                .find_category(&category)?
207                .ok_or_else(|| EnvelopeError::category_not_found(&category))?;
208
209            let budget_service = BudgetService::new(storage);
210            let deleted = budget_service.remove_target(cat.id)?;
211
212            if deleted {
213                println!("Deleted target for '{}'.", cat.name);
214            } else {
215                println!("No target found for '{}'.", cat.name);
216            }
217        }
218
219        TargetCommands::AutoFill { period } => {
220            let period = period_service.parse_or_current(period.as_deref())?;
221            let friendly = period_service.format_period_friendly(&period);
222
223            let budget_service = BudgetService::new(storage);
224            let category_service = CategoryService::new(storage);
225            let allocations = budget_service.auto_fill_all_targets(&period)?;
226
227            if allocations.is_empty() {
228                println!("No targets to auto-fill for {}.", friendly);
229                println!();
230                println!("Use 'envelope target set <category> <amount>' to create targets first.");
231            } else {
232                println!("Auto-filled budgets from targets for {}:", friendly);
233                println!();
234
235                for allocation in &allocations {
236                    let cat_name = category_service
237                        .get_category(allocation.category_id)?
238                        .map(|c| c.name)
239                        .unwrap_or_else(|| "Unknown".to_string());
240
241                    println!("  {}: {}", cat_name, allocation.budgeted);
242                }
243
244                println!();
245                println!("{} category/categories updated.", allocations.len());
246
247                // Show Available to Budget
248                let atb = budget_service.get_available_to_budget(&period)?;
249                if atb.is_negative() {
250                    println!();
251                    println!(
252                        "⚠️  Warning: Overbudgeted by {}. Available to Budget: {}",
253                        atb.abs(),
254                        atb
255                    );
256                } else if atb.is_positive() {
257                    println!();
258                    println!("Available to Budget: {}", atb);
259                }
260            }
261        }
262    }
263
264    Ok(())
265}
266
267/// Parse the cadence string and optional parameters into a TargetCadence
268fn parse_cadence(
269    cadence: &str,
270    days: Option<u32>,
271    date: Option<&str>,
272) -> EnvelopeResult<TargetCadence> {
273    match cadence.to_lowercase().as_str() {
274        "weekly" => Ok(TargetCadence::Weekly),
275        "monthly" => Ok(TargetCadence::Monthly),
276        "yearly" | "annual" | "annually" => Ok(TargetCadence::Yearly),
277        "custom" => {
278            let days = days.ok_or_else(|| {
279                EnvelopeError::Validation(
280                    "Custom cadence requires --days parameter (e.g., --days 14)".to_string(),
281                )
282            })?;
283            if days == 0 {
284                return Err(EnvelopeError::Validation(
285                    "Custom interval must be at least 1 day".to_string(),
286                ));
287            }
288            Ok(TargetCadence::Custom { days })
289        }
290        "by-date" | "bydate" | "by_date" => {
291            let date_str = date.ok_or_else(|| {
292                EnvelopeError::Validation(
293                    "By-date cadence requires --date parameter (e.g., --date 2025-12-25)"
294                        .to_string(),
295                )
296            })?;
297            let target_date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d").map_err(|e| {
298                EnvelopeError::Validation(format!(
299                    "Invalid date format '{}'. Use YYYY-MM-DD: {}",
300                    date_str, e
301                ))
302            })?;
303            Ok(TargetCadence::ByDate { target_date })
304        }
305        _ => Err(EnvelopeError::Validation(format!(
306            "Unknown cadence '{}'. Valid options: weekly, monthly, yearly, custom, by-date",
307            cadence
308        ))),
309    }
310}