ferrous_forge/edition/
migrator.rs1use crate::{Error, Result};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use tokio::fs;
7
8use super::Edition;
9
10pub struct EditionMigrator {
12 project_path: PathBuf,
13 backup_dir: Option<PathBuf>,
14}
15
16impl EditionMigrator {
17 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 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 pub async fn migrate(&self, target_edition: Edition, options: MigrationOptions) -> Result<MigrationResult> {
33 let mut result = MigrationResult::default();
34
35 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 if options.create_backup {
46 self.create_backup(&mut result).await?;
47 }
48
49 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 self.run_cargo_fix(target_edition, &options, &mut result).await?;
58
59 if options.update_manifest {
61 self.update_manifest(target_edition, &mut result).await?;
62 }
63
64 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 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 fs::create_dir_all(&backup_dir).await?;
82
83 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 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 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 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 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 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 pub async fn rollback(&self) -> Result<()> {
203 if let Some(backup_dir) = &self.backup_dir {
204 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#[derive(Debug, Clone)]
221pub struct MigrationOptions {
222 pub create_backup: bool,
224 pub check_git: bool,
226 pub update_manifest: bool,
228 pub run_tests: bool,
230 pub fix_idioms: bool,
232 pub allow_dirty: bool,
234 pub allow_staged: bool,
236 pub all_targets: bool,
238 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#[derive(Debug, Clone, Default)]
260pub struct MigrationResult {
261 pub status: MigrationStatus,
263 pub new_edition: Option<Edition>,
265 pub backup_path: Option<PathBuf>,
267 pub messages: Vec<String>,
269 pub warnings: Vec<String>,
271 pub errors: Vec<String>,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum MigrationStatus {
278 Pending,
280 AlreadyUpToDate,
282 Success,
284 PartialSuccess,
286 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}