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> {
38 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
73pub fn detect_rust_version() -> Result<RustVersion> {
80 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 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
102fn 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 Channel::Custom(version_str.to_string())
111 } else {
112 Channel::Stable
113 }
114}
115
116fn detect_host() -> String {
118 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 "unknown".to_string()
128}
129
130pub 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 line.split_whitespace().next().unwrap_or(line).to_string()
158 })
159 .collect())
160}
161
162pub fn is_rustup_available() -> bool {
164 which::which("rustup").is_ok()
165}
166
167pub 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}