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 std::process::Command;
18use tracing::{debug, info};
19
20/// Toolchain channel types
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum ToolchainChannel {
23    /// Stable releases
24    Stable,
25    /// Beta releases
26    Beta,
27    /// Nightly builds
28    Nightly,
29    /// Specific version (e.g., "1.70.0")
30    Version(String),
31    /// Custom toolchain
32    Custom(String),
33}
34
35impl std::fmt::Display for ToolchainChannel {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Stable => write!(f, "stable"),
39            Self::Beta => write!(f, "beta"),
40            Self::Nightly => write!(f, "nightly"),
41            Self::Version(v) => write!(f, "{}", v),
42            Self::Custom(s) => write!(f, "{}", s),
43        }
44    }
45}
46
47impl ToolchainChannel {
48    /// Parse a channel string into a ToolchainChannel
49    pub fn parse(channel: &str) -> Self {
50        match channel.to_lowercase().as_str() {
51            "stable" => Self::Stable,
52            "beta" => Self::Beta,
53            "nightly" => Self::Nightly,
54            s => {
55                // Check if it looks like a version number
56                if s.chars().next().map_or(false, |c| c.is_ascii_digit()) {
57                    Self::Version(s.to_string())
58                } else {
59                    Self::Custom(s.to_string())
60                }
61            }
62        }
63    }
64}
65
66/// Information about an installed toolchain
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct ToolchainInfo {
69    /// Toolchain name/channel
70    pub channel: ToolchainChannel,
71    /// Whether this is the default toolchain
72    pub is_default: bool,
73    /// Whether the toolchain is installed
74    pub is_installed: bool,
75}
76
77/// Version requirements from locked config
78#[derive(Debug, Clone)]
79pub struct VersionRequirements {
80    /// Minimum required version (inclusive)
81    pub minimum: Option<Version>,
82    /// Maximum allowed version (inclusive)
83    pub maximum: Option<Version>,
84    /// Exact version requirement
85    pub exact: Option<Version>,
86}
87
88impl VersionRequirements {
89    /// Create empty requirements (no constraints)
90    pub fn new() -> Self {
91        Self {
92            minimum: None,
93            maximum: None,
94            exact: None,
95        }
96    }
97
98    /// Check if a version meets the requirements
99    pub fn check(&self, version: &Version) -> bool {
100        if let Some(exact) = &self.exact {
101            return version == exact;
102        }
103
104        if let Some(minimum) = &self.minimum {
105            if version < minimum {
106                return false;
107            }
108        }
109
110        if let Some(maximum) = &self.maximum {
111            if version > maximum {
112                return false;
113            }
114        }
115
116        true
117    }
118
119    /// Get a human-readable description of the requirements
120    pub fn description(&self) -> String {
121        if let Some(exact) = &self.exact {
122            return format!("exactly {}", exact);
123        }
124
125        match (&self.minimum, &self.maximum) {
126            (Some(min), Some(max)) => format!("between {} and {}", min, max),
127            (Some(min), None) => format!(">= {}", min),
128            (None, Some(max)) => format!("<= {}", max),
129            (None, None) => "any version".to_string(),
130        }
131    }
132}
133
134impl Default for VersionRequirements {
135    fn default() -> Self {
136        Self::new()
137    }
138}
139
140/// Rustup manager for toolchain operations
141pub struct RustupManager;
142
143impl RustupManager {
144    /// Create a new rustup manager
145    pub fn new() -> Self {
146        Self
147    }
148
149    /// Check if rustup is available on the system
150    pub fn is_available(&self) -> bool {
151        is_rustup_available()
152    }
153
154    /// Ensure rustup is available, returning an error if not
155    fn ensure_rustup(&self) -> Result<()> {
156        if !self.is_available() {
157            return Err(Error::rust_not_found(
158                "rustup not found. Please install rustup from https://rustup.rs",
159            ));
160        }
161        Ok(())
162    }
163
164    /// Get the current Rust version with toolchain info
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if `rustc` is not found or its version output cannot be parsed.
169    pub async fn get_current_version(&self) -> Result<RustVersion> {
170        crate::rust_version::detector::detect_rust_version()
171    }
172
173    /// List all installed toolchains
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if rustup is not available or the toolchain list cannot be retrieved.
178    pub fn list_toolchains(&self) -> Result<Vec<ToolchainInfo>> {
179        self.ensure_rustup()?;
180
181        let active = get_active_toolchain()?;
182        let installed = get_installed_toolchains()?;
183
184        let toolchains: Vec<ToolchainInfo> = installed
185            .into_iter()
186            .map(|name| {
187                let channel = ToolchainChannel::parse(&name);
188                let is_default = name == active;
189
190                ToolchainInfo {
191                    channel,
192                    is_default,
193                    is_installed: true,
194                }
195            })
196            .collect();
197
198        Ok(toolchains)
199    }
200
201    /// Install a specific toolchain
202    ///
203    /// # Errors
204    ///
205    /// Returns an error if rustup is not available or the installation fails.
206    pub async fn install_toolchain(&self, channel: &ToolchainChannel) -> Result<()> {
207        self.ensure_rustup()?;
208
209        let channel_str = channel.to_string();
210        info!("Installing toolchain: {}", channel_str);
211
212        let output = Command::new("rustup")
213            .args(["toolchain", "install", &channel_str, "--no-self-update"])
214            .output()
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 = Command::new("rustup")
241            .args(["toolchain", "uninstall", &channel_str])
242            .output()
243            .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
244
245        if !output.status.success() {
246            let stderr = String::from_utf8_lossy(&output.stderr);
247            return Err(Error::command(format!(
248                "Failed to uninstall toolchain '{}': {}",
249                channel_str, stderr
250            )));
251        }
252
253        info!("Successfully uninstalled toolchain: {}", channel_str);
254        Ok(())
255    }
256
257    /// Switch to a different toolchain (set as default)
258    ///
259    /// # Errors
260    ///
261    /// Returns an error if rustup is not available or the switch fails.
262    pub async fn switch_toolchain(&self, channel: &ToolchainChannel) -> Result<()> {
263        self.ensure_rustup()?;
264
265        let channel_str = channel.to_string();
266        info!("Switching to toolchain: {}", channel_str);
267
268        let output = Command::new("rustup")
269            .args(["default", &channel_str])
270            .output()
271            .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
272
273        if !output.status.success() {
274            let stderr = String::from_utf8_lossy(&output.stderr);
275            return Err(Error::command(format!(
276                "Failed to switch to toolchain '{}': {}",
277                channel_str, stderr
278            )));
279        }
280
281        info!("Successfully switched to toolchain: {}", channel_str);
282        Ok(())
283    }
284
285    /// Update all installed toolchains
286    ///
287    /// # Errors
288    ///
289    /// Returns an error if rustup is not available or the update fails.
290    pub async fn update_toolchains(&self) -> Result<UpdateResult> {
291        self.ensure_rustup()?;
292
293        info!("Updating toolchains...");
294
295        let output = Command::new("rustup")
296            .args(["update", "--no-self-update"])
297            .output()
298            .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
299
300        let stdout = String::from_utf8_lossy(&output.stdout);
301        let stderr = String::from_utf8_lossy(&output.stderr);
302
303        if !output.status.success() {
304            return Err(Error::command(format!(
305                "Failed to update toolchains: {}",
306                stderr
307            )));
308        }
309
310        // Parse output to determine what was updated
311        let updated = stdout
312            .lines()
313            .chain(stderr.lines())
314            .filter(|line| line.contains("updated") || line.contains("installed"))
315            .map(|s| s.to_string())
316            .collect();
317
318        info!("Toolchain update completed");
319
320        Ok(UpdateResult {
321            success: true,
322            updated,
323        })
324    }
325
326    /// Install a toolchain component (e.g., clippy, rustfmt)
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if rustup is not available or the component installation fails.
331    pub async fn install_component(&self, component: &str, toolchain: Option<&str>) -> Result<()> {
332        self.ensure_rustup()?;
333
334        let mut args = vec!["component", "add", component];
335        if let Some(tc) = toolchain {
336            args.push("--toolchain");
337            args.push(tc);
338        }
339
340        info!("Installing component '{}'", component);
341
342        let output = Command::new("rustup")
343            .args(&args)
344            .output()
345            .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
346
347        if !output.status.success() {
348            let stderr = String::from_utf8_lossy(&output.stderr);
349            return Err(Error::command(format!(
350                "Failed to install component '{}': {}",
351                component, stderr
352            )));
353        }
354
355        info!("Successfully installed component '{}'", component);
356        Ok(())
357    }
358
359    /// Get version requirements from locked configuration
360    ///
361    /// # Errors
362    ///
363    /// Returns an error if loading the lock configuration fails.
364    pub async fn get_version_requirements(&self) -> Result<VersionRequirements> {
365        let lock_manager = HierarchicalLockManager::load().await?;
366        let mut requirements = VersionRequirements::new();
367
368        // Check for locked rust-version
369        if let Some((_, entry)) = lock_manager.is_locked("rust-version") {
370            debug!("Found locked rust-version: {}", entry.value);
371            if let Ok(version) = Version::parse(&entry.value) {
372                // For now, treat locked version as minimum requirement
373                // This could be extended to support range syntax like ">=1.70.0, <1.80.0"
374                requirements.minimum = Some(version);
375            }
376        }
377
378        // Check for locked maximum version (if defined)
379        if let Some((_, entry)) = lock_manager.is_locked("max-rust-version") {
380            debug!("Found locked max-rust-version: {}", entry.value);
381            if let Ok(version) = Version::parse(&entry.value) {
382                requirements.maximum = Some(version);
383            }
384        }
385
386        Ok(requirements)
387    }
388
389    /// Check if current Rust version meets locked requirements
390    ///
391    /// # Errors
392    ///
393    /// Returns an error if the current version cannot be determined or the lock configuration cannot be loaded.
394    pub async fn check_version_requirements(&self) -> Result<VersionCheckResult> {
395        let current = self.get_current_version().await?;
396        let requirements = self.get_version_requirements().await?;
397
398        let meets_requirements = requirements.check(&current.version);
399
400        Ok(VersionCheckResult {
401            current: current.version,
402            requirements,
403            meets_requirements,
404        })
405    }
406
407    /// Run rustup self-update
408    ///
409    /// # Errors
410    ///
411    /// Returns an error if rustup is not available or the self-update fails.
412    pub async fn self_update(&self) -> Result<()> {
413        self.ensure_rustup()?;
414
415        info!("Running rustup self-update...");
416
417        let output = Command::new("rustup")
418            .args(["self", "update"])
419            .output()
420            .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
421
422        if !output.status.success() {
423            let stderr = String::from_utf8_lossy(&output.stderr);
424            return Err(Error::command(format!(
425                "Failed to self-update rustup: {}",
426                stderr
427            )));
428        }
429
430        info!("Rustup self-update completed");
431        Ok(())
432    }
433
434    /// Show active toolchain information
435    ///
436    /// # Errors
437    ///
438    /// Returns an error if rustup is not available or the active toolchain cannot be determined.
439    pub fn show_active_toolchain(&self) -> Result<String> {
440        self.ensure_rustup()?;
441
442        let output = Command::new("rustup")
443            .args(["show", "active-toolchain"])
444            .output()
445            .map_err(|e| Error::command(format!("Failed to run rustup: {}", e)))?;
446
447        if !output.status.success() {
448            let stderr = String::from_utf8_lossy(&output.stderr);
449            return Err(Error::command(format!(
450                "Failed to show active toolchain: {}",
451                stderr
452            )));
453        }
454
455        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
456    }
457}
458
459impl Default for RustupManager {
460    fn default() -> Self {
461        Self::new()
462    }
463}
464
465/// Result of an update operation
466#[derive(Debug, Clone)]
467pub struct UpdateResult {
468    /// Whether the update was successful
469    pub success: bool,
470    /// List of updated items
471    pub updated: Vec<String>,
472}
473
474/// Result of a version check
475#[derive(Debug, Clone)]
476pub struct VersionCheckResult {
477    /// Current installed version
478    pub current: Version,
479    /// Required version constraints
480    pub requirements: VersionRequirements,
481    /// Whether the current version meets requirements
482    pub meets_requirements: bool,
483}
484
485impl VersionCheckResult {
486    /// Format a human-readable status message
487    pub fn status_message(&self) -> String {
488        if self.meets_requirements {
489            format!(
490                "✅ Current version {} meets requirements ({})",
491                self.current,
492                self.requirements.description()
493            )
494        } else {
495            format!(
496                "❌ Current version {} does NOT meet requirements ({})",
497                self.current,
498                self.requirements.description()
499            )
500        }
501    }
502}
503
504#[cfg(test)]
505#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn test_toolchain_channel_display() {
511        assert_eq!(ToolchainChannel::Stable.to_string(), "stable");
512        assert_eq!(ToolchainChannel::Beta.to_string(), "beta");
513        assert_eq!(ToolchainChannel::Nightly.to_string(), "nightly");
514        assert_eq!(
515            ToolchainChannel::Version("1.70.0".to_string()).to_string(),
516            "1.70.0"
517        );
518        assert_eq!(
519            ToolchainChannel::Custom("my-toolchain".to_string()).to_string(),
520            "my-toolchain"
521        );
522    }
523
524    #[test]
525    fn test_toolchain_channel_parse() {
526        assert!(matches!(
527            ToolchainChannel::parse("stable"),
528            ToolchainChannel::Stable
529        ));
530        assert!(matches!(
531            ToolchainChannel::parse("beta"),
532            ToolchainChannel::Beta
533        ));
534        assert!(matches!(
535            ToolchainChannel::parse("nightly"),
536            ToolchainChannel::Nightly
537        ));
538        assert!(matches!(
539            ToolchainChannel::parse("1.70.0"),
540            ToolchainChannel::Version(_)
541        ));
542        assert!(matches!(
543            ToolchainChannel::parse("custom-toolchain"),
544            ToolchainChannel::Custom(_)
545        ));
546    }
547
548    #[test]
549    fn test_version_requirements_check() {
550        let mut req = VersionRequirements::new();
551        let v170 = Version::new(1, 70, 0);
552        let v180 = Version::new(1, 80, 0);
553        let v190 = Version::new(1, 90, 0);
554
555        // No constraints
556        assert!(req.check(&v170));
557
558        // Minimum version
559        req.minimum = Some(v180.clone());
560        assert!(!req.check(&v170));
561        assert!(req.check(&v180));
562        assert!(req.check(&v190));
563
564        // Maximum version
565        req = VersionRequirements::new();
566        req.maximum = Some(v180.clone());
567        assert!(req.check(&v170));
568        assert!(req.check(&v180));
569        assert!(!req.check(&v190));
570
571        // Exact version
572        req = VersionRequirements::new();
573        req.exact = Some(v180.clone());
574        assert!(!req.check(&v170));
575        assert!(req.check(&v180));
576        assert!(!req.check(&v190));
577    }
578
579    #[test]
580    fn test_version_requirements_description() {
581        let mut req = VersionRequirements::new();
582        assert_eq!(req.description(), "any version");
583
584        req.minimum = Some(Version::new(1, 70, 0));
585        assert_eq!(req.description(), ">= 1.70.0");
586
587        req.maximum = Some(Version::new(1, 80, 0));
588        assert_eq!(req.description(), "between 1.70.0 and 1.80.0");
589
590        req = VersionRequirements::new();
591        req.exact = Some(Version::new(1, 75, 0));
592        assert_eq!(req.description(), "exactly 1.75.0");
593    }
594}