1use 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
20pub struct RustupToolProvider;
25
26impl Default for RustupToolProvider {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32impl RustupToolProvider {
33 #[must_use]
35 pub fn new() -> Self {
36 Self
37 }
38
39 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 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 fn toolchain_path(toolchain: &str, platform: &Platform) -> PathBuf {
66 let host_triple = Self::host_triple(platform);
67 let toolchain_name = format!("{toolchain}-{host_triple}");
71 Self::rustup_home().join("toolchains").join(toolchain_name)
72 }
73
74 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 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 if let Some(p) = profile {
93 cmd.arg("--profile").arg(p);
94 }
95
96 for component in components {
98 cmd.arg("-c").arg(component);
99 }
100
101 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 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 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 self.install_toolchain(toolchain, profile.as_deref(), components, targets)
270 .await?;
271
272 let toolchain_dir = Self::toolchain_path(toolchain, &resolved.platform);
274 let bin_dir = toolchain_dir.join("bin");
275
276 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 let platform = Platform::new(Os::Darwin, Arch::Arm64);
357 assert_eq!(
358 RustupToolProvider::host_triple(&platform),
359 "aarch64-apple-darwin"
360 );
361
362 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 let platform = Platform::new(Os::Linux, Arch::Arm64);
371 assert_eq!(
372 RustupToolProvider::host_triple(&platform),
373 "aarch64-unknown-linux-gnu"
374 );
375
376 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 let digest2 = RustupToolProvider::compute_digest("1.83.0", Some("minimal"), &[], &[]);
440 assert_ne!(digest1, digest2);
441
442 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 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 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 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 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 let home = RustupToolProvider::rustup_home();
570 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 assert!(!provider.is_cached(&resolved, &options));
783 }
784}