Skip to main content

ferrous_forge/rust_version/
rustup.rs

1//! Rustup integration for toolchain management
2//!
3//! Provides commands to check, update, and manage Rust toolchains via rustup.
4//! Enforces minimum/maximum versions set in locked config.
5//!
6//! @task T020
7//! @epic T014
8
9use 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/// Toolchain channel types
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub enum ToolchainChannel {
22    /// Stable releases
23    Stable,
24    /// Beta releases
25    Beta,
26    /// Nightly builds
27    Nightly,
28    /// Specific version (e.g., "1.70.0")
29    Version(String),
30    /// Custom toolchain
31    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    /// Parse a channel string into a `ToolchainChannel`
48    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                // Check if it looks like a version number
55                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/// Information about an installed toolchain
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct ToolchainInfo {
68    /// Toolchain name/channel
69    pub channel: ToolchainChannel,
70    /// Whether this is the default toolchain
71    pub is_default: bool,
72    /// Whether the toolchain is installed
73    pub is_installed: bool,
74}
75
76/// Version requirements from locked config
77#[derive(Debug, Clone)]
78pub struct VersionRequirements {
79    /// Minimum required version (inclusive)
80    pub minimum: Option<Version>,
81    /// Maximum allowed version (inclusive)
82    pub maximum: Option<Version>,
83    /// Exact version requirement
84    pub exact: Option<Version>,
85}
86
87impl VersionRequirements {
88    /// Create empty requirements (no constraints)
89    pub fn new() -> Self {
90        Self {
91            minimum: None,
92            maximum: None,
93            exact: None,
94        }
95    }
96
97    /// Check if a version meets the requirements
98    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    /// Get a human-readable description of the requirements
119    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
139/// Rustup manager for toolchain operations
140pub struct RustupManager;
141
142impl RustupManager {
143    /// Create a new rustup manager
144    pub fn new() -> Self {
145        Self
146    }
147
148    /// Check if rustup is available on the system
149    pub fn is_available(&self) -> bool {
150        is_rustup_available()
151    }
152
153    /// Ensure rustup is available, returning an error if not
154    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    /// Get the current Rust version with toolchain info
164    ///
165    /// # Errors
166    ///
167    /// Returns an error if `rustc` is not found or its version output cannot be parsed.
168    pub async fn get_current_version(&self) -> Result<RustVersion> {
169        crate::rust_version::detector::detect_rust_version().await
170    }
171
172    /// List all installed toolchains
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if rustup is not available or the toolchain list cannot be retrieved.
177    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    /// Install a specific toolchain
201    ///
202    /// # Errors
203    ///
204    /// Returns an error if rustup is not available or the installation fails.
205    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    /// Uninstall a specific toolchain
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if rustup is not available or the uninstallation fails.
234    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    /// Switch to a different toolchain (set as default)
259    ///
260    /// # Errors
261    ///
262    /// Returns an error if rustup is not available or the switch fails.
263    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    /// Update all installed toolchains
288    ///
289    /// # Errors
290    ///
291    /// Returns an error if rustup is not available or the update fails.
292    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        // Parse output to determine what was updated
314        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    /// Install a toolchain component (e.g., clippy, rustfmt)
330    ///
331    /// # Errors
332    ///
333    /// Returns an error if rustup is not available or the component installation fails.
334    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    /// Get version requirements from locked configuration
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if loading the lock configuration fails.
368    pub async fn get_version_requirements(&self) -> Result<VersionRequirements> {
369        let lock_manager = HierarchicalLockManager::load().await?;
370        let mut requirements = VersionRequirements::new();
371
372        // Check for locked rust-version
373        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                // For now, treat locked version as minimum requirement
377                // This could be extended to support range syntax like ">=1.70.0, <1.80.0"
378                requirements.minimum = Some(version);
379            }
380        }
381
382        // Check for locked maximum version (if defined)
383        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    /// Check if current Rust version meets locked requirements
394    ///
395    /// # Errors
396    ///
397    /// Returns an error if the current version cannot be determined or the lock configuration cannot be loaded.
398    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(&current.version);
403
404        Ok(VersionCheckResult {
405            current: current.version,
406            requirements,
407            meets_requirements,
408        })
409    }
410
411    /// Run rustup self-update
412    ///
413    /// # Errors
414    ///
415    /// Returns an error if rustup is not available or the self-update fails.
416    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    /// Show active toolchain information
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if rustup is not available or the active toolchain cannot be determined.
444    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/// Result of an update operation
472#[derive(Debug, Clone)]
473pub struct UpdateResult {
474    /// Whether the update was successful
475    pub success: bool,
476    /// List of updated items
477    pub updated: Vec<String>,
478}
479
480/// Result of a version check
481#[derive(Debug, Clone)]
482pub struct VersionCheckResult {
483    /// Current installed version
484    pub current: Version,
485    /// Required version constraints
486    pub requirements: VersionRequirements,
487    /// Whether the current version meets requirements
488    pub meets_requirements: bool,
489}
490
491impl VersionCheckResult {
492    /// Format a human-readable status message
493    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        // No constraints
562        assert!(req.check(&v170));
563
564        // Minimum version
565        req.minimum = Some(v180.clone());
566        assert!(!req.check(&v170));
567        assert!(req.check(&v180));
568        assert!(req.check(&v190));
569
570        // Maximum version
571        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        // Exact version
578        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}