ferrous_forge/rust_version/
detector.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RustVersion {
16 pub version: Version,
18 pub commit_hash: String,
20 pub commit_date: NaiveDate,
22 pub host: String,
24 pub channel: Channel,
26 pub raw_string: String,
28}
29
30impl RustVersion {
31 pub fn parse(version_output: &str) -> Result<Self> {
33 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
68pub fn detect_rust_version() -> Result<RustVersion> {
70 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 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
92fn 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 Channel::Custom(version_str.to_string())
101 } else {
102 Channel::Stable
103 }
104}
105
106fn detect_host() -> String {
108 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 "unknown".to_string()
119}
120
121pub 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 line.split_whitespace().next().unwrap_or(line).to_string()
144 })
145 .collect())
146}
147
148pub fn is_rustup_available() -> bool {
150 which::which("rustup").is_ok()
151}
152
153pub 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}