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