Skip to main content

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, ToolResolveRequest,
13    ToolSource,
14};
15use sha2::{Digest, Sha256};
16use std::path::PathBuf;
17use tokio::process::Command;
18use tracing::{debug, info};
19
20/// Tool provider for rustup-managed Rust toolchains.
21///
22/// Uses the system's rustup installation to manage Rust toolchains,
23/// components, and cross-compilation targets.
24pub struct RustupToolProvider;
25
26impl Default for RustupToolProvider {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl RustupToolProvider {
33    /// Create a new rustup tool provider.
34    #[must_use]
35    pub fn new() -> Self {
36        Self
37    }
38
39    /// Get the rustup home directory.
40    fn rustup_home() -> PathBuf {
41        std::env::var("RUSTUP_HOME").map_or_else(
42            |_| {
43                dirs::home_dir()
44                    .unwrap_or_else(|| PathBuf::from("."))
45                    .join(".rustup")
46            },
47            PathBuf::from,
48        )
49    }
50
51    /// Get the host triple for the current platform.
52    fn host_triple(platform: &Platform) -> String {
53        let arch = match platform.arch {
54            Arch::Arm64 => "aarch64",
55            Arch::X86_64 => "x86_64",
56        };
57        let os = match platform.os {
58            Os::Darwin => "apple-darwin",
59            Os::Linux => "unknown-linux-gnu",
60        };
61        format!("{arch}-{os}")
62    }
63
64    /// Get the toolchain directory path.
65    fn toolchain_path(toolchain: &str, platform: &Platform) -> PathBuf {
66        let host_triple = Self::host_triple(platform);
67        // Rustup stores toolchains as either:
68        // - "{version}-{triple}" for versioned toolchains (e.g., "1.83.0-x86_64-apple-darwin")
69        // - "{channel}-{triple}" for channel toolchains (e.g., "stable-x86_64-apple-darwin")
70        let toolchain_name = format!("{toolchain}-{host_triple}");
71        Self::rustup_home().join("toolchains").join(toolchain_name)
72    }
73
74    /// Check if a toolchain is installed.
75    fn is_toolchain_installed(toolchain: &str, platform: &Platform) -> bool {
76        let path = Self::toolchain_path(toolchain, platform);
77        path.join("bin").join("rustc").exists()
78    }
79
80    /// Install a toolchain with the given configuration.
81    async fn install_toolchain(
82        &self,
83        toolchain: &str,
84        profile: Option<&str>,
85        components: &[String],
86        targets: &[String],
87    ) -> Result<()> {
88        let mut cmd = Command::new("rustup");
89        cmd.arg("toolchain").arg("install").arg(toolchain);
90
91        // Add profile if specified
92        if let Some(p) = profile {
93            cmd.arg("--profile").arg(p);
94        }
95
96        // Add components
97        for component in components {
98            cmd.arg("-c").arg(component);
99        }
100
101        // Add targets
102        for target in targets {
103            cmd.arg("-t").arg(target);
104        }
105
106        info!(
107            %toolchain,
108            ?profile,
109            ?components,
110            ?targets,
111            "Installing Rust toolchain"
112        );
113
114        let output = cmd.output().await.map_err(|e| {
115            cuenv_core::Error::tool_resolution(format!("Failed to run rustup: {e}"))
116        })?;
117
118        if !output.status.success() {
119            let stderr = String::from_utf8_lossy(&output.stderr);
120            return Err(cuenv_core::Error::tool_resolution(format!(
121                "rustup toolchain install failed: {stderr}"
122            )));
123        }
124
125        debug!(%toolchain, "Toolchain installed successfully");
126        Ok(())
127    }
128
129    /// Compute a digest for the toolchain configuration.
130    fn compute_digest(
131        toolchain: &str,
132        profile: Option<&str>,
133        components: &[String],
134        targets: &[String],
135    ) -> String {
136        let mut hasher = Sha256::new();
137        hasher.update(toolchain.as_bytes());
138        hasher.update(b"|");
139        hasher.update(profile.unwrap_or("default").as_bytes());
140        hasher.update(b"|");
141        for c in components {
142            hasher.update(c.as_bytes());
143            hasher.update(b",");
144        }
145        hasher.update(b"|");
146        for t in targets {
147            hasher.update(t.as_bytes());
148            hasher.update(b",");
149        }
150        format!("sha256:{:x}", hasher.finalize())
151    }
152}
153
154#[async_trait]
155impl ToolProvider for RustupToolProvider {
156    fn name(&self) -> &'static str {
157        "rustup"
158    }
159
160    fn description(&self) -> &'static str {
161        "Manage Rust toolchains via rustup"
162    }
163
164    fn can_handle(&self, source: &ToolSource) -> bool {
165        matches!(source, ToolSource::Rustup { .. })
166    }
167
168    async fn check_prerequisites(&self) -> Result<()> {
169        // Check if rustup is available
170        let output = Command::new("rustup")
171            .arg("--version")
172            .output()
173            .await
174            .map_err(|e| {
175                cuenv_core::Error::tool_resolution(format!(
176                    "rustup not found. Please install rustup: https://rustup.rs\nError: {e}"
177                ))
178            })?;
179
180        if !output.status.success() {
181            return Err(cuenv_core::Error::tool_resolution(
182                "rustup --version failed. Is rustup properly installed?".to_string(),
183            ));
184        }
185
186        debug!("rustup is available");
187        Ok(())
188    }
189
190    async fn resolve(&self, request: &ToolResolveRequest<'_>) -> Result<ResolvedTool> {
191        let tool_name = request.tool_name;
192        let version = request.version;
193        let platform = request.platform;
194        let config = request.config;
195
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
583            .resolve(&ToolResolveRequest {
584                tool_name: "rust",
585                version: "1.83.0",
586                platform: &platform,
587                config: &config,
588                token: None,
589            })
590            .await;
591        assert!(resolved.is_ok());
592
593        let resolved = resolved.unwrap();
594        assert_eq!(resolved.name, "rust");
595        assert_eq!(resolved.version, "1.83.0");
596
597        match &resolved.source {
598            ToolSource::Rustup {
599                toolchain,
600                profile,
601                components,
602                targets,
603            } => {
604                assert_eq!(toolchain, "1.83.0");
605                assert!(profile.is_none());
606                assert!(components.is_empty());
607                assert!(targets.is_empty());
608            }
609            _ => panic!("Expected Rustup source"),
610        }
611    }
612
613    #[tokio::test]
614    async fn test_resolve_with_toolchain() {
615        let provider = RustupToolProvider::new();
616        let platform = Platform::new(Os::Linux, Arch::X86_64);
617        let config = serde_json::json!({
618            "toolchain": "nightly"
619        });
620
621        let resolved = provider
622            .resolve(&ToolResolveRequest {
623                tool_name: "rust",
624                version: "latest",
625                platform: &platform,
626                config: &config,
627                token: None,
628            })
629            .await
630            .unwrap();
631
632        match &resolved.source {
633            ToolSource::Rustup { toolchain, .. } => {
634                assert_eq!(toolchain, "nightly");
635            }
636            _ => panic!("Expected Rustup source"),
637        }
638    }
639
640    #[tokio::test]
641    async fn test_resolve_with_profile() {
642        let provider = RustupToolProvider::new();
643        let platform = Platform::new(Os::Darwin, Arch::Arm64);
644        let config = serde_json::json!({
645            "profile": "minimal"
646        });
647
648        let resolved = provider
649            .resolve(&ToolResolveRequest {
650                tool_name: "rust",
651                version: "1.80.0",
652                platform: &platform,
653                config: &config,
654                token: None,
655            })
656            .await
657            .unwrap();
658
659        match &resolved.source {
660            ToolSource::Rustup { profile, .. } => {
661                assert_eq!(profile.as_deref(), Some("minimal"));
662            }
663            _ => panic!("Expected Rustup source"),
664        }
665    }
666
667    #[tokio::test]
668    async fn test_resolve_with_components() {
669        let provider = RustupToolProvider::new();
670        let platform = Platform::new(Os::Linux, Arch::Arm64);
671        let config = serde_json::json!({
672            "components": ["clippy", "rustfmt", "rust-src"]
673        });
674
675        let resolved = provider
676            .resolve(&ToolResolveRequest {
677                tool_name: "rust",
678                version: "stable",
679                platform: &platform,
680                config: &config,
681                token: None,
682            })
683            .await
684            .unwrap();
685
686        match &resolved.source {
687            ToolSource::Rustup { components, .. } => {
688                assert_eq!(components.len(), 3);
689                assert!(components.contains(&"clippy".to_string()));
690                assert!(components.contains(&"rustfmt".to_string()));
691                assert!(components.contains(&"rust-src".to_string()));
692            }
693            _ => panic!("Expected Rustup source"),
694        }
695    }
696
697    #[tokio::test]
698    async fn test_resolve_with_targets() {
699        let provider = RustupToolProvider::new();
700        let platform = Platform::new(Os::Darwin, Arch::X86_64);
701        let config = serde_json::json!({
702            "targets": ["wasm32-unknown-unknown", "aarch64-apple-darwin"]
703        });
704
705        let resolved = provider
706            .resolve(&ToolResolveRequest {
707                tool_name: "rust",
708                version: "1.82.0",
709                platform: &platform,
710                config: &config,
711                token: None,
712            })
713            .await
714            .unwrap();
715
716        match &resolved.source {
717            ToolSource::Rustup { targets, .. } => {
718                assert_eq!(targets.len(), 2);
719                assert!(targets.contains(&"wasm32-unknown-unknown".to_string()));
720                assert!(targets.contains(&"aarch64-apple-darwin".to_string()));
721            }
722            _ => panic!("Expected Rustup source"),
723        }
724    }
725
726    #[tokio::test]
727    async fn test_resolve_full_config() {
728        let provider = RustupToolProvider::new();
729        let platform = Platform::new(Os::Linux, Arch::X86_64);
730        let config = serde_json::json!({
731            "toolchain": "nightly-2024-01-15",
732            "profile": "complete",
733            "components": ["clippy", "rustfmt", "rust-analyzer"],
734            "targets": ["x86_64-unknown-linux-musl", "wasm32-wasi"]
735        });
736
737        let resolved = provider
738            .resolve(&ToolResolveRequest {
739                tool_name: "rust",
740                version: "nightly",
741                platform: &platform,
742                config: &config,
743                token: None,
744            })
745            .await
746            .unwrap();
747
748        match &resolved.source {
749            ToolSource::Rustup {
750                toolchain,
751                profile,
752                components,
753                targets,
754            } => {
755                assert_eq!(toolchain, "nightly-2024-01-15");
756                assert_eq!(profile.as_deref(), Some("complete"));
757                assert_eq!(components.len(), 3);
758                assert_eq!(targets.len(), 2);
759            }
760            _ => panic!("Expected Rustup source"),
761        }
762    }
763
764    #[test]
765    fn test_is_cached_wrong_source_type() {
766        let provider = RustupToolProvider::new();
767        let options = ToolOptions::new();
768
769        let resolved = ResolvedTool {
770            name: "sometool".to_string(),
771            version: "1.0.0".to_string(),
772            platform: Platform::new(Os::Darwin, Arch::Arm64),
773            source: ToolSource::GitHub {
774                repo: "owner/repo".to_string(),
775                tag: "v1.0.0".to_string(),
776                asset: "file.zip".to_string(),
777                path: None,
778            },
779        };
780
781        // Should return false for non-Rustup source
782        assert!(!provider.is_cached(&resolved, &options));
783    }
784}