agcodex_persistence/
migration.rs1use crate::AGCX_MAGIC;
4use crate::FORMAT_VERSION;
5use crate::error::PersistenceError;
6use crate::error::Result;
7use std::fs::File;
8use std::fs::{self};
9use std::io::Read;
10use std::path::Path;
11use std::path::PathBuf;
12use tracing::info;
13use tracing::warn;
14
15pub struct MigrationManager {
17 base_path: PathBuf,
18}
19
20impl MigrationManager {
21 pub const fn new(base_path: PathBuf) -> Self {
23 Self { base_path }
24 }
25
26 pub fn check_migration_needed(&self) -> Result<Option<MigrationPlan>> {
28 let old_agcodex_path = self
30 .base_path
31 .parent()
32 .and_then(|p| p.parent())
33 .map(|p| p.join(".codex"));
34
35 if let Some(ref path) = old_agcodex_path
36 && path.exists()
37 {
38 info!("Found old Codex data at {:?}", path);
39 return Ok(Some(MigrationPlan::FromCodex {
40 source_path: path.clone(),
41 }));
42 }
43
44 if let Ok(entries) = fs::read_dir(&self.base_path) {
46 for entry in entries.flatten() {
47 let path = entry.path();
48 if path.extension().and_then(|ext| ext.to_str()) == Some("agcx")
49 && let Ok(version) = self.read_file_version(&path)
50 && version != FORMAT_VERSION
51 {
52 return Ok(Some(MigrationPlan::VersionUpgrade {
53 from_version: version,
54 to_version: FORMAT_VERSION,
55 }));
56 }
57 }
58 }
59
60 Ok(None)
61 }
62
63 pub async fn migrate(&self, plan: MigrationPlan) -> Result<MigrationReport> {
65 match plan {
66 MigrationPlan::FromCodex { source_path } => self.migrate_from_codex(&source_path).await,
67 MigrationPlan::VersionUpgrade {
68 from_version,
69 to_version,
70 } => self.migrate_version(from_version, to_version).await,
71 }
72 }
73
74 async fn migrate_from_codex(&self, source_path: &Path) -> Result<MigrationReport> {
76 info!("Starting migration from Codex to AGCodex");
77
78 let mut report = MigrationReport::new();
79
80 let backup_path = self.base_path.join("migration_backup");
82 fs::create_dir_all(&backup_path)?;
83
84 let conversations_path = source_path.join("conversations");
89 if conversations_path.exists() {
90 let entries = fs::read_dir(&conversations_path)?;
91
92 for entry in entries.flatten() {
93 let path = entry.path();
94 if path.is_file() {
95 match self.convert_agcodex_session(&path).await {
96 Ok(session_id) => {
97 report.sessions_migrated += 1;
98 info!("Migrated session: {}", session_id);
99 }
100 Err(e) => {
101 report.sessions_failed += 1;
102 report
103 .errors
104 .push(format!("Failed to migrate {:?}: {}", path, e));
105 warn!("Failed to migrate session from {:?}: {}", path, e);
106 }
107 }
108 }
109 }
110 }
111
112 report.success = report.sessions_failed == 0;
113 Ok(report)
114 }
115
116 async fn convert_agcodex_session(&self, _path: &Path) -> Result<uuid::Uuid> {
118 Ok(uuid::Uuid::new_v4())
126 }
127
128 async fn migrate_version(&self, from_version: u16, to_version: u16) -> Result<MigrationReport> {
130 info!("Migrating from version {} to {}", from_version, to_version);
131
132 let mut report = MigrationReport::new();
133
134 let backup_path = self.base_path.join(format!("backup_v{}", from_version));
136 fs::create_dir_all(&backup_path)?;
137
138 let entries = fs::read_dir(&self.base_path)?;
140
141 for entry in entries.flatten() {
142 let path = entry.path();
143
144 if path.extension().and_then(|ext| ext.to_str()) == Some("agcx") {
145 let file_name = path.file_name().unwrap();
147 let backup_file = backup_path.join(file_name);
148 fs::copy(&path, &backup_file)?;
149
150 match self.migrate_file_version(&path, from_version, to_version) {
152 Ok(_) => {
153 report.sessions_migrated += 1;
154 info!("Migrated {:?}", path);
155 }
156 Err(e) => {
157 report.sessions_failed += 1;
158 report
159 .errors
160 .push(format!("Failed to migrate {:?}: {}", path, e));
161 warn!("Failed to migrate {:?}: {}", path, e);
162 }
163 }
164 }
165 }
166
167 report.success = report.sessions_failed == 0;
168 Ok(report)
169 }
170
171 fn migrate_file_version(
173 &self,
174 _path: &Path,
175 from_version: u16,
176 _to_version: u16,
177 ) -> Result<()> {
178 match from_version {
180 0 => {
181 warn!("Migration from version 0 not yet implemented");
184 Ok(())
185 }
186 _ => {
187 Err(PersistenceError::MigrationRequired(
189 from_version,
190 FORMAT_VERSION,
191 ))
192 }
193 }
194 }
195
196 fn read_file_version(&self, path: &Path) -> Result<u16> {
198 let mut file = File::open(path)?;
199 let mut buffer = [0u8; 6];
200 file.read_exact(&mut buffer)?;
201
202 if &buffer[0..4] != AGCX_MAGIC {
204 return Err(PersistenceError::InvalidMagic);
205 }
206
207 Ok(u16::from_le_bytes([buffer[4], buffer[5]]))
209 }
210
211 pub fn create_backup(&self, name: &str) -> Result<PathBuf> {
213 let backup_path = self.base_path.join(format!("backups/{}", name));
214 fs::create_dir_all(&backup_path)?;
215
216 let entries = fs::read_dir(&self.base_path)?;
218
219 for entry in entries.flatten() {
220 let path = entry.path();
221 if path.is_file() {
222 let file_name = path.file_name().unwrap();
223 let backup_file = backup_path.join(file_name);
224 fs::copy(&path, &backup_file)?;
225 }
226 }
227
228 info!("Created backup at {:?}", backup_path);
229 Ok(backup_path)
230 }
231
232 pub fn restore_backup(&self, backup_path: &Path) -> Result<()> {
234 if !backup_path.exists() {
235 return Err(PersistenceError::PathNotFound(
236 backup_path.to_string_lossy().to_string(),
237 ));
238 }
239
240 let entries = fs::read_dir(&self.base_path)?;
242 for entry in entries.flatten() {
243 let path = entry.path();
244 if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("agcx") {
245 fs::remove_file(&path)?;
246 }
247 }
248
249 let backup_entries = fs::read_dir(backup_path)?;
251 for entry in backup_entries.flatten() {
252 let source = entry.path();
253 if source.is_file() {
254 let file_name = source.file_name().unwrap();
255 let dest = self.base_path.join(file_name);
256 fs::copy(&source, &dest)?;
257 }
258 }
259
260 info!("Restored from backup at {:?}", backup_path);
261 Ok(())
262 }
263}
264
265#[derive(Debug, Clone)]
267pub enum MigrationPlan {
268 FromCodex { source_path: PathBuf },
270 VersionUpgrade { from_version: u16, to_version: u16 },
272}
273
274#[derive(Debug, Clone, Default)]
276pub struct MigrationReport {
277 pub success: bool,
278 pub sessions_migrated: usize,
279 pub sessions_failed: usize,
280 pub errors: Vec<String>,
281 pub backup_path: Option<PathBuf>,
282}
283
284impl MigrationReport {
285 fn new() -> Self {
286 Self::default()
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use tempfile::TempDir;
294
295 #[test]
296 fn test_check_migration_needed() {
297 let temp_dir = TempDir::new().unwrap();
298 let manager = MigrationManager::new(temp_dir.path().to_path_buf());
299
300 let result = manager.check_migration_needed().unwrap();
302 assert!(result.is_none());
303 }
304
305 #[test]
306 fn test_backup_and_restore() {
307 let temp_dir = TempDir::new().unwrap();
308 let base_path = temp_dir.path().to_path_buf();
309 let manager = MigrationManager::new(base_path.clone());
310
311 let test_file = base_path.join("test.agcx");
313 use std::io::Write;
314 File::create(&test_file)
315 .unwrap()
316 .write_all(b"test data")
317 .unwrap();
318
319 let backup_path = manager.create_backup("test_backup").unwrap();
321 assert!(backup_path.exists());
322
323 fs::remove_file(&test_file).unwrap();
325 assert!(!test_file.exists());
326
327 manager.restore_backup(&backup_path).unwrap();
329 assert!(test_file.exists());
330
331 let mut content = String::new();
333 File::open(&test_file)
334 .unwrap()
335 .read_to_string(&mut content)
336 .unwrap();
337 assert_eq!(content, "test data");
338 }
339}