1use 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
19pub struct RustupToolProvider;
24
25impl Default for RustupToolProvider {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl RustupToolProvider {
32 #[must_use]
34 pub fn new() -> Self {
35 Self
36 }
37
38 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 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 fn toolchain_path(toolchain: &str, platform: &Platform) -> PathBuf {
65 let host_triple = Self::host_triple(platform);
66 let toolchain_name = format!("{toolchain}-{host_triple}");
70 Self::rustup_home().join("toolchains").join(toolchain_name)
71 }
72
73 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 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 if let Some(p) = profile {
92 cmd.arg("--profile").arg(p);
93 }
94
95 for component in components {
97 cmd.arg("-c").arg(component);
98 }
99
100 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 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 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 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.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 assert!(!provider.is_cached(&resolved, &options));
745 }
746}