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