1use 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#[derive(Subcommand)]
16pub enum TargetCommands {
17 Set {
19 category: String,
21 amount: String,
23 #[arg(short, long, default_value = "monthly")]
25 cadence: String,
26 #[arg(long)]
28 days: Option<u32>,
29 #[arg(long)]
31 date: Option<String>,
32 },
33
34 List,
36
37 Show {
39 category: String,
41 },
42
43 Delete {
45 category: String,
47 },
48
49 #[command(name = "auto-fill")]
51 AutoFill {
52 #[arg(short, long)]
54 period: Option<String>,
55 },
56}
57
58pub 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 let current_period = period_service.current_period();
94 let suggested = target.calculate_for_period(¤t_period);
95 println!(
96 " Suggested for {}: {}",
97 period_service.format_period_friendly(¤t_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(¤t_period);
126
127 println!(
128 "{:25} {:>12} {:>15}",
129 cat_name, target.amount, target.cadence
130 );
131
132 if suggested != target.amount {
134 println!(
135 "{:25} {:>12} (for {})",
136 "",
137 suggested,
138 period_service.format_period_friendly(¤t_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 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 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
267fn 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}