Skip to main content

ferrous_forge/edition/
mod.rs

1//! Rust edition detection and migration assistance
2//!
3//! This module provides functionality to detect the current edition used in
4//! a project, check for available migrations, and assist with the migration process.
5
6use crate::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use tokio::fs;
10
11/// Edition compatibility analysis and detection.
12pub mod analyzer;
13/// Edition migration assistance and execution.
14pub mod migrator;
15
16pub use analyzer::EditionAnalyzer;
17pub use migrator::EditionMigrator;
18
19/// Rust edition
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
21pub enum Edition {
22    /// Rust 2015 edition
23    Edition2015,
24    /// Rust 2018 edition
25    Edition2018,
26    /// Rust 2021 edition
27    Edition2021,
28    /// Rust 2024 edition
29    Edition2024,
30}
31
32impl Edition {
33    /// Parse edition from string
34    ///
35    /// # Errors
36    ///
37    /// Returns an error if the string does not match a known edition
38    /// (`"2015"`, `"2018"`, `"2021"`, or `"2024"`).
39    pub fn parse_edition(s: &str) -> Result<Self> {
40        match s {
41            "2015" => Ok(Self::Edition2015),
42            "2018" => Ok(Self::Edition2018),
43            "2021" => Ok(Self::Edition2021),
44            "2024" => Ok(Self::Edition2024),
45            _ => Err(Error::parse(format!("Unknown edition: {}", s))),
46        }
47    }
48
49    /// Get the edition as a string
50    pub fn as_str(&self) -> &'static str {
51        match self {
52            Self::Edition2015 => "2015",
53            Self::Edition2018 => "2018",
54            Self::Edition2021 => "2021",
55            Self::Edition2024 => "2024",
56        }
57    }
58
59    /// Get the latest stable edition
60    pub fn latest() -> Self {
61        Self::Edition2024
62    }
63
64    /// Check if this edition is the latest
65    pub fn is_latest(&self) -> bool {
66        *self == Self::latest()
67    }
68
69    /// Get the next edition after this one
70    pub fn next(&self) -> Option<Self> {
71        match self {
72            Self::Edition2015 => Some(Self::Edition2018),
73            Self::Edition2018 => Some(Self::Edition2021),
74            Self::Edition2021 => Some(Self::Edition2024),
75            Self::Edition2024 => None,
76        }
77    }
78
79    /// Get edition-specific lints for migration
80    pub fn migration_lints(&self) -> Vec<String> {
81        match self {
82            Self::Edition2015 => vec!["rust_2018_compatibility".to_string()],
83            Self::Edition2018 => vec!["rust_2021_compatibility".to_string()],
84            Self::Edition2021 => vec!["rust_2024_compatibility".to_string()],
85            Self::Edition2024 => vec![],
86        }
87    }
88}
89
90impl std::fmt::Display for Edition {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "Edition {}", self.as_str())
93    }
94}
95
96/// Edition compliance status
97#[derive(Debug, Clone)]
98pub struct EditionStatus {
99    /// Current edition in use
100    pub current: Edition,
101    /// Latest available edition
102    pub latest: Edition,
103    /// Is the project using the latest edition?
104    pub is_latest: bool,
105    /// Path to Cargo.toml
106    pub manifest_path: PathBuf,
107    /// Recommended migration path
108    pub migration_path: Vec<Edition>,
109}
110
111impl EditionStatus {
112    /// Create a new edition status
113    pub fn new(current: Edition, manifest_path: PathBuf) -> Self {
114        let latest = Edition::latest();
115        let is_latest = current == latest;
116
117        // Build migration path
118        let mut migration_path = Vec::new();
119        let mut current_edition = current;
120
121        while let Some(next) = current_edition.next() {
122            if next <= latest {
123                migration_path.push(next);
124                current_edition = next;
125            } else {
126                break;
127            }
128        }
129
130        Self {
131            current,
132            latest,
133            is_latest,
134            manifest_path,
135            migration_path,
136        }
137    }
138}
139
140/// Detect edition from Cargo.toml
141///
142/// # Errors
143///
144/// Returns an error if the manifest file does not exist, cannot be read,
145/// or contains an invalid edition value.
146pub async fn detect_edition(manifest_path: &Path) -> Result<Edition> {
147    if !manifest_path.exists() {
148        return Err(Error::file_not_found(format!(
149            "Cargo.toml not found at {}",
150            manifest_path.display()
151        )));
152    }
153
154    let contents = fs::read_to_string(manifest_path).await?;
155    let manifest: toml::Value = toml::from_str(&contents)
156        .map_err(|e| Error::parse(format!("Failed to parse Cargo.toml: {}", e)))?;
157
158    // Get edition from [package] section
159    let edition_str = manifest
160        .get("package")
161        .and_then(|p| p.get("edition"))
162        .and_then(|e| e.as_str())
163        .unwrap_or("2015"); // Default to 2015 if not specified
164
165    Edition::parse_edition(edition_str)
166}
167
168/// Check edition compliance for a project
169///
170/// # Errors
171///
172/// Returns an error if the `Cargo.toml` cannot be found, read, or parsed.
173pub async fn check_compliance(project_path: &Path) -> Result<EditionStatus> {
174    let manifest_path = project_path.join("Cargo.toml");
175    let edition = detect_edition(&manifest_path).await?;
176
177    Ok(EditionStatus::new(edition, manifest_path))
178}
179
180/// Get edition migration recommendations
181pub fn get_migration_recommendations(status: &EditionStatus) -> Vec<String> {
182    let mut recommendations = Vec::new();
183
184    if !status.is_latest {
185        recommendations.push(format!(
186            "Your project is using {}, but {} is now available",
187            status.current, status.latest
188        ));
189
190        if !status.migration_path.is_empty() {
191            recommendations.push(format!(
192                "Recommended migration path: {}",
193                status
194                    .migration_path
195                    .iter()
196                    .map(|e| e.to_string())
197                    .collect::<Vec<_>>()
198                    .join(" → ")
199            ));
200        }
201
202        recommendations
203            .push("Run `ferrous-forge edition migrate` to start the migration process".to_string());
204    } else {
205        recommendations.push(format!(
206            "✅ Your project is already using the latest edition ({})",
207            status.latest
208        ));
209    }
210
211    recommendations
212}
213
214#[cfg(test)]
215#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
216mod tests {
217    use super::*;
218    use tempfile::TempDir;
219
220    #[test]
221    fn test_edition_from_str() {
222        assert_eq!(
223            Edition::parse_edition("2015").unwrap(),
224            Edition::Edition2015
225        );
226        assert_eq!(
227            Edition::parse_edition("2018").unwrap(),
228            Edition::Edition2018
229        );
230        assert_eq!(
231            Edition::parse_edition("2021").unwrap(),
232            Edition::Edition2021
233        );
234        assert_eq!(
235            Edition::parse_edition("2024").unwrap(),
236            Edition::Edition2024
237        );
238        assert!(Edition::parse_edition("2027").is_err());
239    }
240
241    #[test]
242    fn test_edition_ordering() {
243        assert!(Edition::Edition2015 < Edition::Edition2018);
244        assert!(Edition::Edition2018 < Edition::Edition2021);
245        assert!(Edition::Edition2021 < Edition::Edition2024);
246    }
247
248    #[test]
249    fn test_edition_next() {
250        assert_eq!(Edition::Edition2015.next(), Some(Edition::Edition2018));
251        assert_eq!(Edition::Edition2018.next(), Some(Edition::Edition2021));
252        assert_eq!(Edition::Edition2021.next(), Some(Edition::Edition2024));
253        assert_eq!(Edition::Edition2024.next(), None);
254    }
255
256    #[test]
257    fn test_migration_path() {
258        let status = EditionStatus::new(Edition::Edition2015, PathBuf::from("Cargo.toml"));
259        assert_eq!(status.migration_path.len(), 3);
260        assert_eq!(status.migration_path[0], Edition::Edition2018);
261        assert_eq!(status.migration_path[1], Edition::Edition2021);
262        assert_eq!(status.migration_path[2], Edition::Edition2024);
263    }
264
265    #[tokio::test]
266    async fn test_detect_edition() {
267        let temp_dir = TempDir::new().unwrap();
268        let manifest_path = temp_dir.path().join("Cargo.toml");
269
270        let manifest_content = r#"
271[package]
272name = "test"
273version = "0.1.0"
274edition = "2021"
275"#;
276
277        fs::write(&manifest_path, manifest_content).await.unwrap();
278
279        let edition = detect_edition(&manifest_path).await.unwrap();
280        assert_eq!(edition, Edition::Edition2021);
281    }
282}