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(dead_code)]
19 terminal_type: TerminalType,
20}
21
22impl ConfigBackupManager {
23 pub fn new(terminal_type: TerminalType) -> Self {
25 Self { terminal_type }
26 }
27
28 pub fn backup_config(&self, config_path: &Path) -> Result<PathBuf> {
32 if !config_path.exists() {
34 anyhow::bail!("Config file does not exist: {}", config_path.display());
35 }
36
37 let backup_path = self.generate_backup_path(config_path)?;
39
40 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 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 self.cleanup_old_backups(config_path)?;
58
59 Ok(backup_path)
60 }
61
62 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 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 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 if file_name_str.starts_with(&*config_name) && file_name_str.contains(".vtcode_backup_")
112 {
113 backups.push(entry.path());
114 }
115 }
116
117 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 pub fn cleanup_old_backups(&self, config_path: &Path) -> Result<()> {
144 let backups = self.list_backups(config_path)?;
145
146 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 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 fs::write(&config_path, "original content").unwrap();
193
194 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 fs::write(&config_path, "modified content").unwrap();
206
207 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 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 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 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 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}