cuenv_tools_rustup/
lib.rs

1//! Rustup tool provider for cuenv.
2//!
3//! Manages Rust toolchains via rustup. Supports:
4//! - Specific version toolchains (e.g., "1.83.0", "stable", "nightly")
5//! - Installation profiles (minimal, default, complete)
6//! - Additional components (clippy, rustfmt, rust-src, etc.)
7//! - Cross-compilation targets
8
9use async_trait::async_trait;
10use cuenv_core::Result;
11use cuenv_core::tools::{
12    Arch, FetchedTool, Os, Platform, ResolvedTool, ToolOptions, ToolProvider, ToolSource,
13};
14use sha2::{Digest, Sha256};
15use std::path::PathBuf;
16use tokio::process::Command;
17use tracing::{debug, info};
18
19/// Tool provider for rustup-managed Rust toolchains.
20///
21/// Uses the system's rustup installation to manage Rust toolchains,
22/// components, and cross-compilation targets.
23pub struct RustupToolProvider;
24
25impl Default for RustupToolProvider {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl RustupToolProvider {
32    /// Create a new rustup tool provider.
33    #[must_use]
34    pub fn new() -> Self {
35        Self
36    }
37
38    /// Get the rustup home directory.
39    fn rustup_home() -> PathBuf {
40        std::env::var("RUSTUP_HOME").map_or_else(
41            |_| {
42                dirs::home_dir()
43                    .unwrap_or_else(|| PathBuf::from("."))
44                    .join(".rustup")
45            },
46            PathBuf::from,
47        )
48    }
49
50    /// Get the host triple for the current platform.
51    fn host_triple(platform: &Platform) -> String {
52        let arch = match platform.arch {
53            Arch::Arm64 => "aarch64",
54            Arch::X86_64 => "x86_64",
55        };
56        let os = match platform.os {
57            Os::Darwin => "apple-darwin",
58            Os::Linux => "unknown-linux-gnu",
59        };
60        format!("{arch}-{os}")
61    }
62
63    /// Get the toolchain directory path.
64    fn toolchain_path(toolchain: &str, platform: &Platform) -> PathBuf {
65        let host_triple = Self::host_triple(platform);
66        // Rustup stores toolchains as either:
67        // - "{version}-{triple}" for versioned toolchains (e.g., "1.83.0-x86_64-apple-darwin")
68        // - "{channel}-{triple}" for channel toolchains (e.g., "stable-x86_64-apple-darwin")
69        let toolchain_name = format!("{toolchain}-{host_triple}");
70        Self::rustup_home().join("toolchains").join(toolchain_name)
71    }
72
73    /// Check if a toolchain is installed.
74    fn is_toolchain_installed(toolchain: &str, platform: &Platform) -> bool {
75        let path = Self::toolchain_path(toolchain, platform);
76        path.join("bin").join("rustc").exists()
77    }
78
79    /// Install a toolchain with the given configuration.
80    async fn install_toolchain(
81        &self,
82        toolchain: &str,
83        profile: Option<&str>,
84        components: &[String],
85        targets: &[String],
86    ) -> Result<()> {
87        let mut cmd = Command::new("rustup");
88        cmd.arg("toolchain").arg("install").arg(toolchain);
89
90        // Add profile if specified
91        if let Some(p) = profile {
92            cmd.arg("--profile").arg(p);
93        }
94
95        // Add components
96        for component in components {
97            cmd.arg("-c").arg(component);
98        }
99
100        // Add targets
101        for target in targets {
102            cmd.arg("-t").arg(target);
103        }
104
105        info!(
106            %toolchain,
107            ?profile,
108            ?components,
109            ?targets,
110            "Installing Rust toolchain"
111        );
112
113        let output = cmd.output().await.map_err(|e| {
114            cuenv_core::Error::tool_resolution(format!("Failed to run rustup: {e}"))
115        })?;
116
117        if !output.status.success() {
118            let stderr = String::from_utf8_lossy(&output.stderr);
119            return Err(cuenv_core::Error::tool_resolution(format!(
120                "rustup toolchain install failed: {stderr}"
121            )));
122        }
123
124        debug!(%toolchain, "Toolchain installed successfully");
125        Ok(())
126    }
127
128    /// Compute a digest for the toolchain configuration.
129    fn compute_digest(
130        toolchain: &str,
131        profile: Option<&str>,
132        components: &[String],
133        targets: &[String],
134    ) -> String {
135        let mut hasher = Sha256::new();
136        hasher.update(toolchain.as_bytes());
137        hasher.update(b"|");
138        hasher.update(profile.unwrap_or("default").as_bytes());
139        hasher.update(b"|");
140        for c in components {
141            hasher.update(c.as_bytes());
142            hasher.update(b",");
143        }
144        hasher.update(b"|");
145        for t in targets {
146            hasher.update(t.as_bytes());
147            hasher.update(b",");
148        }
149        format!("sha256:{:x}", hasher.finalize())
150    }
151}
152
153#[async_trait]
154impl ToolProvider for RustupToolProvider {
155    fn name(&self) -> &'static str {
156        "rustup"
157    }
158
159    fn description(&self) -> &'static str {
160        "Manage Rust toolchains via rustup"
161    }
162
163    fn can_handle(&self, source: &ToolSource) -> bool {
164        matches!(source, ToolSource::Rustup { .. })
165    }
166
167    async fn check_prerequisites(&self) -> Result<()> {
168        // Check if rustup is available
169        let output = Command::new("rustup")
170            .arg("--version")
171            .output()
172            .await
173            .map_err(|e| {
174                cuenv_core::Error::tool_resolution(format!(
175                    "rustup not found. Please install rustup: https://rustup.rs\nError: {e}"
176                ))
177            })?;
178
179        if !output.status.success() {
180            return Err(cuenv_core::Error::tool_resolution(
181                "rustup --version failed. Is rustup properly installed?".to_string(),
182            ));
183        }
184
185        debug!("rustup is available");
186        Ok(())
187    }
188
189    async fn resolve(
190        &self,
191        tool_name: &str,
192        version: &str,
193        platform: &Platform,
194        config: &serde_json::Value,
195    ) -> Result<ResolvedTool> {
196        let toolchain = config
197            .get("toolchain")
198            .and_then(|v| v.as_str())
199            .unwrap_or(version);
200
201        let profile = config
202            .get("profile")
203            .and_then(|v| v.as_str())
204            .map(String::from);
205
206        let components: Vec<String> = config
207            .get("components")
208            .and_then(|v| v.as_array())
209            .map(|arr| {
210                arr.iter()
211                    .filter_map(|v| v.as_str().map(String::from))
212                    .collect()
213            })
214            .unwrap_or_default();
215
216        let targets: Vec<String> = config
217            .get("targets")
218            .and_then(|v| v.as_array())
219            .map(|arr| {
220                arr.iter()
221                    .filter_map(|v| v.as_str().map(String::from))
222                    .collect()
223            })
224            .unwrap_or_default();
225
226        info!(
227            %tool_name,
228            %toolchain,
229            ?profile,
230            ?components,
231            ?targets,
232            %platform,
233            "Resolving rustup toolchain"
234        );
235
236        Ok(ResolvedTool {
237            name: tool_name.to_string(),
238            version: version.to_string(),
239            platform: platform.clone(),
240            source: ToolSource::Rustup {
241                toolchain: toolchain.to_string(),
242                profile,
243                components,
244                targets,
245            },
246        })
247    }
248
249    async fn fetch(&self, resolved: &ResolvedTool, _options: &ToolOptions) -> Result<FetchedTool> {
250        let ToolSource::Rustup {
251            toolchain,
252            profile,
253            components,
254            targets,
255        } = &resolved.source
256        else {
257            return Err(cuenv_core::Error::tool_resolution(
258                "RustupToolProvider received non-Rustup source".to_string(),
259            ));
260        };
261
262        info!(
263            tool = %resolved.name,
264            %toolchain,
265            "Fetching rustup toolchain"
266        );
267
268        // Install the toolchain (idempotent - safe to re-run)
269        self.install_toolchain(toolchain, profile.as_deref(), components, targets)
270            .await?;
271
272        // Get the binary path
273        let toolchain_dir = Self::toolchain_path(toolchain, &resolved.platform);
274        let bin_dir = toolchain_dir.join("bin");
275
276        // For rust toolchain, the "binary" is actually the bin directory
277        // We'll point to cargo as the main binary since that's typically what's used
278        let binary_path = bin_dir.join("cargo");
279
280        if !binary_path.exists() {
281            return Err(cuenv_core::Error::tool_resolution(format!(
282                "Toolchain installed but cargo not found at {}",
283                binary_path.display()
284            )));
285        }
286
287        let sha256 = Self::compute_digest(toolchain, profile.as_deref(), components, targets);
288
289        info!(
290            tool = %resolved.name,
291            binary = ?bin_dir,
292            %sha256,
293            "Fetched rustup toolchain"
294        );
295
296        Ok(FetchedTool {
297            name: resolved.name.clone(),
298            binary_path: bin_dir,
299            sha256,
300        })
301    }
302
303    fn is_cached(&self, resolved: &ResolvedTool, _options: &ToolOptions) -> bool {
304        let ToolSource::Rustup { toolchain, .. } = &resolved.source else {
305            return false;
306        };
307
308        let installed = Self::is_toolchain_installed(toolchain, &resolved.platform);
309        if installed {
310            debug!(%toolchain, "Toolchain already installed");
311        }
312        installed
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn test_provider_name() {
322        let provider = RustupToolProvider::new();
323        assert_eq!(provider.name(), "rustup");
324    }
325
326    #[test]
327    fn test_provider_description() {
328        let provider = RustupToolProvider::new();
329        assert_eq!(provider.description(), "Manage Rust toolchains via rustup");
330    }
331
332    #[test]
333    fn test_provider_default() {
334        let provider = RustupToolProvider;
335        assert_eq!(provider.name(), "rustup");
336    }
337
338    #[test]
339    fn test_host_triple() {
340        let platform = Platform::new(Os::Darwin, Arch::Arm64);
341        assert_eq!(
342            RustupToolProvider::host_triple(&platform),
343            "aarch64-apple-darwin"
344        );
345
346        let platform = Platform::new(Os::Linux, Arch::X86_64);
347        assert_eq!(
348            RustupToolProvider::host_triple(&platform),
349            "x86_64-unknown-linux-gnu"
350        );
351    }
352
353    #[test]
354    fn test_host_triple_all_combos() {
355        // Darwin + Arm64
356        let platform = Platform::new(Os::Darwin, Arch::Arm64);
357        assert_eq!(
358            RustupToolProvider::host_triple(&platform),
359            "aarch64-apple-darwin"
360        );
361
362        // Darwin + X86_64
363        let platform = Platform::new(Os::Darwin, Arch::X86_64);
364        assert_eq!(
365            RustupToolProvider::host_triple(&platform),
366            "x86_64-apple-darwin"
367        );
368
369        // Linux + Arm64
370        let platform = Platform::new(Os::Linux, Arch::Arm64);
371        assert_eq!(
372            RustupToolProvider::host_triple(&platform),
373            "aarch64-unknown-linux-gnu"
374        );
375
376        // Linux + X86_64
377        let platform = Platform::new(Os::Linux, Arch::X86_64);
378        assert_eq!(
379            RustupToolProvider::host_triple(&platform),
380            "x86_64-unknown-linux-gnu"
381        );
382    }
383
384    #[test]
385    fn test_can_handle() {
386        let provider = RustupToolProvider::new();
387
388        let rustup_source = ToolSource::Rustup {
389            toolchain: "1.83.0".into(),
390            profile: Some("default".into()),
391            components: vec![],
392            targets: vec![],
393        };
394        assert!(provider.can_handle(&rustup_source));
395
396        let github_source = ToolSource::GitHub {
397            repo: "org/repo".into(),
398            tag: "v1".into(),
399            asset: "file.zip".into(),
400            path: None,
401        };
402        assert!(!provider.can_handle(&github_source));
403    }
404
405    #[test]
406    fn test_can_handle_nix_source() {
407        let provider = RustupToolProvider::new();
408
409        let nix_source = ToolSource::Nix {
410            flake: "nixpkgs".into(),
411            package: "cargo".into(),
412            output: None,
413        };
414        assert!(!provider.can_handle(&nix_source));
415    }
416
417    #[test]
418    fn test_can_handle_oci_source() {
419        let provider = RustupToolProvider::new();
420
421        let oci_source = ToolSource::Oci {
422            image: "rust:latest".into(),
423            path: "rust".into(),
424        };
425        assert!(!provider.can_handle(&oci_source));
426    }
427
428    #[test]
429    fn test_compute_digest() {
430        let digest1 = RustupToolProvider::compute_digest(
431            "1.83.0",
432            Some("default"),
433            &["clippy".into(), "rustfmt".into()],
434            &["x86_64-unknown-linux-gnu".into()],
435        );
436        assert!(digest1.starts_with("sha256:"));
437
438        // Different config should produce different digest
439        let digest2 = RustupToolProvider::compute_digest("1.83.0", Some("minimal"), &[], &[]);
440        assert_ne!(digest1, digest2);
441
442        // Same config should produce same digest
443        let digest3 = RustupToolProvider::compute_digest(
444            "1.83.0",
445            Some("default"),
446            &["clippy".into(), "rustfmt".into()],
447            &["x86_64-unknown-linux-gnu".into()],
448        );
449        assert_eq!(digest1, digest3);
450    }
451
452    #[test]
453    fn test_compute_digest_no_profile() {
454        let digest = RustupToolProvider::compute_digest("stable", None, &[], &[]);
455        assert!(digest.starts_with("sha256:"));
456        // Default profile is used when None
457        assert!(digest.len() > 10);
458    }
459
460    #[test]
461    fn test_compute_digest_multiple_components() {
462        let digest = RustupToolProvider::compute_digest(
463            "nightly",
464            Some("complete"),
465            &[
466                "clippy".into(),
467                "rustfmt".into(),
468                "rust-src".into(),
469                "rust-analyzer".into(),
470            ],
471            &[],
472        );
473        assert!(digest.starts_with("sha256:"));
474    }
475
476    #[test]
477    fn test_compute_digest_multiple_targets() {
478        let digest = RustupToolProvider::compute_digest(
479            "1.80.0",
480            None,
481            &[],
482            &[
483                "x86_64-unknown-linux-gnu".into(),
484                "aarch64-unknown-linux-gnu".into(),
485                "wasm32-unknown-unknown".into(),
486            ],
487        );
488        assert!(digest.starts_with("sha256:"));
489    }
490
491    #[test]
492    fn test_compute_digest_deterministic() {
493        let digest1 = RustupToolProvider::compute_digest(
494            "1.75.0",
495            Some("default"),
496            &["clippy".into()],
497            &["x86_64-pc-windows-msvc".into()],
498        );
499        let digest2 = RustupToolProvider::compute_digest(
500            "1.75.0",
501            Some("default"),
502            &["clippy".into()],
503            &["x86_64-pc-windows-msvc".into()],
504        );
505        assert_eq!(digest1, digest2);
506    }
507
508    #[test]
509    fn test_compute_digest_order_matters() {
510        // Different component order should produce different digest
511        let digest1 = RustupToolProvider::compute_digest(
512            "stable",
513            None,
514            &["clippy".into(), "rustfmt".into()],
515            &[],
516        );
517        let digest2 = RustupToolProvider::compute_digest(
518            "stable",
519            None,
520            &["rustfmt".into(), "clippy".into()],
521            &[],
522        );
523        assert_ne!(digest1, digest2);
524    }
525
526    #[test]
527    fn test_toolchain_path() {
528        let platform = Platform::new(Os::Darwin, Arch::Arm64);
529        let path = RustupToolProvider::toolchain_path("1.83.0", &platform);
530
531        // Should contain the toolchain and host triple
532        let path_str = path.to_string_lossy();
533        assert!(path_str.contains("toolchains"));
534        assert!(path_str.contains("1.83.0-aarch64-apple-darwin"));
535    }
536
537    #[test]
538    fn test_toolchain_path_stable() {
539        let platform = Platform::new(Os::Linux, Arch::X86_64);
540        let path = RustupToolProvider::toolchain_path("stable", &platform);
541
542        let path_str = path.to_string_lossy();
543        assert!(path_str.contains("stable-x86_64-unknown-linux-gnu"));
544    }
545
546    #[test]
547    fn test_toolchain_path_nightly() {
548        let platform = Platform::new(Os::Darwin, Arch::X86_64);
549        let path = RustupToolProvider::toolchain_path("nightly", &platform);
550
551        let path_str = path.to_string_lossy();
552        assert!(path_str.contains("nightly-x86_64-apple-darwin"));
553    }
554
555    #[test]
556    fn test_is_toolchain_installed_nonexistent() {
557        // A fake toolchain that definitely doesn't exist
558        let platform = Platform::new(Os::Darwin, Arch::Arm64);
559        let installed = RustupToolProvider::is_toolchain_installed(
560            "nonexistent-fake-toolchain-12345",
561            &platform,
562        );
563        assert!(!installed);
564    }
565
566    #[test]
567    fn test_rustup_home_default() {
568        // Test that rustup_home returns a path
569        let home = RustupToolProvider::rustup_home();
570        // Should end with .rustup when RUSTUP_HOME is not set
571        // or be the RUSTUP_HOME value if set
572        let path_str = home.to_string_lossy();
573        assert!(path_str.contains("rustup") || path_str.contains(".rustup"));
574    }
575
576    #[tokio::test]
577    async fn test_resolve_minimal_config() {
578        let provider = RustupToolProvider::new();
579        let platform = Platform::new(Os::Darwin, Arch::Arm64);
580        let config = serde_json::json!({});
581
582        let resolved = provider.resolve("rust", "1.83.0", &platform, &config).await;
583        assert!(resolved.is_ok());
584
585        let resolved = resolved.unwrap();
586        assert_eq!(resolved.name, "rust");
587        assert_eq!(resolved.version, "1.83.0");
588
589        match &resolved.source {
590            ToolSource::Rustup {
591                toolchain,
592                profile,
593                components,
594                targets,
595            } => {
596                assert_eq!(toolchain, "1.83.0");
597                assert!(profile.is_none());
598                assert!(components.is_empty());
599                assert!(targets.is_empty());
600            }
601            _ => panic!("Expected Rustup source"),
602        }
603    }
604
605    #[tokio::test]
606    async fn test_resolve_with_toolchain() {
607        let provider = RustupToolProvider::new();
608        let platform = Platform::new(Os::Linux, Arch::X86_64);
609        let config = serde_json::json!({
610            "toolchain": "nightly"
611        });
612
613        let resolved = provider
614            .resolve("rust", "latest", &platform, &config)
615            .await
616            .unwrap();
617
618        match &resolved.source {
619            ToolSource::Rustup { toolchain, .. } => {
620                assert_eq!(toolchain, "nightly");
621            }
622            _ => panic!("Expected Rustup source"),
623        }
624    }
625
626    #[tokio::test]
627    async fn test_resolve_with_profile() {
628        let provider = RustupToolProvider::new();
629        let platform = Platform::new(Os::Darwin, Arch::Arm64);
630        let config = serde_json::json!({
631            "profile": "minimal"
632        });
633
634        let resolved = provider
635            .resolve("rust", "1.80.0", &platform, &config)
636            .await
637            .unwrap();
638
639        match &resolved.source {
640            ToolSource::Rustup { profile, .. } => {
641                assert_eq!(profile.as_deref(), Some("minimal"));
642            }
643            _ => panic!("Expected Rustup source"),
644        }
645    }
646
647    #[tokio::test]
648    async fn test_resolve_with_components() {
649        let provider = RustupToolProvider::new();
650        let platform = Platform::new(Os::Linux, Arch::Arm64);
651        let config = serde_json::json!({
652            "components": ["clippy", "rustfmt", "rust-src"]
653        });
654
655        let resolved = provider
656            .resolve("rust", "stable", &platform, &config)
657            .await
658            .unwrap();
659
660        match &resolved.source {
661            ToolSource::Rustup { components, .. } => {
662                assert_eq!(components.len(), 3);
663                assert!(components.contains(&"clippy".to_string()));
664                assert!(components.contains(&"rustfmt".to_string()));
665                assert!(components.contains(&"rust-src".to_string()));
666            }
667            _ => panic!("Expected Rustup source"),
668        }
669    }
670
671    #[tokio::test]
672    async fn test_resolve_with_targets() {
673        let provider = RustupToolProvider::new();
674        let platform = Platform::new(Os::Darwin, Arch::X86_64);
675        let config = serde_json::json!({
676            "targets": ["wasm32-unknown-unknown", "aarch64-apple-darwin"]
677        });
678
679        let resolved = provider
680            .resolve("rust", "1.82.0", &platform, &config)
681            .await
682            .unwrap();
683
684        match &resolved.source {
685            ToolSource::Rustup { targets, .. } => {
686                assert_eq!(targets.len(), 2);
687                assert!(targets.contains(&"wasm32-unknown-unknown".to_string()));
688                assert!(targets.contains(&"aarch64-apple-darwin".to_string()));
689            }
690            _ => panic!("Expected Rustup source"),
691        }
692    }
693
694    #[tokio::test]
695    async fn test_resolve_full_config() {
696        let provider = RustupToolProvider::new();
697        let platform = Platform::new(Os::Linux, Arch::X86_64);
698        let config = serde_json::json!({
699            "toolchain": "nightly-2024-01-15",
700            "profile": "complete",
701            "components": ["clippy", "rustfmt", "rust-analyzer"],
702            "targets": ["x86_64-unknown-linux-musl", "wasm32-wasi"]
703        });
704
705        let resolved = provider
706            .resolve("rust", "nightly", &platform, &config)
707            .await
708            .unwrap();
709
710        match &resolved.source {
711            ToolSource::Rustup {
712                toolchain,
713                profile,
714                components,
715                targets,
716            } => {
717                assert_eq!(toolchain, "nightly-2024-01-15");
718                assert_eq!(profile.as_deref(), Some("complete"));
719                assert_eq!(components.len(), 3);
720                assert_eq!(targets.len(), 2);
721            }
722            _ => panic!("Expected Rustup source"),
723        }
724    }
725
726    #[test]
727    fn test_is_cached_wrong_source_type() {
728        let provider = RustupToolProvider::new();
729        let options = ToolOptions::new();
730
731        let resolved = ResolvedTool {
732            name: "sometool".to_string(),
733            version: "1.0.0".to_string(),
734            platform: Platform::new(Os::Darwin, Arch::Arm64),
735            source: ToolSource::GitHub {
736                repo: "owner/repo".to_string(),
737                tag: "v1.0.0".to_string(),
738                asset: "file.zip".to_string(),
739                path: None,
740            },
741        };
742
743        // Should return false for non-Rustup source
744        assert!(!provider.is_cached(&resolved, &options));
745    }
746}