ferrous_forge/edition/
migrator.rs

1//! Edition migration assistance
2
3use crate::{Error, Result};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use tokio::fs;
7
8use super::Edition;
9
10/// Edition migrator for upgrading projects
11pub struct EditionMigrator {
12    project_path: PathBuf,
13    backup_dir: Option<PathBuf>,
14}
15
16impl EditionMigrator {
17    /// Create a new edition migrator
18    pub fn new(project_path: impl AsRef<Path>) -> Self {
19        Self {
20            project_path: project_path.as_ref().to_path_buf(),
21            backup_dir: None,
22        }
23    }
24    
25    /// Set backup directory
26    pub fn with_backup(mut self, backup_dir: impl AsRef<Path>) -> Self {
27        self.backup_dir = Some(backup_dir.as_ref().to_path_buf());
28        self
29    }
30    
31    /// Migrate project to target edition
32    pub async fn migrate(&self, target_edition: Edition, options: MigrationOptions) -> Result<MigrationResult> {
33        let mut result = MigrationResult::default();
34        
35        // Check current edition
36        let manifest_path = self.project_path.join("Cargo.toml");
37        let current_edition = super::detect_edition(&manifest_path).await?;
38        
39        if current_edition >= target_edition {
40            result.status = MigrationStatus::AlreadyUpToDate;
41            return Ok(result);
42        }
43        
44        // Create backup if requested
45        if options.create_backup {
46            self.create_backup(&mut result).await?;
47        }
48        
49        // Check for uncommitted changes if strict mode
50        if options.check_git && self.has_uncommitted_changes()? {
51            return Err(Error::migration(
52                "Uncommitted changes detected. Please commit or stash changes before migration."
53            ));
54        }
55        
56        // Run cargo fix --edition
57        self.run_cargo_fix(target_edition, &options, &mut result).await?;
58        
59        // Update Cargo.toml
60        if options.update_manifest {
61            self.update_manifest(target_edition, &mut result).await?;
62        }
63        
64        // Run tests if requested
65        if options.run_tests {
66            self.run_tests(&mut result).await?;
67        }
68        
69        result.status = MigrationStatus::Success;
70        result.new_edition = Some(target_edition);
71        
72        Ok(result)
73    }
74    
75    /// Create backup of the project
76    async fn create_backup(&self, result: &mut MigrationResult) -> Result<()> {
77        let backup_dir = self.backup_dir.clone()
78            .unwrap_or_else(|| self.project_path.join(".ferrous-forge-backup"));
79        
80        // Create backup directory
81        fs::create_dir_all(&backup_dir).await?;
82        
83        // Copy important files
84        let manifest_src = self.project_path.join("Cargo.toml");
85        let manifest_dst = backup_dir.join("Cargo.toml");
86        fs::copy(&manifest_src, &manifest_dst).await?;
87        
88        result.backup_path = Some(backup_dir);
89        result.messages.push("Backup created successfully".to_string());
90        
91        Ok(())
92    }
93    
94    /// Check for uncommitted changes
95    fn has_uncommitted_changes(&self) -> Result<bool> {
96        let output = Command::new("git")
97            .current_dir(&self.project_path)
98            .args(&["status", "--porcelain"])
99            .output()?;
100        
101        Ok(!output.stdout.is_empty())
102    }
103    
104    /// Run cargo fix --edition
105    async fn run_cargo_fix(
106        &self,
107        _target_edition: Edition,
108        options: &MigrationOptions,
109        result: &mut MigrationResult,
110    ) -> Result<()> {
111        let cargo_path = which::which("cargo")
112            .map_err(|_| Error::rust_not_found("cargo not found"))?;
113        
114        let mut args = vec!["fix", "--edition"];
115        
116        if options.fix_idioms {
117            args.push("--edition-idioms");
118        }
119        
120        if options.allow_dirty {
121            args.push("--allow-dirty");
122        }
123        
124        if options.allow_staged {
125            args.push("--allow-staged");
126        }
127        
128        if options.all_targets {
129            args.push("--all-targets");
130        }
131        
132        let output = Command::new(cargo_path)
133            .current_dir(&self.project_path)
134            .args(&args)
135            .output()?;
136        
137        if !output.status.success() {
138            let stderr = String::from_utf8_lossy(&output.stderr);
139            result.errors.push(format!("cargo fix failed: {}", stderr));
140            result.status = MigrationStatus::PartialSuccess;
141            
142            if !options.continue_on_error {
143                return Err(Error::migration(format!("cargo fix failed: {}", stderr)));
144            }
145        } else {
146            result.messages.push("cargo fix completed successfully".to_string());
147        }
148        
149        let stdout = String::from_utf8_lossy(&output.stdout);
150        result.messages.push(stdout.to_string());
151        
152        Ok(())
153    }
154    
155    /// Update Cargo.toml with new edition
156    async fn update_manifest(&self, target_edition: Edition, result: &mut MigrationResult) -> Result<()> {
157        let manifest_path = self.project_path.join("Cargo.toml");
158        let contents = fs::read_to_string(&manifest_path).await?;
159        
160        let mut manifest: toml::Value = toml::from_str(&contents)?;
161        
162        // Update edition field
163        if let Some(package) = manifest.get_mut("package") {
164            if let Some(table) = package.as_table_mut() {
165                table.insert(
166                    "edition".to_string(),
167                    toml::Value::String(target_edition.as_str().to_string()),
168                );
169            }
170        }
171        
172        let new_contents = toml::to_string_pretty(&manifest)
173            .map_err(|e| Error::parse(format!("Failed to serialize TOML: {}", e)))?;
174        fs::write(&manifest_path, new_contents).await?;
175        
176        result.messages.push(format!(
177            "Updated Cargo.toml to edition {}",
178            target_edition.as_str()
179        ));
180        
181        Ok(())
182    }
183    
184    /// Run tests after migration
185    async fn run_tests(&self, result: &mut MigrationResult) -> Result<()> {
186        let output = Command::new("cargo")
187            .current_dir(&self.project_path)
188            .arg("test")
189            .output()?;
190        
191        if !output.status.success() {
192            result.warnings.push("Tests failed after migration".to_string());
193            result.status = MigrationStatus::PartialSuccess;
194        } else {
195            result.messages.push("All tests passed after migration".to_string());
196        }
197        
198        Ok(())
199    }
200    
201    /// Rollback migration using backup
202    pub async fn rollback(&self) -> Result<()> {
203        if let Some(backup_dir) = &self.backup_dir {
204            // Restore Cargo.toml
205            let backup_manifest = backup_dir.join("Cargo.toml");
206            let project_manifest = self.project_path.join("Cargo.toml");
207            
208            if backup_manifest.exists() {
209                fs::copy(&backup_manifest, &project_manifest).await?;
210            }
211            
212            Ok(())
213        } else {
214            Err(Error::migration("No backup available for rollback"))
215        }
216    }
217}
218
219/// Migration options
220#[derive(Debug, Clone)]
221pub struct MigrationOptions {
222    /// Create backup before migration
223    pub create_backup: bool,
224    /// Check for uncommitted git changes
225    pub check_git: bool,
226    /// Update Cargo.toml
227    pub update_manifest: bool,
228    /// Run tests after migration
229    pub run_tests: bool,
230    /// Apply edition idioms
231    pub fix_idioms: bool,
232    /// Allow migration with dirty working directory
233    pub allow_dirty: bool,
234    /// Allow migration with staged changes
235    pub allow_staged: bool,
236    /// Fix all targets
237    pub all_targets: bool,
238    /// Continue on error
239    pub continue_on_error: bool,
240}
241
242impl Default for MigrationOptions {
243    fn default() -> Self {
244        Self {
245            create_backup: true,
246            check_git: true,
247            update_manifest: true,
248            run_tests: false,
249            fix_idioms: false,
250            allow_dirty: false,
251            allow_staged: false,
252            all_targets: true,
253            continue_on_error: false,
254        }
255    }
256}
257
258/// Migration result
259#[derive(Debug, Clone, Default)]
260pub struct MigrationResult {
261    /// Migration status
262    pub status: MigrationStatus,
263    /// New edition after migration
264    pub new_edition: Option<Edition>,
265    /// Backup path if created
266    pub backup_path: Option<PathBuf>,
267    /// Messages from the migration
268    pub messages: Vec<String>,
269    /// Warnings
270    pub warnings: Vec<String>,
271    /// Errors
272    pub errors: Vec<String>,
273}
274
275/// Migration status
276#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum MigrationStatus {
278    /// Not started
279    Pending,
280    /// Already on target edition
281    AlreadyUpToDate,
282    /// Migration successful
283    Success,
284    /// Partially successful (with warnings)
285    PartialSuccess,
286    /// Migration failed
287    Failed,
288}
289
290impl Default for MigrationStatus {
291    fn default() -> Self {
292        Self::Pending
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use tempfile::TempDir;
300    
301    #[test]
302    fn test_migration_options_default() {
303        let options = MigrationOptions::default();
304        assert!(options.create_backup);
305        assert!(options.check_git);
306        assert!(options.update_manifest);
307        assert!(!options.run_tests);
308    }
309    
310    #[tokio::test]
311    async fn test_migrator_creation() {
312        let temp_dir = TempDir::new().unwrap();
313        let migrator = EditionMigrator::new(temp_dir.path())
314            .with_backup(temp_dir.path().join("backup"));
315        
316        assert_eq!(migrator.project_path, temp_dir.path());
317    }
318}