Skip to main content

vtcode_core/terminal_setup/
backup.rs

1//! Configuration backup and restore system.
2//!
3//! Provides timestamped backups with retention policies to safely modify terminal configs.
4
5use crate::utils::file_utils::ensure_dir_exists_sync;
6use anyhow::{Context, Result};
7use chrono::Local;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use super::detector::TerminalType;
12
13/// Maximum number of backups to retain per config file
14const MAX_BACKUPS: usize = 5;
15
16/// Manages configuration file backups for terminal setup
17pub struct ConfigBackupManager {
18    #[expect(dead_code)]
19    terminal_type: TerminalType,
20}
21
22impl ConfigBackupManager {
23    /// Create a new backup manager for the given terminal type
24    pub fn new(terminal_type: TerminalType) -> Self {
25        Self { terminal_type }
26    }
27
28    /// Create a timestamped backup of the config file
29    ///
30    /// Returns the path to the created backup file
31    pub fn backup_config(&self, config_path: &Path) -> Result<PathBuf> {
32        // Check if config file exists
33        if !config_path.exists() {
34            anyhow::bail!("Config file does not exist: {}", config_path.display());
35        }
36
37        // Generate backup path with timestamp
38        let backup_path = self.generate_backup_path(config_path)?;
39
40        // Create parent directory if needed
41        if let Some(parent) = backup_path.parent() {
42            ensure_dir_exists_sync(parent).with_context(|| {
43                format!("Failed to create backup directory: {}", parent.display())
44            })?;
45        }
46
47        // Copy config to backup
48        fs::copy(config_path, &backup_path).with_context(|| {
49            format!(
50                "Failed to backup config from {} to {}",
51                config_path.display(),
52                backup_path.display()
53            )
54        })?;
55
56        // Cleanup old backups
57        self.cleanup_old_backups(config_path)?;
58
59        Ok(backup_path)
60    }
61
62    /// Restore a config file from a backup
63    pub fn restore_backup(&self, original_path: &Path, backup_path: &Path) -> Result<()> {
64        if !backup_path.exists() {
65            anyhow::bail!("Backup file does not exist: {}", backup_path.display());
66        }
67
68        // Create parent directory if needed
69        if let Some(parent) = original_path.parent() {
70            ensure_dir_exists_sync(parent).with_context(|| {
71                format!("Failed to create config directory: {}", parent.display())
72            })?;
73        }
74
75        fs::copy(backup_path, original_path).with_context(|| {
76            format!(
77                "Failed to restore backup from {} to {}",
78                backup_path.display(),
79                original_path.display()
80            )
81        })?;
82
83        Ok(())
84    }
85
86    /// List all available backups for a config file
87    pub fn list_backups(&self, config_path: &Path) -> Result<Vec<PathBuf>> {
88        let config_name = config_path
89            .file_name()
90            .context("Invalid config path")?
91            .to_string_lossy();
92
93        let config_dir = config_path
94            .parent()
95            .context("Config file has no parent directory")?;
96
97        if !config_dir.exists() {
98            return Ok(Vec::new());
99        }
100
101        let mut backups = Vec::new();
102
103        for entry in fs::read_dir(config_dir)
104            .with_context(|| format!("Failed to read directory: {}", config_dir.display()))?
105        {
106            let entry = entry.with_context(|| "Failed to read directory entry")?;
107            let file_name = entry.file_name();
108            let file_name_str = file_name.to_string_lossy();
109
110            // Match pattern: config_name.vtcode_backup_YYYYMMDD_HHMMSS
111            if file_name_str.starts_with(&*config_name) && file_name_str.contains(".vtcode_backup_")
112            {
113                backups.push(entry.path());
114            }
115        }
116
117        // Sort by modification time (newest first)
118        backups.sort_by(|a, b| {
119            let a_meta = fs::metadata(a).ok();
120            let b_meta = fs::metadata(b).ok();
121            let a_modified = a_meta
122                .as_ref()
123                .and_then(|meta| meta.modified().ok())
124                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
125            let b_modified = b_meta
126                .as_ref()
127                .and_then(|meta| meta.modified().ok())
128                .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
129
130            match b_modified.cmp(&a_modified) {
131                std::cmp::Ordering::Equal => b
132                    .file_name()
133                    .and_then(|name| name.to_str())
134                    .cmp(&a.file_name().and_then(|name| name.to_str())),
135                other => other,
136            }
137        });
138
139        Ok(backups)
140    }
141
142    /// Clean up old backups, keeping only the most recent MAX_BACKUPS
143    pub fn cleanup_old_backups(&self, config_path: &Path) -> Result<()> {
144        let backups = self.list_backups(config_path)?;
145
146        // Remove backups beyond MAX_BACKUPS
147        for backup in backups.iter().skip(MAX_BACKUPS) {
148            fs::remove_file(backup)
149                .with_context(|| format!("Failed to remove old backup: {}", backup.display()))?;
150        }
151
152        Ok(())
153    }
154
155    /// Generate a timestamped backup path
156    fn generate_backup_path(&self, config_path: &Path) -> Result<PathBuf> {
157        let timestamp = Local::now().format("%Y%m%d_%H%M%S_%6f");
158
159        let config_name = config_path
160            .file_name()
161            .context("Invalid config path")?
162            .to_string_lossy();
163
164        let backup_name = format!("{}.vtcode_backup_{}", config_name, timestamp);
165        let parent = config_path
166            .parent()
167            .context("Config file has no parent directory")?;
168
169        let mut backup_path = parent.join(&backup_name);
170        let mut collision_suffix: u32 = 0;
171        while backup_path.exists() {
172            collision_suffix += 1;
173            backup_path = parent.join(format!("{}_{}", backup_name, collision_suffix));
174        }
175
176        Ok(backup_path)
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use std::fs;
184    use tempfile::TempDir;
185
186    #[test]
187    fn test_backup_and_restore() {
188        let temp_dir = TempDir::new().unwrap();
189        let config_path = temp_dir.path().join("test.conf");
190
191        // Create original config
192        fs::write(&config_path, "original content").unwrap();
193
194        // Create backup
195        let manager = ConfigBackupManager::new(TerminalType::Kitty);
196        let backup_path = manager.backup_config(&config_path).unwrap();
197
198        assert!(backup_path.exists());
199        assert_eq!(
200            fs::read_to_string(&backup_path).unwrap(),
201            "original content"
202        );
203
204        // Modify original
205        fs::write(&config_path, "modified content").unwrap();
206
207        // Restore from backup
208        manager.restore_backup(&config_path, &backup_path).unwrap();
209
210        assert_eq!(
211            fs::read_to_string(&config_path).unwrap(),
212            "original content"
213        );
214    }
215
216    #[test]
217    fn test_list_backups() {
218        let temp_dir = TempDir::new().unwrap();
219        let config_path = temp_dir.path().join("test.conf");
220
221        fs::write(&config_path, "content").unwrap();
222
223        let manager = ConfigBackupManager::new(TerminalType::Kitty);
224
225        // Create multiple backups
226        let backup1 = manager.backup_config(&config_path).unwrap();
227        std::thread::sleep(std::time::Duration::from_millis(10));
228        let backup2 = manager.backup_config(&config_path).unwrap();
229
230        let backups = manager.list_backups(&config_path).unwrap();
231
232        assert_eq!(backups.len(), 2);
233        // Newest should be first
234        assert_eq!(backups[0], backup2);
235        assert_eq!(backups[1], backup1);
236    }
237
238    #[test]
239    fn test_cleanup_old_backups() {
240        let temp_dir = TempDir::new().unwrap();
241        let config_path = temp_dir.path().join("test.conf");
242
243        fs::write(&config_path, "content").unwrap();
244
245        let manager = ConfigBackupManager::new(TerminalType::Kitty);
246
247        // Create more than MAX_BACKUPS
248        for _ in 0..7 {
249            manager.backup_config(&config_path).unwrap();
250            std::thread::sleep(std::time::Duration::from_millis(10));
251        }
252
253        let backups = manager.list_backups(&config_path).unwrap();
254
255        // Should only have MAX_BACKUPS (5) remaining
256        assert_eq!(backups.len(), MAX_BACKUPS);
257    }
258
259    #[test]
260    fn test_backup_nonexistent_file() {
261        let temp_dir = TempDir::new().unwrap();
262        let config_path = temp_dir.path().join("nonexistent.conf");
263
264        let manager = ConfigBackupManager::new(TerminalType::Kitty);
265        let result = manager.backup_config(&config_path);
266
267        result.unwrap_err();
268    }
269}