vtcode_core/terminal_setup/
backup.rs1use 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
13const MAX_BACKUPS: usize = 5;
15
16pub 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 pub fn new(terminal_type: TerminalType) -> Self {
28 Self { terminal_type }
29 }
30
31 pub fn backup_config(&self, config_path: &Path) -> Result<PathBuf> {
35 if !config_path.exists() {
37 anyhow::bail!("Config file does not exist: {}", config_path.display());
38 }
39
40 let backup_path = self.generate_backup_path(config_path)?;
42
43 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 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 self.cleanup_old_backups(config_path)?;
56
57 Ok(backup_path)
58 }
59
60 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 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 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 if file_name_str.starts_with(&*config_name) && file_name_str.contains(".vtcode_backup_") {
100 backups.push(entry.path());
101 }
102 }
103
104 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 pub fn cleanup_old_backups(&self, config_path: &Path) -> Result<()> {
131 let backups = self.list_backups(config_path)?;
132
133 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 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 fs::write(&config_path, "original content").unwrap();
174
175 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 fs::write(&config_path, "modified content").unwrap();
184
185 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 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 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 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 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}