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")
72 .map_err(|_| Error::rust_not_found("rustc not found. Please install Rust from https://rustup.rs"))?;
73
74 let output = Command::new(rustc_path)
76 .arg("--version")
77 .output()
78 .map_err(|e| Error::command(format!("Failed to run rustc: {}", e)))?;
79
80 if !output.status.success() {
81 let stderr = String::from_utf8_lossy(&output.stderr);
82 return Err(Error::command(format!("rustc failed: {}", stderr)));
83 }
84
85 let stdout = str::from_utf8(&output.stdout)
86 .map_err(|e| Error::parse(format!("Invalid UTF-8 in rustc output: {}", e)))?;
87
88 RustVersion::parse(stdout)
89}
90
91fn detect_channel(version_str: &str) -> Channel {
93 if version_str.contains("nightly") {
94 Channel::Nightly
95 } else if version_str.contains("beta") {
96 Channel::Beta
97 } else if version_str.contains("-") {
98 Channel::Custom(version_str.to_string())
100 } else {
101 Channel::Stable
102 }
103}
104
105fn detect_host() -> String {
107 if let Ok(output) = Command::new("rustc").arg("--print").arg("host").output() {
109 if output.status.success() {
110 if let Ok(host) = str::from_utf8(&output.stdout) {
111 return host.trim().to_string();
112 }
113 }
114 }
115
116 "unknown".to_string()
118}
119
120pub fn get_installed_toolchains() -> Result<Vec<String>> {
122 let rustup_path = which::which("rustup")
123 .map_err(|_| Error::rust_not_found("rustup not found"))?;
124
125 let output = Command::new(rustup_path)
126 .args(&["toolchain", "list"])
127 .output()
128 .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
129
130 if !output.status.success() {
131 let stderr = String::from_utf8_lossy(&output.stderr);
132 return Err(Error::command(format!("rustup failed: {}", stderr)));
133 }
134
135 let stdout = str::from_utf8(&output.stdout)?;
136
137 Ok(stdout
138 .lines()
139 .filter(|line| !line.is_empty())
140 .map(|line| {
141 line.split_whitespace()
143 .next()
144 .unwrap_or(line)
145 .to_string()
146 })
147 .collect())
148}
149
150pub fn is_rustup_available() -> bool {
152 which::which("rustup").is_ok()
153}
154
155pub fn get_active_toolchain() -> Result<String> {
157 let rustup_path = which::which("rustup")
158 .map_err(|_| Error::rust_not_found("rustup not found"))?;
159
160 let output = Command::new(rustup_path)
161 .args(&["show", "active-toolchain"])
162 .output()
163 .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
164
165 if !output.status.success() {
166 let stderr = String::from_utf8_lossy(&output.stderr);
167 return Err(Error::command(format!("rustup failed: {}", stderr)));
168 }
169
170 let stdout = str::from_utf8(&output.stdout)?;
171
172 Ok(stdout
173 .split_whitespace()
174 .next()
175 .unwrap_or("unknown")
176 .to_string())
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn test_parse_stable_version() {
185 let output = "rustc 1.90.0 (4b06a43a1 2025-08-07)";
186 let version = RustVersion::parse(output).unwrap();
187
188 assert_eq!(version.version, Version::new(1, 90, 0));
189 assert_eq!(version.commit_hash, "4b06a43a1");
190 assert_eq!(version.commit_date.to_string(), "2025-08-07");
191 assert_eq!(version.channel, Channel::Stable);
192 }
193
194 #[test]
195 fn test_parse_beta_version() {
196 let output = "rustc 1.91.0-beta.1 (5c8a0cafe 2025-09-01)";
197 let version = RustVersion::parse(output).unwrap();
198
199 assert_eq!(version.version.major, 1);
200 assert_eq!(version.version.minor, 91);
201 assert_eq!(version.version.patch, 0);
202 assert_eq!(version.channel, Channel::Beta);
203 }
204
205 #[test]
206 fn test_parse_nightly_version() {
207 let output = "rustc 1.92.0-nightly (abc123def 2025-09-15)";
208 let version = RustVersion::parse(output).unwrap();
209
210 assert_eq!(version.channel, Channel::Nightly);
211 }
212
213 #[test]
214 fn test_detect_channel() {
215 assert_eq!(detect_channel("1.90.0"), Channel::Stable);
216 assert_eq!(detect_channel("1.91.0-beta.1"), Channel::Beta);
217 assert_eq!(detect_channel("1.92.0-nightly"), Channel::Nightly);
218 }
219}