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