Skip to main content

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