ferrous_forge/rust_version/
detector.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct RustVersion {
15 pub version: Version,
17 pub commit_hash: String,
19 pub commit_date: NaiveDate,
21 pub host: String,
23 pub channel: Channel,
25 pub raw_string: String,
27}
28
29impl RustVersion {
30 pub async fn parse(version_output: &str) -> Result<Self> {
37 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
72pub async fn detect_rust_version() -> Result<RustVersion> {
79 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 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
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
116async fn detect_host() -> String {
118 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 "unknown".to_string()
132}
133
134pub 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 line.split_whitespace().next().unwrap_or(line).to_string()
163 })
164 .collect())
165}
166
167pub fn is_rustup_available() -> bool {
169 which::which("rustup").is_ok()
170}
171
172pub 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}