1use clap::Subcommand;
6use std::path::PathBuf;
7
8use crate::backup::{BackupManager, RestoreManager};
9use crate::config::paths::EnvelopePaths;
10use crate::config::settings::Settings;
11use crate::error::EnvelopeResult;
12
13#[derive(Subcommand)]
15pub enum BackupCommands {
16 Create,
18
19 List {
21 #[arg(short, long)]
23 verbose: bool,
24 },
25
26 Restore {
28 backup: String,
30
31 #[arg(short, long)]
33 force: bool,
34 },
35
36 Info {
38 backup: String,
40 },
41
42 Prune {
44 #[arg(short, long)]
46 force: bool,
47 },
48}
49
50pub fn handle_backup_command(
52 paths: &EnvelopePaths,
53 settings: &Settings,
54 cmd: BackupCommands,
55) -> EnvelopeResult<()> {
56 let retention = settings.backup_retention.clone();
57 let manager = BackupManager::new(paths.clone(), retention);
58
59 match cmd {
60 BackupCommands::Create => {
61 println!("Creating backup...");
62 let backup_path = manager.create_backup()?;
63 let filename = backup_path
64 .file_name()
65 .map(|s| s.to_string_lossy().to_string())
66 .unwrap_or_else(|| backup_path.display().to_string());
67 println!("Backup created: {}", filename);
68 println!("Location: {}", backup_path.display());
69 }
70
71 BackupCommands::List { verbose } => {
72 let backups = manager.list_backups()?;
73
74 if backups.is_empty() {
75 println!("No backups found.");
76 println!("Create one with: envelope backup create");
77 return Ok(());
78 }
79
80 println!("Available Backups");
81 println!("=================");
82 println!();
83
84 for (i, backup) in backups.iter().enumerate() {
85 let age = chrono::Utc::now().signed_duration_since(backup.created_at);
86 let age_str = format_duration(age);
87
88 let monthly_marker = if backup.is_monthly { " [monthly]" } else { "" };
89
90 if verbose {
91 println!(
92 "{}. {}{}\n Created: {}\n Size: {}\n Age: {}\n",
93 i + 1,
94 backup.filename,
95 monthly_marker,
96 backup.created_at.format("%Y-%m-%d %H:%M:%S UTC"),
97 format_size(backup.size_bytes),
98 age_str,
99 );
100 } else {
101 println!(
102 " {}. {} ({} ago, {}){}",
103 i + 1,
104 backup.filename,
105 age_str,
106 format_size(backup.size_bytes),
107 monthly_marker,
108 );
109 }
110 }
111
112 println!();
113 println!("Total: {} backup(s)", backups.len());
114 }
115
116 BackupCommands::Restore { backup, force } => {
117 let backup_path = resolve_backup_path(&manager, paths, &backup)?;
118
119 let restore_manager = RestoreManager::new(paths.clone());
121 let validation = restore_manager.validate_backup(&backup_path)?;
122
123 println!("Backup Information");
124 println!("==================");
125 println!("File: {}", backup_path.display());
126 println!(
127 "Created: {}",
128 validation.backup_date.format("%Y-%m-%d %H:%M:%S UTC")
129 );
130 if let Some(ref export_ver) = validation.export_schema_version {
131 println!("Format: Export (v{})", export_ver);
132 } else {
133 println!("Format: Backup (v{})", validation.schema_version);
134 }
135 println!("Status: {}", validation.summary());
136 println!();
137
138 if !force {
139 println!("WARNING: This will overwrite ALL current data!");
140 println!("To proceed, run again with --force flag:");
141 println!(" envelope backup restore {} --force", backup);
142 return Ok(());
143 }
144
145 println!("Creating backup of current data before restore...");
147 let pre_restore_backup = manager.create_backup()?;
148 println!(
149 "Pre-restore backup saved: {}",
150 pre_restore_backup.file_name().unwrap().to_string_lossy()
151 );
152 println!();
153
154 println!("Restoring from backup...");
155 let result = restore_manager.restore_from_file(&backup_path)?;
156
157 println!("Restore complete!");
158 println!("{}", result.summary());
159
160 if result.all_restored() {
161 println!("\nAll data has been restored successfully.");
162 } else {
163 println!("\nNote: Some data may not have been present in the backup.");
164 }
165 }
166
167 BackupCommands::Info { backup } => {
168 let backup_path = resolve_backup_path(&manager, paths, &backup)?;
169
170 let restore_manager = RestoreManager::new(paths.clone());
171 let validation = restore_manager.validate_backup(&backup_path)?;
172
173 let metadata = std::fs::metadata(&backup_path)?;
174
175 println!("Backup Details");
176 println!("==============");
177 println!("File: {}", backup_path.display());
178 println!("Size: {}", format_size(metadata.len()));
179 println!(
180 "Created: {}",
181 validation.backup_date.format("%Y-%m-%d %H:%M:%S UTC")
182 );
183 if let Some(ref export_ver) = validation.export_schema_version {
184 println!("Format: Export (v{})", export_ver);
185 } else {
186 println!("Format: Backup (v{})", validation.schema_version);
187 }
188 println!();
189 println!("Contents:");
190 println!(
191 " Accounts: {}",
192 if validation.has_accounts { "Yes" } else { "No" }
193 );
194 println!(
195 " Transactions: {}",
196 if validation.has_transactions {
197 "Yes"
198 } else {
199 "No"
200 }
201 );
202 println!(
203 " Budget: {}",
204 if validation.has_budget { "Yes" } else { "No" }
205 );
206 println!(
207 " Payees: {}",
208 if validation.has_payees { "Yes" } else { "No" }
209 );
210 println!();
211 println!(
212 "Status: {}",
213 if validation.is_complete() {
214 "Complete"
215 } else {
216 "Partial"
217 }
218 );
219 }
220
221 BackupCommands::Prune { force } => {
222 let backups = manager.list_backups()?;
223 let retention = settings.backup_retention.clone();
224
225 let (monthly, daily): (Vec<_>, Vec<_>) = backups.iter().partition(|b| b.is_monthly);
227
228 let daily_to_delete = daily.len().saturating_sub(retention.daily_count as usize);
229 let monthly_to_delete = monthly
230 .len()
231 .saturating_sub(retention.monthly_count as usize);
232 let total_to_delete = daily_to_delete + monthly_to_delete;
233
234 if total_to_delete == 0 {
235 println!("No backups to prune.");
236 println!(
237 "Current retention policy: {} daily, {} monthly",
238 retention.daily_count, retention.monthly_count
239 );
240 println!(
241 "You have {} daily and {} monthly backups.",
242 daily.len(),
243 monthly.len()
244 );
245 return Ok(());
246 }
247
248 println!("Prune Summary");
249 println!("=============");
250 println!(
251 "Retention policy: {} daily, {} monthly",
252 retention.daily_count, retention.monthly_count
253 );
254 println!(
255 "Current backups: {} daily, {} monthly",
256 daily.len(),
257 monthly.len()
258 );
259 println!(
260 "To be deleted: {} daily, {} monthly ({} total)",
261 daily_to_delete, monthly_to_delete, total_to_delete
262 );
263 println!();
264
265 if !force {
266 println!("To delete old backups, run again with --force flag:");
267 println!(" envelope backup prune --force");
268 return Ok(());
269 }
270
271 let deleted = manager.enforce_retention()?;
272 println!("Deleted {} backup(s).", deleted.len());
273 }
274 }
275
276 Ok(())
277}
278
279fn resolve_backup_path(
281 manager: &BackupManager,
282 paths: &EnvelopePaths,
283 backup: &str,
284) -> EnvelopeResult<PathBuf> {
285 if backup.eq_ignore_ascii_case("latest") {
287 return manager.get_latest_backup()?.map(|b| b.path).ok_or_else(|| {
288 crate::error::EnvelopeError::NotFound {
289 entity_type: "Backup",
290 identifier: "latest".to_string(),
291 }
292 });
293 }
294
295 let path = PathBuf::from(backup);
297 if path.exists() {
298 return Ok(path);
299 }
300
301 let backup_path = paths.backup_dir().join(backup);
303 if backup_path.exists() {
304 return Ok(backup_path);
305 }
306
307 for ext in &["json", "yaml", "yml"] {
309 let with_ext = paths.backup_dir().join(format!("{}.{}", backup, ext));
310 if with_ext.exists() {
311 return Ok(with_ext);
312 }
313 }
314
315 Err(crate::error::EnvelopeError::NotFound {
316 entity_type: "Backup",
317 identifier: backup.to_string(),
318 })
319}
320
321fn format_duration(duration: chrono::Duration) -> String {
323 let total_seconds = duration.num_seconds();
324
325 if total_seconds < 60 {
326 return format!("{}s", total_seconds);
327 }
328
329 let minutes = total_seconds / 60;
330 if minutes < 60 {
331 return format!("{}m", minutes);
332 }
333
334 let hours = minutes / 60;
335 if hours < 24 {
336 return format!("{}h", hours);
337 }
338
339 let days = hours / 24;
340 if days < 30 {
341 return format!("{}d", days);
342 }
343
344 let months = days / 30;
345 format!("{}mo", months)
346}
347
348fn format_size(bytes: u64) -> String {
350 const KB: u64 = 1024;
351 const MB: u64 = KB * 1024;
352 const GB: u64 = MB * 1024;
353
354 if bytes >= GB {
355 format!("{:.1} GB", bytes as f64 / GB as f64)
356 } else if bytes >= MB {
357 format!("{:.1} MB", bytes as f64 / MB as f64)
358 } else if bytes >= KB {
359 format!("{:.1} KB", bytes as f64 / KB as f64)
360 } else {
361 format!("{} B", bytes)
362 }
363}