envelope_cli/cli/
backup.rs

1//! Backup CLI commands
2//!
3//! Implements CLI commands for backup management.
4
5use 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/// Backup subcommands
14#[derive(Subcommand)]
15pub enum BackupCommands {
16    /// Create a new backup
17    Create,
18
19    /// List all available backups
20    List {
21        /// Show detailed information
22        #[arg(short, long)]
23        verbose: bool,
24    },
25
26    /// Restore from a backup
27    Restore {
28        /// Backup filename or path (use 'latest' for most recent)
29        backup: String,
30
31        /// Skip confirmation prompt
32        #[arg(short, long)]
33        force: bool,
34    },
35
36    /// Show information about a specific backup
37    Info {
38        /// Backup filename or path
39        backup: String,
40    },
41
42    /// Delete old backups according to retention policy
43    Prune {
44        /// Skip confirmation prompt
45        #[arg(short, long)]
46        force: bool,
47    },
48}
49
50/// Handle a backup command
51pub 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            // Validate the backup first
120            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            // Create a backup of current data before restoring
146            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            // Calculate how many would be deleted
226            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
279/// Resolve a backup identifier to a full path
280fn resolve_backup_path(
281    manager: &BackupManager,
282    paths: &EnvelopePaths,
283    backup: &str,
284) -> EnvelopeResult<PathBuf> {
285    // Handle "latest" keyword
286    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    // Check if it's a full path
296    let path = PathBuf::from(backup);
297    if path.exists() {
298        return Ok(path);
299    }
300
301    // Check if it's a filename in the backup directory
302    let backup_path = paths.backup_dir().join(backup);
303    if backup_path.exists() {
304        return Ok(backup_path);
305    }
306
307    // Try adding common backup extensions
308    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
321/// Format a duration in human-readable form
322fn 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
348/// Format a file size in human-readable form
349fn 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}