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)
45 .await?;
46
47 self.check_common_patterns(target_edition, &mut report)
49 .await?;
50
51 self.add_suggestions(target_edition, &mut report);
53
54 Ok(report)
55 }
56
57 async fn check_edition_lints(
59 &self,
60 _edition: Edition,
61 report: &mut AnalysisReport,
62 ) -> Result<()> {
63 let cargo_path =
64 which::which("cargo").map_err(|_| Error::rust_not_found("cargo not found"))?;
65
66 let current_edition = super::detect_edition(&self.project_path.join("Cargo.toml")).await?;
68 let lints = current_edition.migration_lints();
69
70 if lints.is_empty() {
71 return Ok(());
72 }
73
74 for lint in lints {
76 let output = Command::new(&cargo_path)
77 .current_dir(&self.project_path)
78 .args(&["clippy", "--", "-W", &lint])
79 .output()?;
80
81 let stderr = String::from_utf8_lossy(&output.stderr);
82
83 for line in stderr.lines() {
85 if line.contains("warning:") || line.contains("error:") {
86 report.issues.push(EditionIssue {
87 file: self.extract_file_from_lint(line),
88 line: self.extract_line_from_lint(line),
89 message: line.to_string(),
90 severity: if line.contains("error:") {
91 Severity::Error
92 } else {
93 Severity::Warning
94 },
95 });
96 }
97 }
98 }
99
100 Ok(())
101 }
102
103 async fn check_common_patterns(
105 &self,
106 target_edition: Edition,
107 report: &mut AnalysisReport,
108 ) -> Result<()> {
109 match target_edition {
110 Edition::Edition2018 => {
111 report.warnings.push(
113 "Consider removing `extern crate` declarations (except for macros)".to_string(),
114 );
115 }
116 Edition::Edition2021 => {
117 report.warnings.push(
119 "Closures now capture individual fields instead of entire structs".to_string(),
120 );
121 report
123 .warnings
124 .push("Or patterns in matches are now available".to_string());
125 }
126 Edition::Edition2024 => {
127 report.warnings.push(
129 "Edition 2024 includes improved async support and pattern matching".to_string(),
130 );
131 }
132 _ => {}
133 }
134
135 Ok(())
136 }
137
138 fn add_suggestions(&self, target_edition: Edition, report: &mut AnalysisReport) {
140 report.suggestions.push(format!(
141 "Run `cargo fix --edition` to automatically fix most edition issues"
142 ));
143
144 report.suggestions.push(format!(
145 "After migration, update Cargo.toml to edition = \"{}\"",
146 target_edition.as_str()
147 ));
148
149 report
150 .suggestions
151 .push("Review and test your code after migration".to_string());
152
153 if target_edition >= Edition::Edition2018 {
154 report
155 .suggestions
156 .push("Consider using `cargo fmt` to update code style".to_string());
157 }
158 }
159
160 fn extract_file_from_lint(&self, line: &str) -> Option<String> {
161 line.split(':').next().map(|s| s.trim().to_string())
163 }
164
165 fn extract_line_from_lint(&self, line: &str) -> Option<u32> {
166 line.split(':').nth(1).and_then(|s| s.trim().parse().ok())
168 }
169}
170
171#[derive(Debug, Clone)]
173pub struct AnalysisReport {
174 pub target_edition: Edition,
176 pub total_files: usize,
178 pub issues: Vec<EditionIssue>,
180 pub warnings: Vec<String>,
182 pub suggestions: Vec<String>,
184}
185
186impl AnalysisReport {
187 pub fn is_ready_for_migration(&self) -> bool {
189 self.issues.iter().all(|i| i.severity != Severity::Error)
190 }
191
192 pub fn summary(&self) -> String {
194 format!(
195 "Analysis complete: {} files, {} issues, {} warnings",
196 self.total_files,
197 self.issues.len(),
198 self.warnings.len()
199 )
200 }
201}
202
203#[derive(Debug, Clone)]
205pub struct EditionIssue {
206 pub file: Option<String>,
208 pub line: Option<u32>,
210 pub message: String,
212 pub severity: Severity,
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub enum Severity {
219 Error,
221 Warning,
223 Info,
225}
226
227#[cfg(test)]
228#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
229mod tests {
230 use super::*;
231 use tempfile::TempDir;
232
233 #[tokio::test]
234 async fn test_analyzer_creation() {
235 let temp_dir = TempDir::new().unwrap();
236 let analyzer = EditionAnalyzer::new(temp_dir.path());
237
238 let manifest_content = r#"
240[package]
241name = "test"
242version = "0.1.0"
243edition = "2021"
244"#;
245
246 tokio::fs::write(temp_dir.path().join("Cargo.toml"), manifest_content)
247 .await
248 .unwrap();
249
250 let _report = analyzer.analyze(Edition::Edition2024).await;
253 }
254}