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(
33 &self,
34 target_edition: Edition,
35 options: MigrationOptions,
36 ) -> Result<MigrationResult> {
37 let mut result = MigrationResult::default();
38
39 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 if options.create_backup {
50 self.create_backup(&mut result).await?;
51 }
52
53 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 self.run_cargo_fix(target_edition, &options, &mut result)
62 .await?;
63
64 if options.update_manifest {
66 self.update_manifest(target_edition, &mut result).await?;
67 }
68
69 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 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 fs::create_dir_all(&backup_dir).await?;
89
90 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 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 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 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 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 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 pub async fn rollback(&self) -> Result<()> {
222 if let Some(backup_dir) = &self.backup_dir {
223 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#[derive(Debug, Clone)]
240pub struct MigrationOptions {
241 pub create_backup: bool,
243 pub check_git: bool,
245 pub update_manifest: bool,
247 pub run_tests: bool,
249 pub fix_idioms: bool,
251 pub allow_dirty: bool,
253 pub allow_staged: bool,
255 pub all_targets: bool,
257 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#[derive(Debug, Clone, Default)]
279pub struct MigrationResult {
280 pub status: MigrationStatus,
282 pub new_edition: Option<Edition>,
284 pub backup_path: Option<PathBuf>,
286 pub messages: Vec<String>,
288 pub warnings: Vec<String>,
290 pub errors: Vec<String>,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296pub enum MigrationStatus {
297 Pending,
299 AlreadyUpToDate,
301 Success,
303 PartialSuccess,
305 Failed,
307}
308
309impl Default for MigrationStatus {
310 fn default() -> Self {
311 Self::Pending
312 }
313}
314
315#[cfg(test)]
316#[allow(clippy::unwrap_used, clippy::expect_used)]
317mod tests {
318 use super::*;
319 use tempfile::TempDir;
320
321 #[test]
322 fn test_migration_options_default() {
323 let options = MigrationOptions::default();
324 assert!(options.create_backup);
325 assert!(options.check_git);
326 assert!(options.update_manifest);
327 assert!(!options.run_tests);
328 }
329
330 #[tokio::test]
331 async fn test_migrator_creation() {
332 let temp_dir = TempDir::new().unwrap();
333 let migrator =
334 EditionMigrator::new(temp_dir.path()).with_backup(temp_dir.path().join("backup"));
335
336 assert_eq!(migrator.project_path, temp_dir.path());
337 }
338}