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").map_err(|_| {
72        Error::rust_not_found("rustc not found. Please install Rust from https://rustup.rs")
73    })?;
74
75    // Get version output
76    let output = Command::new(rustc_path)
77        .arg("--version")
78        .output()
79        .map_err(|e| Error::command(format!("Failed to run rustc: {}", e)))?;
80
81    if !output.status.success() {
82        let stderr = String::from_utf8_lossy(&output.stderr);
83        return Err(Error::command(format!("rustc failed: {}", stderr)));
84    }
85
86    let stdout = str::from_utf8(&output.stdout)
87        .map_err(|e| Error::parse(format!("Invalid UTF-8 in rustc output: {}", e)))?;
88
89    RustVersion::parse(stdout)
90}
91
92/// Detect the channel from version string
93fn detect_channel(version_str: &str) -> Channel {
94    if version_str.contains("nightly") {
95        Channel::Nightly
96    } else if version_str.contains("beta") {
97        Channel::Beta
98    } else if version_str.contains("-") {
99        // Has pre-release identifier
100        Channel::Custom(version_str.to_string())
101    } else {
102        Channel::Stable
103    }
104}
105
106/// Detect the host triple
107fn detect_host() -> String {
108    // Try to get from rustc
109    if let Ok(output) = Command::new("rustc").arg("--print").arg("host").output() {
110        if output.status.success() {
111            if let Ok(host) = str::from_utf8(&output.stdout) {
112                return host.trim().to_string();
113            }
114        }
115    }
116
117    // Fallback to a generic target string
118    "unknown".to_string()
119}
120
121/// Get installed toolchains via rustup
122pub fn get_installed_toolchains() -> Result<Vec<String>> {
123    let rustup_path =
124        which::which("rustup").map_err(|_| Error::rust_not_found("rustup not found"))?;
125
126    let output = Command::new(rustup_path)
127        .args(&["toolchain", "list"])
128        .output()
129        .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
130
131    if !output.status.success() {
132        let stderr = String::from_utf8_lossy(&output.stderr);
133        return Err(Error::command(format!("rustup failed: {}", stderr)));
134    }
135
136    let stdout = str::from_utf8(&output.stdout)?;
137
138    Ok(stdout
139        .lines()
140        .filter(|line| !line.is_empty())
141        .map(|line| {
142            // Remove " (default)" suffix if present
143            line.split_whitespace().next().unwrap_or(line).to_string()
144        })
145        .collect())
146}
147
148/// Check if rustup is available
149pub fn is_rustup_available() -> bool {
150    which::which("rustup").is_ok()
151}
152
153/// Get the active toolchain
154pub fn get_active_toolchain() -> Result<String> {
155    let rustup_path =
156        which::which("rustup").map_err(|_| Error::rust_not_found("rustup not found"))?;
157
158    let output = Command::new(rustup_path)
159        .args(&["show", "active-toolchain"])
160        .output()
161        .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
162
163    if !output.status.success() {
164        let stderr = String::from_utf8_lossy(&output.stderr);
165        return Err(Error::command(format!("rustup failed: {}", stderr)));
166    }
167
168    let stdout = str::from_utf8(&output.stdout)?;
169
170    Ok(stdout
171        .split_whitespace()
172        .next()
173        .unwrap_or("unknown")
174        .to_string())
175}
176
177#[cfg(test)]
178#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn test_parse_stable_version() {
184        let output = "rustc 1.90.0 (4b06a43a1 2025-08-07)";
185        let version = RustVersion::parse(output).unwrap();
186
187        assert_eq!(version.version, Version::new(1, 90, 0));
188        assert_eq!(version.commit_hash, "4b06a43a1");
189        assert_eq!(version.commit_date.to_string(), "2025-08-07");
190        assert_eq!(version.channel, Channel::Stable);
191    }
192
193    #[test]
194    fn test_parse_beta_version() {
195        let output = "rustc 1.91.0-beta.1 (5c8a0cafe 2025-09-01)";
196        let version = RustVersion::parse(output).unwrap();
197
198        assert_eq!(version.version.major, 1);
199        assert_eq!(version.version.minor, 91);
200        assert_eq!(version.version.patch, 0);
201        assert_eq!(version.channel, Channel::Beta);
202    }
203
204    #[test]
205    fn test_parse_nightly_version() {
206        let output = "rustc 1.92.0-nightly (abc123def 2025-09-15)";
207        let version = RustVersion::parse(output).unwrap();
208
209        assert_eq!(version.channel, Channel::Nightly);
210    }
211
212    #[test]
213    fn test_detect_channel() {
214        assert_eq!(detect_channel("1.90.0"), Channel::Stable);
215        assert_eq!(detect_channel("1.91.0-beta.1"), Channel::Beta);
216        assert_eq!(detect_channel("1.92.0-nightly"), Channel::Nightly);
217    }
218}