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