ferrous_forge/edition/
analyzer.rs1use crate::{Error, Result};
4use std::path::Path;
5use std::process::Command;
6use walkdir::WalkDir;
7
8use super::Edition;
9
10pub struct EditionAnalyzer {
12 project_path: std::path::PathBuf,
13}
14
15impl EditionAnalyzer {
16 pub fn new(project_path: impl AsRef<Path>) -> Self {
18 Self {
19 project_path: project_path.as_ref().to_path_buf(),
20 }
21 }
22
23 pub async fn analyze(&self, target_edition: Edition) -> Result<AnalysisReport> {
30 let mut report = AnalysisReport {
31 target_edition,
32 total_files: 0,
33 issues: Vec::new(),
34 warnings: Vec::new(),
35 suggestions: Vec::new(),
36 };
37
38 for entry in WalkDir::new(&self.project_path)
40 .into_iter()
41 .filter_map(|e| e.ok())
42 {
43 if entry.path().extension().and_then(|s| s.to_str()) == Some("rs") {
44 report.total_files += 1;
45 }
46 }
47
48 self.check_edition_lints(target_edition, &mut report)
50 .await?;
51
52 self.check_common_patterns(target_edition, &mut report)
54 .await?;
55
56 self.add_suggestions(target_edition, &mut report);
58
59 Ok(report)
60 }
61
62 async fn check_edition_lints(
64 &self,
65 _edition: Edition,
66 report: &mut AnalysisReport,
67 ) -> Result<()> {
68 let cargo_path =
69 which::which("cargo").map_err(|_| Error::rust_not_found("cargo not found"))?;
70
71 let current_edition = super::detect_edition(&self.project_path.join("Cargo.toml")).await?;
73 let lints = current_edition.migration_lints();
74
75 if lints.is_empty() {
76 return Ok(());
77 }
78
79 for lint in lints {
81 let output = Command::new(&cargo_path)
82 .current_dir(&self.project_path)
83 .args(&["clippy", "--", "-W", &lint])
84 .output()?;
85
86 let stderr = String::from_utf8_lossy(&output.stderr);
87
88 for line in stderr.lines() {
90 if line.contains("warning:") || line.contains("error:") {
91 report.issues.push(EditionIssue {
92 file: self.extract_file_from_lint(line),
93 line: self.extract_line_from_lint(line),
94 message: line.to_string(),
95 severity: if line.contains("error:") {
96 Severity::Error
97 } else {
98 Severity::Warning
99 },
100 });
101 }
102 }
103 }
104
105 Ok(())
106 }
107
108 async fn check_common_patterns(
110 &self,
111 target_edition: Edition,
112 report: &mut AnalysisReport,
113 ) -> Result<()> {
114 match target_edition {
115 Edition::Edition2018 => {
116 report.warnings.push(
118 "Consider removing `extern crate` declarations (except for macros)".to_string(),
119 );
120 }
121 Edition::Edition2021 => {
122 report.warnings.push(
124 "Closures now capture individual fields instead of entire structs".to_string(),
125 );
126 report
128 .warnings
129 .push("Or patterns in matches are now available".to_string());
130 }
131 Edition::Edition2024 => {
132 report.warnings.push(
134 "Edition 2024 includes improved async support and pattern matching".to_string(),
135 );
136 }
137 _ => {}
138 }
139
140 Ok(())
141 }
142
143 fn add_suggestions(&self, target_edition: Edition, report: &mut AnalysisReport) {
145 report.suggestions.push(format!(
146 "Run `cargo fix --edition` to automatically fix most edition issues"
147 ));
148
149 report.suggestions.push(format!(
150 "After migration, update Cargo.toml to edition = \"{}\"",
151 target_edition.as_str()
152 ));
153
154 report
155 .suggestions
156 .push("Review and test your code after migration".to_string());
157
158 if target_edition >= Edition::Edition2018 {
159 report
160 .suggestions
161 .push("Consider using `cargo fmt` to update code style".to_string());
162 }
163 }
164
165 fn extract_file_from_lint(&self, line: &str) -> Option<String> {
166 line.split(':').next().map(|s| s.trim().to_string())
168 }
169
170 fn extract_line_from_lint(&self, line: &str) -> Option<u32> {
171 line.split(':').nth(1).and_then(|s| s.trim().parse().ok())
173 }
174}
175
176#[derive(Debug, Clone)]
178pub struct AnalysisReport {
179 pub target_edition: Edition,
181 pub total_files: usize,
183 pub issues: Vec<EditionIssue>,
185 pub warnings: Vec<String>,
187 pub suggestions: Vec<String>,
189}
190
191impl AnalysisReport {
192 pub fn is_ready_for_migration(&self) -> bool {
194 self.issues.iter().all(|i| i.severity != Severity::Error)
195 }
196
197 pub fn summary(&self) -> String {
199 format!(
200 "Analysis complete: {} files, {} issues, {} warnings",
201 self.total_files,
202 self.issues.len(),
203 self.warnings.len()
204 )
205 }
206}
207
208#[derive(Debug, Clone)]
210pub struct EditionIssue {
211 pub file: Option<String>,
213 pub line: Option<u32>,
215 pub message: String,
217 pub severity: Severity,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum Severity {
224 Error,
226 Warning,
228 Info,
230}
231
232#[cfg(test)]
233#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
234mod tests {
235 use super::*;
236 use tempfile::TempDir;
237
238 #[tokio::test]
239 async fn test_analyzer_creation() {
240 let temp_dir = TempDir::new().unwrap();
241 let analyzer = EditionAnalyzer::new(temp_dir.path());
242
243 let manifest_content = r#"
245[package]
246name = "test"
247version = "0.1.0"
248edition = "2021"
249"#;
250
251 tokio::fs::write(temp_dir.path().join("Cargo.toml"), manifest_content)
252 .await
253 .unwrap();
254
255 let _report = analyzer.analyze(Edition::Edition2024).await;
258 }
259}