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            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            // Create a backup of current data before restoring
142            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            // Calculate how many would be deleted
218            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
271/// Resolve a backup identifier to a full path
272fn resolve_backup_path(
273    manager: &BackupManager,
274    paths: &EnvelopePaths,
275    backup: &str,
276) -> EnvelopeResult<PathBuf> {
277    // Handle "latest" keyword
278    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    // Check if it's a full path
288    let path = PathBuf::from(backup);
289    if path.exists() {
290        return Ok(path);
291    }
292
293    // Check if it's a filename in the backup directory
294    let backup_path = paths.backup_dir().join(backup);
295    if backup_path.exists() {
296        return Ok(backup_path);
297    }
298
299    // Try adding common backup extensions
300    for ext in &["json", "yaml", "yml"] {
301        let with_ext = paths.backup_dir().join(format!("{}.{}", backup, ext));
302        if with_ext.exists() {
303            return Ok(with_ext);
304        }
305    }
306
307    Err(crate::error::EnvelopeError::NotFound {
308        entity_type: "Backup",
309        identifier: backup.to_string(),
310    })
311}
312
313/// Format a duration in human-readable form
314fn format_duration(duration: chrono::Duration) -> String {
315    let total_seconds = duration.num_seconds();
316
317    if total_seconds < 60 {
318        return format!("{}s", total_seconds);
319    }
320
321    let minutes = total_seconds / 60;
322    if minutes < 60 {
323        return format!("{}m", minutes);
324    }
325
326    let hours = minutes / 60;
327    if hours < 24 {
328        return format!("{}h", hours);
329    }
330
331    let days = hours / 24;
332    if days < 30 {
333        return format!("{}d", days);
334    }
335
336    let months = days / 30;
337    format!("{}mo", months)
338}
339
340/// Format a file size in human-readable form
341fn format_size(bytes: u64) -> String {
342    const KB: u64 = 1024;
343    const MB: u64 = KB * 1024;
344    const GB: u64 = MB * 1024;
345
346    if bytes >= GB {
347        format!("{:.1} GB", bytes as f64 / GB as f64)
348    } else if bytes >= MB {
349        format!("{:.1} MB", bytes as f64 / MB as f64)
350    } else if bytes >= KB {
351        format!("{:.1} KB", bytes as f64 / KB as f64)
352    } else {
353        format!("{} B", bytes)
354    }
355}