ferrous_forge/rust_version/
rustup.rs1use crate::config::locking::HierarchicalLockManager;
10use crate::rust_version::RustVersion;
11use crate::rust_version::detector::{
12 get_active_toolchain, get_installed_toolchains, is_rustup_available,
13};
14use crate::{Error, Result};
15use semver::Version;
16use serde::{Deserialize, Serialize};
17use tracing::{debug, info};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub enum ToolchainChannel {
22 Stable,
24 Beta,
26 Nightly,
28 Version(String),
30 Custom(String),
32}
33
34impl std::fmt::Display for ToolchainChannel {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match self {
37 Self::Stable => write!(f, "stable"),
38 Self::Beta => write!(f, "beta"),
39 Self::Nightly => write!(f, "nightly"),
40 Self::Version(v) => write!(f, "{}", v),
41 Self::Custom(s) => write!(f, "{}", s),
42 }
43 }
44}
45
46impl ToolchainChannel {
47 pub fn parse(channel: &str) -> Self {
49 match channel.to_lowercase().as_str() {
50 "stable" => Self::Stable,
51 "beta" => Self::Beta,
52 "nightly" => Self::Nightly,
53 s => {
54 if s.chars().next().is_some_and(|c| c.is_ascii_digit()) {
56 Self::Version(s.to_string())
57 } else {
58 Self::Custom(s.to_string())
59 }
60 }
61 }
62 }
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ToolchainInfo {
68 pub channel: ToolchainChannel,
70 pub is_default: bool,
72 pub is_installed: bool,
74}
75
76#[derive(Debug, Clone)]
78pub struct VersionRequirements {
79 pub minimum: Option<Version>,
81 pub maximum: Option<Version>,
83 pub exact: Option<Version>,
85}
86
87impl VersionRequirements {
88 pub fn new() -> Self {
90 Self {
91 minimum: None,
92 maximum: None,
93 exact: None,
94 }
95 }
96
97 pub fn check(&self, version: &Version) -> bool {
99 if let Some(exact) = &self.exact {
100 return version == exact;
101 }
102
103 if let Some(minimum) = &self.minimum
104 && version < minimum
105 {
106 return false;
107 }
108
109 if let Some(maximum) = &self.maximum
110 && version > maximum
111 {
112 return false;
113 }
114
115 true
116 }
117
118 pub fn description(&self) -> String {
120 if let Some(exact) = &self.exact {
121 return format!("exactly {}", exact);
122 }
123
124 match (&self.minimum, &self.maximum) {
125 (Some(min), Some(max)) => format!("between {} and {}", min, max),
126 (Some(min), None) => format!(">= {}", min),
127 (None, Some(max)) => format!("<= {}", max),
128 (None, None) => "any version".to_string(),
129 }
130 }
131}
132
133impl Default for VersionRequirements {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139pub struct RustupManager;
141
142impl RustupManager {
143 pub fn new() -> Self {
145 Self
146 }
147
148 pub fn is_available(&self) -> bool {
150 is_rustup_available()
151 }
152
153 fn ensure_rustup(&self) -> Result<()> {
155 if !self.is_available() {
156 return Err(Error::rust_not_found(
157 "rustup not found. Please install rustup from https://rustup.rs",
158 ));
159 }
160 Ok(())
161 }
162
163 pub async fn get_current_version(&self) -> Result<RustVersion> {
169 crate::rust_version::detector::detect_rust_version().await
170 }
171
172 pub async fn list_toolchains(&self) -> Result<Vec<ToolchainInfo>> {
178 self.ensure_rustup()?;
179
180 let active = get_active_toolchain().await?;
181 let installed = get_installed_toolchains().await?;
182
183 let toolchains: Vec<ToolchainInfo> = installed
184 .into_iter()
185 .map(|name| {
186 let channel = ToolchainChannel::parse(&name);
187 let is_default = name == active;
188
189 ToolchainInfo {
190 channel,
191 is_default,
192 is_installed: true,
193 }
194 })
195 .collect();
196
197 Ok(toolchains)
198 }
199
200 pub async fn install_toolchain(&self, channel: &ToolchainChannel) -> Result<()> {
206 self.ensure_rustup()?;
207
208 let channel_str = channel.to_string();
209 info!("Installing toolchain: {}", channel_str);
210
211 let output = tokio::process::Command::new("rustup")
212 .args(["toolchain", "install", &channel_str, "--no-self-update"])
213 .output()
214 .await
215 .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
216
217 if !output.status.success() {
218 let stderr = String::from_utf8_lossy(&output.stderr);
219 return Err(Error::command(format!(
220 "Failed to install toolchain '{}': {}",
221 channel_str, stderr
222 )));
223 }
224
225 info!("Successfully installed toolchain: {}", channel_str);
226 Ok(())
227 }
228
229 pub async fn uninstall_toolchain(&self, channel: &ToolchainChannel) -> Result<()> {
235 self.ensure_rustup()?;
236
237 let channel_str = channel.to_string();
238 info!("Uninstalling toolchain: {}", channel_str);
239
240 let output = tokio::process::Command::new("rustup")
241 .args(["toolchain", "uninstall", &channel_str])
242 .output()
243 .await
244 .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
245
246 if !output.status.success() {
247 let stderr = String::from_utf8_lossy(&output.stderr);
248 return Err(Error::command(format!(
249 "Failed to uninstall toolchain '{}': {}",
250 channel_str, stderr
251 )));
252 }
253
254 info!("Successfully uninstalled toolchain: {}", channel_str);
255 Ok(())
256 }
257
258 pub async fn switch_toolchain(&self, channel: &ToolchainChannel) -> Result<()> {
264 self.ensure_rustup()?;
265
266 let channel_str = channel.to_string();
267 info!("Switching to toolchain: {}", channel_str);
268
269 let output = tokio::process::Command::new("rustup")
270 .args(["default", &channel_str])
271 .output()
272 .await
273 .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
274
275 if !output.status.success() {
276 let stderr = String::from_utf8_lossy(&output.stderr);
277 return Err(Error::command(format!(
278 "Failed to switch to toolchain '{}': {}",
279 channel_str, stderr
280 )));
281 }
282
283 info!("Successfully switched to toolchain: {}", channel_str);
284 Ok(())
285 }
286
287 pub async fn update_toolchains(&self) -> Result<UpdateResult> {
293 self.ensure_rustup()?;
294
295 info!("Updating toolchains...");
296
297 let output = tokio::process::Command::new("rustup")
298 .args(["update", "--no-self-update"])
299 .output()
300 .await
301 .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
302
303 let stdout = String::from_utf8_lossy(&output.stdout);
304 let stderr = String::from_utf8_lossy(&output.stderr);
305
306 if !output.status.success() {
307 return Err(Error::command(format!(
308 "Failed to update toolchains: {}",
309 stderr
310 )));
311 }
312
313 let updated = stdout
315 .lines()
316 .chain(stderr.lines())
317 .filter(|line| line.contains("updated") || line.contains("installed"))
318 .map(|s| s.to_string())
319 .collect();
320
321 info!("Toolchain update completed");
322
323 Ok(UpdateResult {
324 success: true,
325 updated,
326 })
327 }
328
329 pub async fn install_component(&self, component: &str, toolchain: Option<&str>) -> Result<()> {
335 self.ensure_rustup()?;
336
337 let mut args = vec!["component", "add", component];
338 if let Some(tc) = toolchain {
339 args.push("--toolchain");
340 args.push(tc);
341 }
342
343 info!("Installing component '{}'", component);
344
345 let output = tokio::process::Command::new("rustup")
346 .args(&args)
347 .output()
348 .await
349 .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
350
351 if !output.status.success() {
352 let stderr = String::from_utf8_lossy(&output.stderr);
353 return Err(Error::command(format!(
354 "Failed to install component '{}': {}",
355 component, stderr
356 )));
357 }
358
359 info!("Successfully installed component '{}'", component);
360 Ok(())
361 }
362
363 pub async fn get_version_requirements(&self) -> Result<VersionRequirements> {
369 let lock_manager = HierarchicalLockManager::load().await?;
370 let mut requirements = VersionRequirements::new();
371
372 if let Some((_, entry)) = lock_manager.is_locked("rust-version") {
374 debug!("Found locked rust-version: {}", entry.value);
375 if let Ok(version) = Version::parse(&entry.value) {
376 requirements.minimum = Some(version);
379 }
380 }
381
382 if let Some((_, entry)) = lock_manager.is_locked("max-rust-version") {
384 debug!("Found locked max-rust-version: {}", entry.value);
385 if let Ok(version) = Version::parse(&entry.value) {
386 requirements.maximum = Some(version);
387 }
388 }
389
390 Ok(requirements)
391 }
392
393 pub async fn check_version_requirements(&self) -> Result<VersionCheckResult> {
399 let current = self.get_current_version().await?;
400 let requirements = self.get_version_requirements().await?;
401
402 let meets_requirements = requirements.check(¤t.version);
403
404 Ok(VersionCheckResult {
405 current: current.version,
406 requirements,
407 meets_requirements,
408 })
409 }
410
411 pub async fn self_update(&self) -> Result<()> {
417 self.ensure_rustup()?;
418
419 info!("Running rustup self-update...");
420
421 let output = tokio::process::Command::new("rustup")
422 .args(["self", "update"])
423 .output()
424 .await
425 .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
426
427 if !output.status.success() {
428 let stderr = String::from_utf8_lossy(&output.stderr);
429 return Err(Error::command(format!(
430 "Failed to self-update rustup: {}",
431 stderr
432 )));
433 }
434
435 info!("Rustup self-update completed");
436 Ok(())
437 }
438
439 pub async fn show_active_toolchain(&self) -> Result<String> {
445 self.ensure_rustup()?;
446
447 let output = tokio::process::Command::new("rustup")
448 .args(["show", "active-toolchain"])
449 .output()
450 .await
451 .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
452
453 if !output.status.success() {
454 let stderr = String::from_utf8_lossy(&output.stderr);
455 return Err(Error::command(format!(
456 "Failed to show active toolchain: {}",
457 stderr
458 )));
459 }
460
461 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
462 }
463}
464
465impl Default for RustupManager {
466 fn default() -> Self {
467 Self::new()
468 }
469}
470
471#[derive(Debug, Clone)]
473pub struct UpdateResult {
474 pub success: bool,
476 pub updated: Vec<String>,
478}
479
480#[derive(Debug, Clone)]
482pub struct VersionCheckResult {
483 pub current: Version,
485 pub requirements: VersionRequirements,
487 pub meets_requirements: bool,
489}
490
491impl VersionCheckResult {
492 pub fn status_message(&self) -> String {
494 if self.meets_requirements {
495 format!(
496 "✅ Current version {} meets requirements ({})",
497 self.current,
498 self.requirements.description()
499 )
500 } else {
501 format!(
502 "❌ Current version {} does NOT meet requirements ({})",
503 self.current,
504 self.requirements.description()
505 )
506 }
507 }
508}
509
510#[cfg(test)]
511#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_toolchain_channel_display() {
517 assert_eq!(ToolchainChannel::Stable.to_string(), "stable");
518 assert_eq!(ToolchainChannel::Beta.to_string(), "beta");
519 assert_eq!(ToolchainChannel::Nightly.to_string(), "nightly");
520 assert_eq!(
521 ToolchainChannel::Version("1.70.0".to_string()).to_string(),
522 "1.70.0"
523 );
524 assert_eq!(
525 ToolchainChannel::Custom("my-toolchain".to_string()).to_string(),
526 "my-toolchain"
527 );
528 }
529
530 #[test]
531 fn test_toolchain_channel_parse() {
532 assert!(matches!(
533 ToolchainChannel::parse("stable"),
534 ToolchainChannel::Stable
535 ));
536 assert!(matches!(
537 ToolchainChannel::parse("beta"),
538 ToolchainChannel::Beta
539 ));
540 assert!(matches!(
541 ToolchainChannel::parse("nightly"),
542 ToolchainChannel::Nightly
543 ));
544 assert!(matches!(
545 ToolchainChannel::parse("1.70.0"),
546 ToolchainChannel::Version(_)
547 ));
548 assert!(matches!(
549 ToolchainChannel::parse("custom-toolchain"),
550 ToolchainChannel::Custom(_)
551 ));
552 }
553
554 #[test]
555 fn test_version_requirements_check() {
556 let mut req = VersionRequirements::new();
557 let v170 = Version::new(1, 70, 0);
558 let v180 = Version::new(1, 80, 0);
559 let v190 = Version::new(1, 90, 0);
560
561 assert!(req.check(&v170));
563
564 req.minimum = Some(v180.clone());
566 assert!(!req.check(&v170));
567 assert!(req.check(&v180));
568 assert!(req.check(&v190));
569
570 req = VersionRequirements::new();
572 req.maximum = Some(v180.clone());
573 assert!(req.check(&v170));
574 assert!(req.check(&v180));
575 assert!(!req.check(&v190));
576
577 req = VersionRequirements::new();
579 req.exact = Some(v180.clone());
580 assert!(!req.check(&v170));
581 assert!(req.check(&v180));
582 assert!(!req.check(&v190));
583 }
584
585 #[test]
586 fn test_version_requirements_description() {
587 let mut req = VersionRequirements::new();
588 assert_eq!(req.description(), "any version");
589
590 req.minimum = Some(Version::new(1, 70, 0));
591 assert_eq!(req.description(), ">= 1.70.0");
592
593 req.maximum = Some(Version::new(1, 80, 0));
594 assert_eq!(req.description(), "between 1.70.0 and 1.80.0");
595
596 req = VersionRequirements::new();
597 req.exact = Some(Version::new(1, 75, 0));
598 assert_eq!(req.description(), "exactly 1.75.0");
599 }
600}