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 println!("Schema version: {}", validation.schema_version);
131 println!("Status: {}", validation.summary());
132 println!();
133
134 if !force {
135 println!("WARNING: This will overwrite ALL current data!");
136 println!("To proceed, run again with --force flag:");
137 println!(" envelope backup restore {} --force", backup);
138 return Ok(());
139 }
140
141 println!("Creating backup of current data before restore...");
143 let pre_restore_backup = manager.create_backup()?;
144 println!(
145 "Pre-restore backup saved: {}",
146 pre_restore_backup.file_name().unwrap().to_string_lossy()
147 );
148 println!();
149
150 println!("Restoring from backup...");
151 let result = restore_manager.restore_from_file(&backup_path)?;
152
153 println!("Restore complete!");
154 println!("{}", result.summary());
155
156 if result.all_restored() {
157 println!("\nAll data has been restored successfully.");
158 } else {
159 println!("\nNote: Some data may not have been present in the backup.");
160 }
161 }
162
163 BackupCommands::Info { backup } => {
164 let backup_path = resolve_backup_path(&manager, paths, &backup)?;
165
166 let restore_manager = RestoreManager::new(paths.clone());
167 let validation = restore_manager.validate_backup(&backup_path)?;
168
169 let metadata = std::fs::metadata(&backup_path)?;
170
171 println!("Backup Details");
172 println!("==============");
173 println!("File: {}", backup_path.display());
174 println!("Size: {}", format_size(metadata.len()));
175 println!(
176 "Created: {}",
177 validation.backup_date.format("%Y-%m-%d %H:%M:%S UTC")
178 );
179 println!("Schema version: {}", validation.schema_version);
180 println!();
181 println!("Contents:");
182 println!(
183 " Accounts: {}",
184 if validation.has_accounts { "Yes" } else { "No" }
185 );
186 println!(
187 " Transactions: {}",
188 if validation.has_transactions {
189 "Yes"
190 } else {
191 "No"
192 }
193 );
194 println!(
195 " Budget: {}",
196 if validation.has_budget { "Yes" } else { "No" }
197 );
198 println!(
199 " Payees: {}",
200 if validation.has_payees { "Yes" } else { "No" }
201 );
202 println!();
203 println!(
204 "Status: {}",
205 if validation.is_complete() {
206 "Complete"
207 } else {
208 "Partial"
209 }
210 );
211 }
212
213 BackupCommands::Prune { force } => {
214 let backups = manager.list_backups()?;
215 let retention = settings.backup_retention.clone();
216
217 let (monthly, daily): (Vec<_>, Vec<_>) = backups.iter().partition(|b| b.is_monthly);
219
220 let daily_to_delete = daily.len().saturating_sub(retention.daily_count as usize);
221 let monthly_to_delete = monthly
222 .len()
223 .saturating_sub(retention.monthly_count as usize);
224 let total_to_delete = daily_to_delete + monthly_to_delete;
225
226 if total_to_delete == 0 {
227 println!("No backups to prune.");
228 println!(
229 "Current retention policy: {} daily, {} monthly",
230 retention.daily_count, retention.monthly_count
231 );
232 println!(
233 "You have {} daily and {} monthly backups.",
234 daily.len(),
235 monthly.len()
236 );
237 return Ok(());
238 }
239
240 println!("Prune Summary");
241 println!("=============");
242 println!(
243 "Retention policy: {} daily, {} monthly",
244 retention.daily_count, retention.monthly_count
245 );
246 println!(
247 "Current backups: {} daily, {} monthly",
248 daily.len(),
249 monthly.len()
250 );
251 println!(
252 "To be deleted: {} daily, {} monthly ({} total)",
253 daily_to_delete, monthly_to_delete, total_to_delete
254 );
255 println!();
256
257 if !force {
258 println!("To delete old backups, run again with --force flag:");
259 println!(" envelope backup prune --force");
260 return Ok(());
261 }
262
263 let deleted = manager.enforce_retention()?;
264 println!("Deleted {} backup(s).", deleted.len());
265 }
266 }
267
268 Ok(())
269}
270
271fn resolve_backup_path(
273 manager: &BackupManager,
274 paths: &EnvelopePaths,
275 backup: &str,
276) -> EnvelopeResult<PathBuf> {
277 if backup.eq_ignore_ascii_case("latest") {
279 return manager.get_latest_backup()?.map(|b| b.path).ok_or_else(|| {
280 crate::error::EnvelopeError::NotFound {
281 entity_type: "Backup",
282 identifier: "latest".to_string(),
283 }
284 });
285 }
286
287 let path = PathBuf::from(backup);
289 if path.exists() {
290 return Ok(path);
291 }
292
293 let backup_path = paths.backup_dir().join(backup);
295 if backup_path.exists() {
296 return Ok(backup_path);
297 }
298
299 let with_ext = paths.backup_dir().join(format!("{}.json", backup));
301 if with_ext.exists() {
302 return Ok(with_ext);
303 }
304
305 Err(crate::error::EnvelopeError::NotFound {
306 entity_type: "Backup",
307 identifier: backup.to_string(),
308 })
309}
310
311fn format_duration(duration: chrono::Duration) -> String {
313 let total_seconds = duration.num_seconds();
314
315 if total_seconds < 60 {
316 return format!("{}s", total_seconds);
317 }
318
319 let minutes = total_seconds / 60;
320 if minutes < 60 {
321 return format!("{}m", minutes);
322 }
323
324 let hours = minutes / 60;
325 if hours < 24 {
326 return format!("{}h", hours);
327 }
328
329 let days = hours / 24;
330 if days < 30 {
331 return format!("{}d", days);
332 }
333
334 let months = days / 30;
335 format!("{}mo", months)
336}
337
338fn format_size(bytes: u64) -> String {
340 const KB: u64 = 1024;
341 const MB: u64 = KB * 1024;
342 const GB: u64 = MB * 1024;
343
344 if bytes >= GB {
345 format!("{:.1} GB", bytes as f64 / GB as f64)
346 } else if bytes >= MB {
347 format!("{:.1} MB", bytes as f64 / MB as f64)
348 } else if bytes >= KB {
349 format!("{:.1} KB", bytes as f64 / KB as f64)
350 } else {
351 format!("{} B", bytes)
352 }
353}