agcodex_persistence/
migration.rs

1//! Migration utilities for handling format changes
2
3use 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
15/// Migration manager for handling format upgrades
16pub struct MigrationManager {
17    base_path: PathBuf,
18}
19
20impl MigrationManager {
21    /// Create a new migration manager
22    pub const fn new(base_path: PathBuf) -> Self {
23        Self { base_path }
24    }
25
26    /// Check if migration is needed
27    pub fn check_migration_needed(&self) -> Result<Option<MigrationPlan>> {
28        // Check for old Codex format
29        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        // Check for version mismatch in existing AGCodex data
45        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    /// Perform migration if needed
64    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    /// Migrate from old Codex format to AGCodex
75    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        // Create backup directory
81        let backup_path = self.base_path.join("migration_backup");
82        fs::create_dir_all(&backup_path)?;
83
84        // TODO: Implement actual Codex format reading and conversion
85        // For now, we'll create a placeholder implementation
86
87        // Look for conversation files in old format
88        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    /// Convert a single Codex session to AGCodex format
117    async fn convert_agcodex_session(&self, _path: &Path) -> Result<uuid::Uuid> {
118        // TODO: Implement actual conversion logic
119        // This would involve:
120        // 1. Reading the old format
121        // 2. Converting to new types
122        // 3. Saving in AGCodex format
123
124        // Placeholder: return a new UUID
125        Ok(uuid::Uuid::new_v4())
126    }
127
128    /// Migrate between AGCodex format versions
129    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        // Create backup
135        let backup_path = self.base_path.join(format!("backup_v{}", from_version));
136        fs::create_dir_all(&backup_path)?;
137
138        // List all session files
139        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                // Backup the file
146                let file_name = path.file_name().unwrap();
147                let backup_file = backup_path.join(file_name);
148                fs::copy(&path, &backup_file)?;
149
150                // Perform version-specific migration
151                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    /// Migrate a single file between versions
172    fn migrate_file_version(
173        &self,
174        _path: &Path,
175        from_version: u16,
176        _to_version: u16,
177    ) -> Result<()> {
178        // Version-specific migration logic would go here
179        match from_version {
180            0 => {
181                // Migration from version 0 to current
182                // This would involve reading the old format and converting
183                warn!("Migration from version 0 not yet implemented");
184                Ok(())
185            }
186            _ => {
187                // Unknown version
188                Err(PersistenceError::MigrationRequired(
189                    from_version,
190                    FORMAT_VERSION,
191                ))
192            }
193        }
194    }
195
196    /// Read the version from a session file
197    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        // Check magic bytes
203        if &buffer[0..4] != AGCX_MAGIC {
204            return Err(PersistenceError::InvalidMagic);
205        }
206
207        // Read version
208        Ok(u16::from_le_bytes([buffer[4], buffer[5]]))
209    }
210
211    /// Create a backup of all current data
212    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        // Copy all session files
217        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    /// Restore from a backup
233    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        // Clear current data
241        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        // Copy backup files
250        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/// Migration plan describing what needs to be done
266#[derive(Debug, Clone)]
267pub enum MigrationPlan {
268    /// Migrate from old Codex format
269    FromCodex { source_path: PathBuf },
270    /// Upgrade between AGCodex versions
271    VersionUpgrade { from_version: u16, to_version: u16 },
272}
273
274/// Report of migration results
275#[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        // Should return None when no migration needed
301        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        // Create some test files
312        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        // Create backup
320        let backup_path = manager.create_backup("test_backup").unwrap();
321        assert!(backup_path.exists());
322
323        // Delete original file
324        fs::remove_file(&test_file).unwrap();
325        assert!(!test_file.exists());
326
327        // Restore from backup
328        manager.restore_backup(&backup_path).unwrap();
329        assert!(test_file.exists());
330
331        // Verify content
332        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}