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> {
25 let mut report = AnalysisReport {
26 target_edition,
27 total_files: 0,
28 issues: Vec::new(),
29 warnings: Vec::new(),
30 suggestions: Vec::new(),
31 };
32
33 for entry in WalkDir::new(&self.project_path)
35 .into_iter()
36 .filter_map(|e| e.ok())
37 {
38 if entry.path().extension().and_then(|s| s.to_str()) == Some("rs") {
39 report.total_files += 1;
40 }
41 }
42
43 self.check_edition_lints(target_edition, &mut report).await?;
45
46 self.check_common_patterns(target_edition, &mut report).await?;
48
49 self.add_suggestions(target_edition, &mut report);
51
52 Ok(report)
53 }
54
55 async fn check_edition_lints(&self, _edition: Edition, report: &mut AnalysisReport) -> Result<()> {
57 let cargo_path = which::which("cargo")
58 .map_err(|_| Error::rust_not_found("cargo not found"))?;
59
60 let current_edition = super::detect_edition(&self.project_path.join("Cargo.toml")).await?;
62 let lints = current_edition.migration_lints();
63
64 if lints.is_empty() {
65 return Ok(());
66 }
67
68 for lint in lints {
70 let output = Command::new(&cargo_path)
71 .current_dir(&self.project_path)
72 .args(&["clippy", "--", "-W", &lint])
73 .output()?;
74
75 let stderr = String::from_utf8_lossy(&output.stderr);
76
77 for line in stderr.lines() {
79 if line.contains("warning:") || line.contains("error:") {
80 report.issues.push(EditionIssue {
81 file: self.extract_file_from_lint(line),
82 line: self.extract_line_from_lint(line),
83 message: line.to_string(),
84 severity: if line.contains("error:") {
85 Severity::Error
86 } else {
87 Severity::Warning
88 },
89 });
90 }
91 }
92 }
93
94 Ok(())
95 }
96
97 async fn check_common_patterns(&self, target_edition: Edition, report: &mut AnalysisReport) -> Result<()> {
99 match target_edition {
100 Edition::Edition2018 => {
101 report.warnings.push(
103 "Consider removing `extern crate` declarations (except for macros)".to_string()
104 );
105 }
106 Edition::Edition2021 => {
107 report.warnings.push(
109 "Closures now capture individual fields instead of entire structs".to_string()
110 );
111 report.warnings.push(
113 "Or patterns in matches are now available".to_string()
114 );
115 }
116 Edition::Edition2024 => {
117 report.warnings.push(
119 "Edition 2024 includes improved async support and pattern matching".to_string()
120 );
121 }
122 _ => {}
123 }
124
125 Ok(())
126 }
127
128 fn add_suggestions(&self, target_edition: Edition, report: &mut AnalysisReport) {
130 report.suggestions.push(format!(
131 "Run `cargo fix --edition` to automatically fix most edition issues"
132 ));
133
134 report.suggestions.push(format!(
135 "After migration, update Cargo.toml to edition = \"{}\"",
136 target_edition.as_str()
137 ));
138
139 report.suggestions.push(
140 "Review and test your code after migration".to_string()
141 );
142
143 if target_edition >= Edition::Edition2018 {
144 report.suggestions.push(
145 "Consider using `cargo fmt` to update code style".to_string()
146 );
147 }
148 }
149
150 fn extract_file_from_lint(&self, line: &str) -> Option<String> {
151 line.split(':').next().map(|s| s.trim().to_string())
153 }
154
155 fn extract_line_from_lint(&self, line: &str) -> Option<u32> {
156 line.split(':')
158 .nth(1)
159 .and_then(|s| s.trim().parse().ok())
160 }
161}
162
163#[derive(Debug, Clone)]
165pub struct AnalysisReport {
166 pub target_edition: Edition,
168 pub total_files: usize,
170 pub issues: Vec<EditionIssue>,
172 pub warnings: Vec<String>,
174 pub suggestions: Vec<String>,
176}
177
178impl AnalysisReport {
179 pub fn is_ready_for_migration(&self) -> bool {
181 self.issues.iter().all(|i| i.severity != Severity::Error)
182 }
183
184 pub fn summary(&self) -> String {
186 format!(
187 "Analysis complete: {} files, {} issues, {} warnings",
188 self.total_files,
189 self.issues.len(),
190 self.warnings.len()
191 )
192 }
193}
194
195#[derive(Debug, Clone)]
197pub struct EditionIssue {
198 pub file: Option<String>,
200 pub line: Option<u32>,
202 pub message: String,
204 pub severity: Severity,
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum Severity {
211 Error,
213 Warning,
215 Info,
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use tempfile::TempDir;
223
224 #[tokio::test]
225 async fn test_analyzer_creation() {
226 let temp_dir = TempDir::new().unwrap();
227 let analyzer = EditionAnalyzer::new(temp_dir.path());
228
229 let manifest_content = r#"
231[package]
232name = "test"
233version = "0.1.0"
234edition = "2021"
235"#;
236
237 tokio::fs::write(temp_dir.path().join("Cargo.toml"), manifest_content)
238 .await
239 .unwrap();
240
241 let _report = analyzer.analyze(Edition::Edition2024).await;
244 }
245}