ferrous_forge/rust_version/
detector.rs

1//! Rust version detection from local installation
2
3use crate::{Error, Result};
4use chrono::NaiveDate;
5use regex::Regex;
6use semver::Version;
7use serde::{Deserialize, Serialize};
8use std::process::Command;
9use std::str;
10
11use super::Channel;
12
13/// Represents the current Rust installation version
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RustVersion {
16    /// Semantic version
17    pub version: Version,
18    /// Commit hash
19    pub commit_hash: String,
20    /// Commit date
21    pub commit_date: NaiveDate,
22    /// Host triple (e.g., x86_64-unknown-linux-gnu)
23    pub host: String,
24    /// Release channel
25    pub channel: Channel,
26    /// Raw version string from rustc
27    pub raw_string: String,
28}
29
30impl RustVersion {
31    /// Parse rustc version output
32    pub fn parse(version_output: &str) -> Result<Self> {
33        // Example: rustc 1.90.0 (4b06a43a1 2025-08-07)
34        let regex = Regex::new(
35            r"rustc (\d+\.\d+\.\d+(?:-[\w.]+)?)\s*\(([a-f0-9]+)\s+(\d{4}-\d{2}-\d{2})\)"
36        )?;
37        
38        let captures = regex
39            .captures(version_output)
40            .ok_or_else(|| Error::parse("Invalid rustc version output"))?;
41        
42        let version_str = &captures[1];
43        let version = Version::parse(version_str)?;
44        let commit_hash = captures[2].to_string();
45        let commit_date = NaiveDate::parse_from_str(&captures[3], "%Y-%m-%d")
46            .map_err(|e| Error::parse(format!("Failed to parse date: {}", e)))?;
47        
48        let channel = detect_channel(version_str);
49        let host = detect_host();
50        
51        Ok(Self {
52            version,
53            commit_hash,
54            commit_date,
55            host,
56            channel,
57            raw_string: version_output.to_string(),
58        })
59    }
60}
61
62impl std::fmt::Display for RustVersion {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(f, "rustc {} ({})", self.version, self.channel)
65    }
66}
67
68/// Detect the currently installed Rust version
69pub fn detect_rust_version() -> Result<RustVersion> {
70    // Check if rustc is available
71    let rustc_path = which::which("rustc")
72        .map_err(|_| Error::rust_not_found("rustc not found. Please install Rust from https://rustup.rs"))?;
73    
74    // Get version output
75    let output = Command::new(rustc_path)
76        .arg("--version")
77        .output()
78        .map_err(|e| Error::command(format!("Failed to run rustc: {}", e)))?;
79    
80    if !output.status.success() {
81        let stderr = String::from_utf8_lossy(&output.stderr);
82        return Err(Error::command(format!("rustc failed: {}", stderr)));
83    }
84    
85    let stdout = str::from_utf8(&output.stdout)
86        .map_err(|e| Error::parse(format!("Invalid UTF-8 in rustc output: {}", e)))?;
87    
88    RustVersion::parse(stdout)
89}
90
91/// Detect the channel from version string
92fn detect_channel(version_str: &str) -> Channel {
93    if version_str.contains("nightly") {
94        Channel::Nightly
95    } else if version_str.contains("beta") {
96        Channel::Beta
97    } else if version_str.contains("-") {
98        // Has pre-release identifier
99        Channel::Custom(version_str.to_string())
100    } else {
101        Channel::Stable
102    }
103}
104
105/// Detect the host triple
106fn detect_host() -> String {
107    // Try to get from rustc
108    if let Ok(output) = Command::new("rustc").arg("--print").arg("host").output() {
109        if output.status.success() {
110            if let Ok(host) = str::from_utf8(&output.stdout) {
111                return host.trim().to_string();
112            }
113        }
114    }
115    
116    // Fallback to a generic target string
117    "unknown".to_string()
118}
119
120/// Get installed toolchains via rustup
121pub fn get_installed_toolchains() -> Result<Vec<String>> {
122    let rustup_path = which::which("rustup")
123        .map_err(|_| Error::rust_not_found("rustup not found"))?;
124    
125    let output = Command::new(rustup_path)
126        .args(&["toolchain", "list"])
127        .output()
128        .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
129    
130    if !output.status.success() {
131        let stderr = String::from_utf8_lossy(&output.stderr);
132        return Err(Error::command(format!("rustup failed: {}", stderr)));
133    }
134    
135    let stdout = str::from_utf8(&output.stdout)?;
136    
137    Ok(stdout
138        .lines()
139        .filter(|line| !line.is_empty())
140        .map(|line| {
141            // Remove " (default)" suffix if present
142            line.split_whitespace()
143                .next()
144                .unwrap_or(line)
145                .to_string()
146        })
147        .collect())
148}
149
150/// Check if rustup is available
151pub fn is_rustup_available() -> bool {
152    which::which("rustup").is_ok()
153}
154
155/// Get the active toolchain
156pub fn get_active_toolchain() -> Result<String> {
157    let rustup_path = which::which("rustup")
158        .map_err(|_| Error::rust_not_found("rustup not found"))?;
159    
160    let output = Command::new(rustup_path)
161        .args(&["show", "active-toolchain"])
162        .output()
163        .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
164    
165    if !output.status.success() {
166        let stderr = String::from_utf8_lossy(&output.stderr);
167        return Err(Error::command(format!("rustup failed: {}", stderr)));
168    }
169    
170    let stdout = str::from_utf8(&output.stdout)?;
171    
172    Ok(stdout
173        .split_whitespace()
174        .next()
175        .unwrap_or("unknown")
176        .to_string())
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    
183    #[test]
184    fn test_parse_stable_version() {
185        let output = "rustc 1.90.0 (4b06a43a1 2025-08-07)";
186        let version = RustVersion::parse(output).unwrap();
187        
188        assert_eq!(version.version, Version::new(1, 90, 0));
189        assert_eq!(version.commit_hash, "4b06a43a1");
190        assert_eq!(version.commit_date.to_string(), "2025-08-07");
191        assert_eq!(version.channel, Channel::Stable);
192    }
193    
194    #[test]
195    fn test_parse_beta_version() {
196        let output = "rustc 1.91.0-beta.1 (5c8a0cafe 2025-09-01)";
197        let version = RustVersion::parse(output).unwrap();
198        
199        assert_eq!(version.version.major, 1);
200        assert_eq!(version.version.minor, 91);
201        assert_eq!(version.version.patch, 0);
202        assert_eq!(version.channel, Channel::Beta);
203    }
204    
205    #[test]
206    fn test_parse_nightly_version() {
207        let output = "rustc 1.92.0-nightly (abc123def 2025-09-15)";
208        let version = RustVersion::parse(output).unwrap();
209        
210        assert_eq!(version.channel, Channel::Nightly);
211    }
212    
213    #[test]
214    fn test_detect_channel() {
215        assert_eq!(detect_channel("1.90.0"), Channel::Stable);
216        assert_eq!(detect_channel("1.91.0-beta.1"), Channel::Beta);
217        assert_eq!(detect_channel("1.92.0-nightly"), Channel::Nightly);
218    }
219}