pocket_cli/cards/
backup.rs

1//! Backup card for Pocket CLI
2//!
3//! This card provides functionality for backing up and restoring snippets and repositories.
4
5use std::path::{Path, PathBuf};
6use std::fs;
7use chrono::{DateTime, Utc};
8use anyhow::{Result, Context};
9use serde::{Serialize, Deserialize};
10
11use crate::cards::{Card, CardConfig, CardCommand};
12use crate::storage::StorageManager;
13
14/// Configuration for the backup card
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct BackupCardConfig {
17    /// Directory where backups are stored
18    pub backup_dir: PathBuf,
19    
20    /// Maximum number of backups to keep
21    pub max_backups: usize,
22    
23    /// Whether to automatically backup on exit
24    pub auto_backup: bool,
25    
26    /// Backup frequency in days (0 means no automatic backups)
27    pub backup_frequency: u32,
28    
29    /// Date of the last backup
30    pub last_backup: Option<DateTime<Utc>>,
31}
32
33impl Default for BackupCardConfig {
34    fn default() -> Self {
35        Self {
36            backup_dir: dirs::data_dir()
37                .unwrap_or_else(|| PathBuf::from("."))
38                .join("pocket")
39                .join("backups"),
40            max_backups: 5,
41            auto_backup: true,
42            backup_frequency: 1,
43            last_backup: None,
44        }
45    }
46}
47
48/// Metadata for a backup
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct BackupMetadata {
51    /// Unique identifier for the backup
52    pub id: String,
53    
54    /// Date and time when the backup was created
55    pub created_at: DateTime<Utc>,
56    
57    /// Description of the backup
58    pub description: String,
59    
60    /// Number of snippets in the backup
61    pub snippet_count: usize,
62    
63    /// Number of repositories in the backup
64    pub repository_count: usize,
65    
66    /// Size of the backup in bytes
67    pub size: u64,
68}
69
70/// Card for backing up and restoring snippets and repositories
71pub struct BackupCard {
72    /// Name of the card
73    name: String,
74    
75    /// Version of the card
76    version: String,
77    
78    /// Description of the card
79    description: String,
80    
81    /// Configuration for the card
82    config: BackupCardConfig,
83    
84    /// Path to the Pocket data directory
85    data_dir: PathBuf,
86}
87
88impl BackupCard {
89    /// Creates a new backup card
90    pub fn new(data_dir: impl AsRef<Path>) -> Self {
91        Self {
92            name: "backup".to_string(),
93            version: env!("CARGO_PKG_VERSION").to_string(),
94            description: "Provides functionality for backing up and restoring snippets and repositories".to_string(),
95            config: BackupCardConfig::default(),
96            data_dir: data_dir.as_ref().to_path_buf(),
97        }
98    }
99    
100    /// Creates a backup of the current state
101    pub fn create_backup(&self, description: &str) -> Result<BackupMetadata> {
102        // Ensure the backup directory exists
103        fs::create_dir_all(&self.config.backup_dir)
104            .context("Failed to create backup directory")?;
105        
106        // Generate a unique ID for the backup
107        let backup_id = format!("backup_{}", chrono::Utc::now().format("%Y%m%d_%H%M%S"));
108        let backup_dir = self.config.backup_dir.join(&backup_id);
109        
110        // Create the backup directory
111        fs::create_dir(&backup_dir)
112            .context("Failed to create backup directory")?;
113        
114        // Copy the data directory to the backup directory
115        self.copy_directory(&self.data_dir, &backup_dir)
116            .context("Failed to copy data directory")?;
117        
118        // Count snippets and repositories
119        let snippet_count = self.count_snippets(&backup_dir)?;
120        let repository_count = self.count_repositories(&backup_dir)?;
121        
122        // Calculate the size of the backup
123        let size = self.directory_size(&backup_dir)?;
124        
125        // Create metadata
126        let metadata = BackupMetadata {
127            id: backup_id,
128            created_at: Utc::now(),
129            description: description.to_string(),
130            snippet_count,
131            repository_count,
132            size,
133        };
134        
135        // Save metadata
136        let metadata_path = backup_dir.join("metadata.json");
137        let metadata_json = serde_json::to_string_pretty(&metadata)?;
138        fs::write(&metadata_path, metadata_json)
139            .context("Failed to write backup metadata")?;
140        
141        // Prune old backups if necessary
142        self.prune_old_backups()?;
143        
144        Ok(metadata)
145    }
146    
147    /// Restores a backup
148    pub fn restore_backup(&self, backup_id: &str) -> Result<()> {
149        let backup_dir = self.config.backup_dir.join(backup_id);
150        
151        // Check if the backup exists
152        if !backup_dir.exists() {
153            anyhow::bail!("Backup '{}' not found", backup_id);
154        }
155        
156        // Read metadata to verify it's a valid backup
157        let metadata_path = backup_dir.join("metadata.json");
158        if !metadata_path.exists() {
159            anyhow::bail!("Invalid backup: metadata.json not found");
160        }
161        
162        // Create a backup of the current state before restoring
163        let current_backup_id = format!("pre_restore_{}", chrono::Utc::now().format("%Y%m%d_%H%M%S"));
164        let current_backup_dir = self.config.backup_dir.join(&current_backup_id);
165        
166        // Create the backup directory
167        fs::create_dir(&current_backup_dir)
168            .context("Failed to create backup directory for current state")?;
169        
170        // Copy the current data directory to the backup directory
171        self.copy_directory(&self.data_dir, &current_backup_dir)
172            .context("Failed to backup current state")?;
173        
174        // Clear the current data directory
175        self.clear_directory(&self.data_dir)
176            .context("Failed to clear data directory")?;
177        
178        // Copy the backup to the data directory
179        self.copy_directory(&backup_dir, &self.data_dir)
180            .context("Failed to restore backup")?;
181        
182        Ok(())
183    }
184    
185    /// Lists all available backups
186    pub fn list_backups(&self) -> Result<Vec<BackupMetadata>> {
187        // Ensure the backup directory exists
188        if !self.config.backup_dir.exists() {
189            return Ok(Vec::new());
190        }
191        
192        let mut backups = Vec::new();
193        
194        // Iterate through all entries in the backup directory
195        for entry in fs::read_dir(&self.config.backup_dir)? {
196            let entry = entry?;
197            let path = entry.path();
198            
199            // Check if it's a directory
200            if path.is_dir() {
201                // Check if it contains a metadata.json file
202                let metadata_path = path.join("metadata.json");
203                if metadata_path.exists() {
204                    // Read and parse the metadata
205                    let metadata_json = fs::read_to_string(&metadata_path)?;
206                    let metadata: BackupMetadata = serde_json::from_str(&metadata_json)?;
207                    backups.push(metadata);
208                }
209            }
210        }
211        
212        // Sort backups by creation date (newest first)
213        backups.sort_by(|a, b| b.created_at.cmp(&a.created_at));
214        
215        Ok(backups)
216    }
217    
218    /// Deletes a backup
219    pub fn delete_backup(&self, backup_id: &str) -> Result<()> {
220        let backup_dir = self.config.backup_dir.join(backup_id);
221        
222        // Check if the backup exists
223        if !backup_dir.exists() {
224            anyhow::bail!("Backup '{}' not found", backup_id);
225        }
226        
227        // Delete the backup directory
228        fs::remove_dir_all(&backup_dir)
229            .context("Failed to delete backup")?;
230        
231        Ok(())
232    }
233    
234    /// Prunes old backups to stay within the maximum limit
235    fn prune_old_backups(&self) -> Result<()> {
236        // List all backups
237        let mut backups = self.list_backups()?;
238        
239        // If we're within the limit, do nothing
240        if backups.len() <= self.config.max_backups {
241            return Ok(());
242        }
243        
244        // Sort backups by creation date (oldest first)
245        backups.sort_by(|a, b| a.created_at.cmp(&b.created_at));
246        
247        // Delete the oldest backups until we're within the limit
248        for backup in backups.iter().take(backups.len() - self.config.max_backups) {
249            self.delete_backup(&backup.id)?;
250        }
251        
252        Ok(())
253    }
254    
255    /// Copies a directory recursively
256    fn copy_directory(&self, src: &Path, dst: &Path) -> Result<()> {
257        // Create the destination directory if it doesn't exist
258        if !dst.exists() {
259            fs::create_dir_all(dst)?;
260        }
261        
262        // Iterate through all entries in the source directory
263        for entry in walkdir::WalkDir::new(src) {
264            let entry = entry?;
265            let src_path = entry.path();
266            let rel_path = src_path.strip_prefix(src)?;
267            let dst_path = dst.join(rel_path);
268            
269            if src_path.is_dir() {
270                // Create the directory in the destination
271                fs::create_dir_all(&dst_path)?;
272            } else {
273                // Copy the file
274                fs::copy(src_path, &dst_path)?;
275            }
276        }
277        
278        Ok(())
279    }
280    
281    /// Clears a directory without deleting the directory itself
282    fn clear_directory(&self, dir: &Path) -> Result<()> {
283        // Check if the directory exists
284        if !dir.exists() {
285            return Ok(());
286        }
287        
288        // Iterate through all entries in the directory
289        for entry in fs::read_dir(dir)? {
290            let entry = entry?;
291            let path = entry.path();
292            
293            if path.is_dir() {
294                // Recursively delete the directory
295                fs::remove_dir_all(&path)?;
296            } else {
297                // Delete the file
298                fs::remove_file(&path)?;
299            }
300        }
301        
302        Ok(())
303    }
304    
305    /// Counts the number of snippets in a directory
306    fn count_snippets(&self, dir: &Path) -> Result<usize> {
307        let snippets_dir = dir.join("snippets");
308        
309        if !snippets_dir.exists() {
310            return Ok(0);
311        }
312        
313        let count = walkdir::WalkDir::new(&snippets_dir)
314            .min_depth(1)
315            .into_iter()
316            .filter_map(Result::ok)
317            .filter(|e| e.file_type().is_file() && e.path().extension().map_or(false, |ext| ext == "json"))
318            .count();
319        
320        Ok(count)
321    }
322    
323    /// Counts the number of repositories in a directory
324    fn count_repositories(&self, dir: &Path) -> Result<usize> {
325        let repos_dir = dir.join("repositories");
326        
327        if !repos_dir.exists() {
328            return Ok(0);
329        }
330        
331        let count = walkdir::WalkDir::new(&repos_dir)
332            .max_depth(1)
333            .min_depth(1)
334            .into_iter()
335            .filter_map(Result::ok)
336            .filter(|e| e.file_type().is_dir())
337            .count();
338        
339        Ok(count)
340    }
341    
342    /// Calculates the size of a directory in bytes
343    fn directory_size(&self, dir: &Path) -> Result<u64> {
344        let mut size = 0;
345        
346        for entry in walkdir::WalkDir::new(dir) {
347            let entry = entry?;
348            if entry.file_type().is_file() {
349                size += entry.metadata()?.len();
350            }
351        }
352        
353        Ok(size)
354    }
355}
356
357impl Card for BackupCard {
358    fn name(&self) -> &str {
359        &self.name
360    }
361    
362    fn version(&self) -> &str {
363        &self.version
364    }
365    
366    fn description(&self) -> &str {
367        &self.description
368    }
369    
370    fn initialize(&mut self, config: &CardConfig) -> Result<()> {
371        // Load card-specific configuration
372        if let Some(backup_config) = config.options.get("backup") {
373            if let Ok(parsed_config) = serde_json::from_value::<BackupCardConfig>(backup_config.clone()) {
374                self.config = parsed_config;
375            }
376        }
377        
378        // Ensure the backup directory exists
379        fs::create_dir_all(&self.config.backup_dir)
380            .context("Failed to create backup directory")?;
381        
382        Ok(())
383    }
384    
385    fn execute(&self, command: &str, args: &[String]) -> Result<()> {
386        match command {
387            "backup" => {
388                let description = args.get(0).map(|s| s.as_str()).unwrap_or("Manual backup");
389                let metadata = self.create_backup(description)?;
390                println!("Backup created: {}", metadata.id);
391                println!("Description: {}", metadata.description);
392                println!("Created at: {}", metadata.created_at);
393                println!("Snippets: {}", metadata.snippet_count);
394                println!("Repositories: {}", metadata.repository_count);
395                println!("Size: {} bytes", metadata.size);
396                Ok(())
397            },
398            "restore" => {
399                if args.is_empty() {
400                    anyhow::bail!("Backup ID is required");
401                }
402                let backup_id = &args[0];
403                self.restore_backup(backup_id)?;
404                println!("Backup '{}' restored successfully", backup_id);
405                Ok(())
406            },
407            "list" => {
408                let backups = self.list_backups()?;
409                if backups.is_empty() {
410                    println!("No backups found");
411                } else {
412                    println!("Available backups:");
413                    for backup in backups {
414                        println!("ID: {}", backup.id);
415                        println!("  Description: {}", backup.description);
416                        println!("  Created at: {}", backup.created_at);
417                        println!("  Snippets: {}", backup.snippet_count);
418                        println!("  Repositories: {}", backup.repository_count);
419                        println!("  Size: {} bytes", backup.size);
420                        println!();
421                    }
422                }
423                Ok(())
424            },
425            "delete" => {
426                if args.is_empty() {
427                    anyhow::bail!("Backup ID is required");
428                }
429                let backup_id = &args[0];
430                self.delete_backup(backup_id)?;
431                println!("Backup '{}' deleted successfully", backup_id);
432                Ok(())
433            },
434            _ => anyhow::bail!("Unknown command: {}", command),
435        }
436    }
437    
438    fn commands(&self) -> Vec<CardCommand> {
439        vec![
440            CardCommand {
441                name: "backup".to_string(),
442                description: "Creates a backup of the current state".to_string(),
443                usage: "pocket backup [description]".to_string(),
444            },
445            CardCommand {
446                name: "restore".to_string(),
447                description: "Restores a backup".to_string(),
448                usage: "pocket restore <backup-id>".to_string(),
449            },
450            CardCommand {
451                name: "list".to_string(),
452                description: "Lists all available backups".to_string(),
453                usage: "pocket backup list".to_string(),
454            },
455            CardCommand {
456                name: "delete".to_string(),
457                description: "Deletes a backup".to_string(),
458                usage: "pocket backup delete <backup-id>".to_string(),
459            },
460        ]
461    }
462    
463    fn cleanup(&mut self) -> Result<()> {
464        // Nothing to clean up
465        Ok(())
466    }
467}