1use std::path::{Path, PathBuf};
4
5use semver::Version;
6
7use crate::{
8 toolchain::{Host, Installation, Toolchain, ToolchainError, UnfixableToolchain},
9 utils::{CommandError, parse_semver_version},
10};
11
12pub const CLI_MINIMUM_RUST_VERSION: &str = env!("CARGO_PKG_RUST_VERSION");
16
17#[derive(Debug, Clone)]
26pub struct RustToolchain {
27 minimum_version: String,
28}
29
30impl RustToolchain {
31 #[must_use]
33 pub fn new(minimum_version: &Version) -> Self {
34 Self {
35 minimum_version: minimum_version.to_string(),
36 }
37 }
38}
39
40impl Default for RustToolchain {
41 fn default() -> Self {
42 Self {
43 minimum_version: String::from(CLI_MINIMUM_RUST_VERSION),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Default)]
50pub struct RustToolchainInstallation {
51 set_default: Option<String>,
55 install_toolchain: Option<String>,
58 update_toolchain: Option<String>,
61 add_targets: Vec<(String, String)>,
63 add_components: Vec<(String, String)>,
65}
66
67impl RustToolchainInstallation {
68 fn require_default_install(&mut self, channel: impl Into<String>) {
69 self.set_default = Some(channel.into());
70 self.install_toolchain = None;
71 self.update_toolchain = None;
72 }
73
74 fn require_toolchain_install(&mut self, channel: impl Into<String>) {
75 if self.set_default.is_none() {
76 self.install_toolchain = Some(channel.into());
77 self.update_toolchain = None;
78 }
79 }
80
81 fn require_toolchain_update(&mut self, toolchain: String) {
82 if self.set_default.is_none() && self.install_toolchain.is_none() {
83 self.update_toolchain = Some(toolchain);
84 }
85 }
86
87 fn require_target(&mut self, toolchain: &str, target: String) {
88 self.add_targets.push((toolchain.to_owned(), target));
89 }
90
91 fn require_component(&mut self, toolchain: &str, component: String) {
92 self.add_components.push((toolchain.to_owned(), component));
93 }
94
95 #[must_use]
97 pub const fn has_actions(&self) -> bool {
98 self.set_default.is_some()
99 || self.install_toolchain.is_some()
100 || self.update_toolchain.is_some()
101 || !self.add_targets.is_empty()
102 || !self.add_components.is_empty()
103 }
104
105 #[must_use]
107 pub fn summary(&self) -> String {
108 let mut actions = Vec::new();
109 if let Some(channel) = &self.set_default {
110 actions.push(format!("install and select `rustup default {channel}`"));
111 }
112 if let Some(channel) = &self.install_toolchain {
113 actions.push(format!("install `rustup toolchain install {channel}`"));
114 }
115 if let Some(toolchain) = &self.update_toolchain {
116 actions.push(format!(
117 "update `{toolchain}` via `rustup update {toolchain}`"
118 ));
119 }
120 for (toolchain, target) in &self.add_targets {
121 actions.push(format!(
122 "add target via `rustup target add --toolchain {toolchain} {target}`"
123 ));
124 }
125 for (toolchain, component) in &self.add_components {
126 actions.push(format!(
127 "add component via `rustup component add --toolchain {toolchain} {component}`"
128 ));
129 }
130
131 if actions.is_empty() {
132 String::from("no automatic actions required")
133 } else {
134 actions.join(", ")
135 }
136 }
137}
138
139#[derive(Debug, thiserror::Error)]
141pub enum FailToInstallRustToolchain {
142 #[error("rustup is required for automatic Rust toolchain fixes but is not on PATH.")]
144 RustupNotFound,
145 #[error("Failed to install default Rust toolchain `{toolchain}`: {source}")]
147 SetDefault {
148 toolchain: String,
150 source: CommandError,
152 },
153 #[error("Failed to install Rust toolchain `{toolchain}`: {source}")]
155 InstallToolchain {
156 toolchain: String,
158 source: CommandError,
160 },
161 #[error("Failed to update Rust toolchain `{toolchain}`: {source}")]
163 UpdateToolchain {
164 toolchain: String,
166 source: CommandError,
168 },
169 #[error("Failed to add Rust target `{target}` to toolchain `{toolchain}`: {source}")]
171 AddTarget {
172 toolchain: String,
174 target: String,
176 source: CommandError,
178 },
179 #[error("Failed to add Rust component `{component}` to toolchain `{toolchain}`: {source}")]
181 AddComponent {
182 toolchain: String,
184 component: String,
186 source: CommandError,
188 },
189}
190
191#[derive(Debug, thiserror::Error)]
193enum RustParseError {
194 #[error("expected `<toolchain> (<reason>)` output")]
196 ActiveToolchain,
197 #[error("expected `rustc <version>` output")]
199 RustcVersion,
200 #[error("missing `host:` line")]
202 HostLine,
203 #[error(transparent)]
205 Version(#[from] crate::utils::VersionParseError),
206}
207
208impl Installation for RustToolchainInstallation {
209 type Error = FailToInstallRustToolchain;
210
211 async fn install(&self, host: &Host) -> Result<(), Self::Error> {
212 if !self.has_actions() {
213 return Ok(());
214 }
215
216 if host.which("rustup").await.is_err() {
217 return Err(FailToInstallRustToolchain::RustupNotFound);
218 }
219
220 if let Some(channel) = &self.set_default {
221 host.run("rustup", ["default", channel.as_str()])
222 .await
223 .map_err(|source| FailToInstallRustToolchain::SetDefault {
224 toolchain: channel.clone(),
225 source,
226 })?;
227 }
228
229 if let Some(channel) = &self.install_toolchain {
230 host.run("rustup", ["toolchain", "install", channel.as_str()])
231 .await
232 .map_err(|source| FailToInstallRustToolchain::InstallToolchain {
233 toolchain: channel.clone(),
234 source,
235 })?;
236 }
237
238 if let Some(toolchain) = &self.update_toolchain {
239 host.run("rustup", ["update", toolchain.as_str()])
240 .await
241 .map_err(|source| FailToInstallRustToolchain::UpdateToolchain {
242 toolchain: toolchain.clone(),
243 source,
244 })?;
245 }
246
247 for (toolchain, target) in &self.add_targets {
248 host.run(
249 "rustup",
250 [
251 "target",
252 "add",
253 "--toolchain",
254 toolchain.as_str(),
255 target.as_str(),
256 ],
257 )
258 .await
259 .map_err(|source| FailToInstallRustToolchain::AddTarget {
260 toolchain: toolchain.clone(),
261 target: target.clone(),
262 source,
263 })?;
264 }
265
266 for (toolchain, component) in &self.add_components {
267 host.run(
268 "rustup",
269 [
270 "component",
271 "add",
272 "--toolchain",
273 toolchain.as_str(),
274 component.as_str(),
275 ],
276 )
277 .await
278 .map_err(|source| FailToInstallRustToolchain::AddComponent {
279 toolchain: toolchain.clone(),
280 component: component.clone(),
281 source,
282 })?;
283 }
284
285 Ok(())
286 }
287}
288
289impl Toolchain for RustToolchain {
290 type Installation = RustToolchainInstallation;
291
292 async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
293 let availability = detect_rust_tool_availability(host).await;
294 ensure_minimum_rust_tools(availability)?;
295 check_rustup_proxies(host, availability)?;
296
297 let pin = read_toolchain_pin(host.cwd()).map_err(|malformed| {
298 ToolchainError::unfixable(
299 format!(
300 "{} is malformed: {}",
301 malformed.path.display(),
302 malformed.reason
303 ),
304 "rustup expects a `[toolchain]` table with a `channel` string and `components`/`targets` string arrays — fix the file or delete it.",
305 )
306 })?;
307
308 let mut installation = RustToolchainInstallation::default();
309 let selected =
310 select_toolchain(host, availability, pin.as_ref(), &mut installation).await?;
311 check_rustc_version(
312 host,
313 &self.minimum_version,
314 pin.as_ref(),
315 &selected,
316 &mut installation,
317 )
318 .await?;
319 check_required_targets_and_components(host, pin.as_ref(), &selected, &mut installation)
320 .await?;
321
322 installation
323 .has_actions()
324 .then_some(ToolchainError::fixable(installation))
325 .map_or(Ok(()), Err)
326 }
327}
328
329#[derive(Debug, Clone, Copy)]
330struct RustToolAvailability {
331 rustup_available: bool,
332 cargo_available: bool,
333 rustc_available: bool,
334}
335
336#[derive(Debug)]
338enum SelectedToolchain {
339 Rustup(String),
341 Standalone,
343}
344
345fn ensure_minimum_rust_tools(
346 availability: RustToolAvailability,
347) -> Result<(), ToolchainError<RustToolchainInstallation>> {
348 if !availability.rustup_available
349 && (!availability.cargo_available || !availability.rustc_available)
350 {
351 return Err(ToolchainError::unfixable(
352 "Rust toolchain is incomplete (`cargo` and/or `rustc` is missing from PATH).",
353 "Install rustup from https://rustup.rs, then run `rustup default stable`.",
354 ));
355 }
356 Ok(())
357}
358
359async fn detect_rust_tool_availability(host: &Host) -> RustToolAvailability {
360 RustToolAvailability {
361 rustup_available: host.which("rustup").await.is_ok(),
362 cargo_available: host.which("cargo").await.is_ok(),
363 rustc_available: host.which("rustc").await.is_ok(),
364 }
365}
366
367fn cargo_bin_dir(host: &Host) -> Option<PathBuf> {
370 if let Some(cargo_home) = host.env_string("CARGO_HOME") {
371 return Some(PathBuf::from(cargo_home).join("bin"));
372 }
373 host.home_dir().map(|home| home.join(".cargo/bin"))
374}
375
376pub(crate) fn rustup_toolchains_dir(host: &Host) -> Option<PathBuf> {
379 host.env_string("RUSTUP_HOME")
380 .map(PathBuf::from)
381 .or_else(|| host.home_dir().map(|home| home.join(".rustup")))
382 .map(|root| root.join("toolchains"))
383}
384
385fn check_rustup_proxies(
390 host: &Host,
391 availability: RustToolAvailability,
392) -> Result<(), ToolchainError<RustToolchainInstallation>> {
393 if (availability.cargo_available && availability.rustc_available)
394 || !availability.rustup_available
395 {
396 return Ok(());
397 }
398
399 let missing: Vec<&str> = [
400 ("cargo", availability.cargo_available),
401 ("rustc", availability.rustc_available),
402 ]
403 .into_iter()
404 .filter_map(|(name, present)| (!present).then_some(name))
405 .collect();
406
407 let Some(bin_dir) = cargo_bin_dir(host) else {
408 return Err(ToolchainError::unfixable(
409 format!(
410 "rustup is on PATH but its {} proxies are not, and no cargo home could be located.",
411 missing.join("`/`")
412 ),
413 "Reinstall rustup from https://rustup.rs so its proxies are installed, then ensure the directory is on PATH.",
414 ));
415 };
416
417 let absent: Vec<&str> = missing
418 .iter()
419 .copied()
420 .filter(|name| !bin_dir.join(tool_binary_name(name)).is_file())
421 .collect();
422 if absent.is_empty() {
423 return Err(ToolchainError::unfixable(
424 format!(
425 "rustup proxies exist in {} but that directory is not on PATH, so `{}` {} unreachable.",
426 bin_dir.display(),
427 missing.join("`, `"),
428 if missing.len() == 1 { "is" } else { "are" },
429 ),
430 format!(
431 "Add `{}` to PATH (e.g. `export PATH=\"{}:$PATH\"` in your shell profile).",
432 bin_dir.display(),
433 bin_dir.display()
434 ),
435 ));
436 }
437
438 Err(ToolchainError::unfixable(
439 format!(
440 "rustup is on PATH but the `{}` {} do not exist under {}.",
441 absent.join("`, `"),
442 if absent.len() == 1 {
443 "proxy"
444 } else {
445 "proxies"
446 },
447 bin_dir.display()
448 ),
449 "Re-run the rustup installer from https://rustup.rs (or `rustup-init`) so the proxies are created, then ensure the directory is on PATH.",
450 ))
451}
452
453fn tool_binary_name(name: &str) -> String {
454 if cfg!(windows) {
455 format!("{name}.exe")
456 } else {
457 name.to_string()
458 }
459}
460
461#[derive(Debug)]
465struct ToolchainPin {
466 channel: Option<String>,
468 targets: Vec<String>,
470 components: Vec<String>,
472}
473
474#[derive(Debug)]
476struct MalformedToolchainPin {
477 path: PathBuf,
478 reason: String,
479}
480
481fn read_toolchain_pin(dir: &Path) -> Result<Option<ToolchainPin>, MalformedToolchainPin> {
484 for ancestor in dir.ancestors() {
485 for name in ["rust-toolchain.toml", "rust-toolchain"] {
486 let path = ancestor.join(name);
487 if !path.is_file() {
488 continue;
489 }
490 let text = std::fs::read_to_string(&path).map_err(|error| MalformedToolchainPin {
491 path: path.clone(),
492 reason: format!("cannot be read: {error}"),
493 })?;
494 return parse_toolchain_pin(&text, name == "rust-toolchain.toml", &path).map(Some);
495 }
496 }
497 Ok(None)
498}
499
500fn parse_toolchain_pin(
501 text: &str,
502 toml_file: bool,
503 path: &Path,
504) -> Result<ToolchainPin, MalformedToolchainPin> {
505 let malformed = |reason: String| MalformedToolchainPin {
506 path: path.to_path_buf(),
507 reason,
508 };
509
510 let trimmed = text.trim();
511 if !toml_file && !trimmed.starts_with('[') && !trimmed.contains('=') {
512 return Ok(ToolchainPin {
514 channel: (!trimmed.is_empty()).then(|| trimmed.to_owned()),
515 targets: Vec::new(),
516 components: Vec::new(),
517 });
518 }
519
520 let document: toml::Value =
521 toml::from_str(text).map_err(|error| malformed(format!("invalid TOML: {error}")))?;
522 let table = match document.get("toolchain") {
523 Some(value) => value
524 .as_table()
525 .ok_or_else(|| malformed("`toolchain` must be a table".to_owned()))?,
526 None => document
529 .as_table()
530 .ok_or_else(|| malformed("the file must be a TOML table".to_owned()))?,
531 };
532
533 let mut pin = ToolchainPin {
534 channel: None,
535 targets: Vec::new(),
536 components: Vec::new(),
537 };
538 if let Some(channel) = table.get("channel") {
539 pin.channel = Some(
540 channel
541 .as_str()
542 .map(ToOwned::to_owned)
543 .ok_or_else(|| malformed("`toolchain.channel` must be a string".to_owned()))?,
544 );
545 }
546 if pin.channel.is_none() {
547 return Err(malformed("`toolchain.channel` is required".to_owned()));
549 }
550 for (key, slot) in [
551 ("targets", &mut pin.targets),
552 ("components", &mut pin.components),
553 ] {
554 let Some(value) = table.get(key) else {
555 continue;
556 };
557 let entries = value
558 .as_array()
559 .ok_or_else(|| malformed(format!("`toolchain.{key}` must be an array")))?;
560 for entry in entries {
561 slot.push(
562 entry.as_str().map(ToOwned::to_owned).ok_or_else(|| {
563 malformed(format!("`toolchain.{key}` entries must be strings"))
564 })?,
565 );
566 }
567 }
568 Ok(pin)
569}
570
571#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573enum ChannelKind {
574 Moving,
576 Dated,
578 Version,
581 Custom,
584}
585
586fn classify_channel(channel: &str) -> ChannelKind {
587 match channel {
588 "stable" | "beta" | "nightly" => ChannelKind::Moving,
589 _ if channel
590 .chars()
591 .next()
592 .is_some_and(|first| first.is_ascii_digit()) =>
593 {
594 ChannelKind::Version
595 }
596 _ if is_dated_channel(channel) => ChannelKind::Dated,
597 _ => ChannelKind::Custom,
598 }
599}
600
601fn is_dated_channel(channel: &str) -> bool {
602 let Some((name, date)) = channel.split_once('-') else {
603 return false;
604 };
605 matches!(name, "stable" | "beta" | "nightly") && is_iso_date(date)
606}
607
608fn is_iso_date(value: &str) -> bool {
609 let is_digits = |part: &str, len: usize| {
610 part.len() == len && part.bytes().all(|byte| byte.is_ascii_digit())
611 };
612 matches!(
613 value.split('-').collect::<Vec<_>>().as_slice(),
614 [year, month, day]
615 if is_digits(year, 4) && is_digits(month, 2) && is_digits(day, 2)
616 )
617}
618
619pub(crate) fn toolchain_is_rustup_managed(name: &str) -> bool {
623 let channel = name.split('-').next().unwrap_or(name);
624 matches!(channel, "stable" | "beta" | "nightly")
625 || channel
626 .chars()
627 .next()
628 .is_some_and(|first| first.is_ascii_digit())
629}
630
631pub(crate) async fn selected_rustup_toolchain(host: &Host) -> Result<String, UnfixableToolchain> {
638 if host.which("rustup").await.is_err() {
639 return Err(UnfixableToolchain::new(
640 "rustup is not installed, so no Rust toolchain is selected",
641 "Install rustup from https://rustup.rs, then run `rustup default stable`.",
642 ));
643 }
644 let output = host
645 .run("rustup", ["show", "active-toolchain"])
646 .await
647 .map_err(|error| {
648 UnfixableToolchain::new(
649 format!("rustup cannot resolve a toolchain for this directory: {error}"),
650 "Fix the `rust` doctor item first (the pinned or default toolchain is missing).",
651 )
652 })?;
653 parse_active_toolchain(&output).map_err(|error| {
654 UnfixableToolchain::new(
655 format!("Could not parse the active rustup toolchain: {error}"),
656 "Run `rustup show active-toolchain`; repair or reinstall rustup if it does not return a toolchain name.",
657 )
658 })
659}
660
661pub(crate) async fn installed_rustup_targets(
663 host: &Host,
664 toolchain: &str,
665) -> Result<Vec<String>, UnfixableToolchain> {
666 let output = host
667 .run(
668 "rustup",
669 ["target", "list", "--installed", "--toolchain", toolchain],
670 )
671 .await
672 .map_err(|error| {
673 UnfixableToolchain::new(
674 format!("Failed to list installed Rust targets for `{toolchain}`: {error}"),
675 "Run `rustup target list --installed`; if it fails, repair rustup with `rustup self update` or reinstall rustup.",
676 )
677 })?;
678 Ok(output
679 .lines()
680 .map(str::trim)
681 .filter(|line| !line.is_empty())
682 .map(ToOwned::to_owned)
683 .collect())
684}
685
686async fn installed_rustup_components(
691 host: &Host,
692 toolchain: &str,
693) -> Result<Vec<String>, UnfixableToolchain> {
694 let output = host
695 .run(
696 "rustup",
697 [
698 "component",
699 "list",
700 "--installed",
701 "--toolchain",
702 toolchain,
703 ],
704 )
705 .await
706 .map_err(|error| {
707 UnfixableToolchain::new(
708 format!("Failed to list installed Rust components for `{toolchain}`: {error}"),
709 "Run `rustup component list --installed`; if it fails, repair rustup with `rustup self update` or reinstall rustup.",
710 )
711 })?;
712 Ok(output
713 .lines()
714 .map(str::trim)
715 .filter(|line| !line.is_empty())
716 .map(ToOwned::to_owned)
717 .collect())
718}
719
720fn component_is_installed(installed: &str, component: &str, host_target: &str) -> bool {
725 installed == component || installed == format!("{component}-{host_target}")
726}
727
728#[derive(Debug, Clone)]
730pub struct RustTargetAdditions {
731 toolchain: String,
732 targets: Vec<String>,
733}
734
735impl RustTargetAdditions {
736 #[must_use]
738 pub const fn new(toolchain: String, targets: Vec<String>) -> Self {
739 Self { toolchain, targets }
740 }
741}
742
743#[derive(Debug, thiserror::Error)]
745pub enum FailToAddRustTargets {
746 #[error("rustup is required to add Rust targets but is not on PATH.")]
748 RustupNotFound,
749 #[error("Failed to add Rust target `{target}` to toolchain `{toolchain}`: {source}")]
751 AddTarget {
752 toolchain: String,
754 target: String,
756 source: CommandError,
758 },
759}
760
761impl Installation for RustTargetAdditions {
762 type Error = FailToAddRustTargets;
763
764 async fn install(&self, host: &Host) -> Result<(), Self::Error> {
765 if host.which("rustup").await.is_err() {
766 return Err(FailToAddRustTargets::RustupNotFound);
767 }
768 for target in &self.targets {
769 host.run(
770 "rustup",
771 [
772 "target",
773 "add",
774 "--toolchain",
775 self.toolchain.as_str(),
776 target.as_str(),
777 ],
778 )
779 .await
780 .map_err(|source| FailToAddRustTargets::AddTarget {
781 toolchain: self.toolchain.clone(),
782 target: target.clone(),
783 source,
784 })?;
785 }
786 Ok(())
787 }
788}
789
790#[derive(Debug, Clone)]
794pub struct SelectedToolchainTargets {
795 required: Vec<String>,
796}
797
798impl SelectedToolchainTargets {
799 #[must_use]
801 pub const fn new(required: Vec<String>) -> Self {
802 Self { required }
803 }
804}
805
806impl Toolchain for SelectedToolchainTargets {
807 type Installation = RustTargetAdditions;
808
809 async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
810 let toolchain = selected_rustup_toolchain(host).await?;
811 if !toolchain_is_rustup_managed(&toolchain) {
812 return Err(ToolchainError::unfixable(
813 format!(
814 "the selected toolchain `{toolchain}` is not rustup-managed, so targets cannot be verified or added"
815 ),
816 "The project pins a custom toolchain; install its targets through the toolchain's provider.",
817 ));
818 }
819 let installed = installed_rustup_targets(host, &toolchain).await?;
820 let missing: Vec<String> = self
821 .required
822 .iter()
823 .filter(|target| !installed.contains(*target))
824 .cloned()
825 .collect();
826 if missing.is_empty() {
827 Ok(())
828 } else {
829 Err(ToolchainError::fixable(RustTargetAdditions::new(
830 toolchain, missing,
831 )))
832 }
833 }
834}
835
836async fn select_toolchain(
837 host: &Host,
838 availability: RustToolAvailability,
839 pin: Option<&ToolchainPin>,
840 installation: &mut RustToolchainInstallation,
841) -> Result<SelectedToolchain, ToolchainError<RustToolchainInstallation>> {
842 if !availability.rustup_available {
843 return Ok(SelectedToolchain::Standalone);
844 }
845
846 match host.run("rustup", ["show", "active-toolchain"]).await {
847 Ok(output) => parse_active_toolchain(&output)
848 .map(SelectedToolchain::Rustup)
849 .map_err(|error| {
850 ToolchainError::unfixable(
851 format!("Could not parse the active rustup toolchain: {error}"),
852 "Run `rustup show active-toolchain`; repair or reinstall rustup if it does not return a toolchain name.",
853 )
854 }),
855 Err(error) => {
856 let error_message = error.to_string();
857 if error_message.contains("not installed") {
858 missing_pinned_toolchain(pin, installation, &error_message)
859 } else if is_no_active_toolchain_error(&error_message) {
860 installation.require_default_install("stable");
861 Err(ToolchainError::fixable(std::mem::take(installation)))
862 } else {
863 Err(ToolchainError::unfixable(
864 format!(
865 "rustup is installed but cannot report an active toolchain: {error_message}"
866 ),
867 "Run `rustup self update` and `rustup toolchain install stable`; if that fails, reinstall rustup from https://rustup.rs.",
868 ))
869 }
870 }
871 }
872}
873
874fn missing_pinned_toolchain(
879 pin: Option<&ToolchainPin>,
880 installation: &mut RustToolchainInstallation,
881 error_message: &str,
882) -> Result<SelectedToolchain, ToolchainError<RustToolchainInstallation>> {
883 let Some(channel) = pin.and_then(|pin| pin.channel.clone()) else {
884 installation.require_default_install("stable");
887 return Err(ToolchainError::fixable(std::mem::take(installation)));
888 };
889 if classify_channel(&channel) == ChannelKind::Custom {
890 return Err(ToolchainError::unfixable(
891 format!("rust-toolchain pin `{channel}` is not a rustup channel: {error_message}"),
892 if channel == "esp" {
893 "Install the Espressif Rust toolchain with `espup install` (install `espup` first with `cargo install espup`)."
894 } else {
895 "Install the toolchain through its provider; rustup only installs stable/beta/nightly and released versions."
896 },
897 ));
898 }
899 installation.require_toolchain_install(channel);
900 Err(ToolchainError::fixable(std::mem::take(installation)))
901}
902
903async fn check_rustc_version(
904 host: &Host,
905 minimum_version: &str,
906 pin: Option<&ToolchainPin>,
907 selected: &SelectedToolchain,
908 installation: &mut RustToolchainInstallation,
909) -> Result<(), ToolchainError<RustToolchainInstallation>> {
910 let version_output = host.run("rustc", ["--version"]).await.map_err(|error| {
911 let error_message = error.to_string();
912 rustc_run_error(pin, selected, &error_message)
913 })?;
914 let installed_version = parse_installed_rustc_version(&version_output)?;
915 let required_version = parse_required_rustc_version(minimum_version)?;
916
917 if installed_version >= required_version {
918 return Ok(());
919 }
920
921 let channel = pin.and_then(|pin| pin.channel.as_deref());
922 match (selected, channel.map(classify_channel)) {
923 (SelectedToolchain::Standalone, _) => Err(ToolchainError::unfixable(
924 format!(
925 "Detected Rust {installed_version}, but the project requires at least Rust {required_version}."
926 ),
927 format!(
928 "Install Rust {required_version} or newer. Recommended: install rustup from https://rustup.rs, then run `rustup update stable`."
929 ),
930 )),
931 (SelectedToolchain::Rustup(_), Some(ChannelKind::Custom)) => {
932 Err(ToolchainError::unfixable(
933 format!(
934 "The pinned toolchain `{}` provides Rust {installed_version}, below the required {required_version}.",
935 channel.unwrap_or_default()
936 ),
937 if channel == Some("esp") {
938 String::from("Update the Espressif Rust toolchain with `espup update`.")
939 } else {
940 String::from(
941 "Update the pinned toolchain through its provider, or raise the floor in `rust-toolchain.toml`.",
942 )
943 },
944 ))
945 }
946 (SelectedToolchain::Rustup(_), Some(ChannelKind::Version | ChannelKind::Dated)) => {
947 Err(ToolchainError::unfixable(
948 format!(
949 "The project pins Rust toolchain `{}` (providing {installed_version}), below the required {required_version}.",
950 channel.unwrap_or_default()
951 ),
952 format!(
953 "Update the `channel` in `rust-toolchain.toml` to a release providing Rust {required_version} or newer."
954 ),
955 ))
956 }
957 (SelectedToolchain::Rustup(name), Some(ChannelKind::Moving)) => {
958 installation.require_toolchain_update(name.clone());
959 Ok(())
960 }
961 (SelectedToolchain::Rustup(name), None) => {
962 if toolchain_is_rustup_managed(name) && !is_version_or_dated_name(name) {
963 installation.require_toolchain_update(name.clone());
964 } else if toolchain_is_rustup_managed(name) {
965 installation.require_default_install("stable");
968 } else {
969 return Err(ToolchainError::unfixable(
970 format!(
971 "The default toolchain `{name}` provides Rust {installed_version}, below the required {required_version}."
972 ),
973 format!(
974 "`{name}` is a custom toolchain; select a rustup channel with `rustup default stable` or update the custom toolchain through its provider."
975 ),
976 ));
977 }
978 Ok(())
979 }
980 }
981}
982
983fn is_version_or_dated_name(name: &str) -> bool {
986 let mut segments = name.split('-');
987 let channel = segments.next().unwrap_or(name);
988 if channel
989 .chars()
990 .next()
991 .is_some_and(|first| first.is_ascii_digit())
992 {
993 return true;
994 }
995 if !matches!(channel, "stable" | "beta" | "nightly") {
996 return false;
997 }
998 let is_digits = |segment: Option<&str>, len: usize| {
1000 segment.is_some_and(|segment| {
1001 segment.len() == len && segment.bytes().all(|byte| byte.is_ascii_digit())
1002 })
1003 };
1004 is_digits(segments.next(), 4) && is_digits(segments.next(), 2) && is_digits(segments.next(), 2)
1005}
1006
1007fn rustc_run_error(
1008 pin: Option<&ToolchainPin>,
1009 selected: &SelectedToolchain,
1010 error_message: &str,
1011) -> ToolchainError<RustToolchainInstallation> {
1012 let detail = format!("`rustc` exists on PATH but failed to run: {error_message}");
1013 match (pin.and_then(|pin| pin.channel.as_deref()), selected) {
1014 (Some(channel), SelectedToolchain::Rustup(_)) => ToolchainError::unfixable(
1015 detail,
1016 format!(
1017 "Reinstall the pinned toolchain with `rustup toolchain install {channel} --force`."
1018 ),
1019 ),
1020 (None, SelectedToolchain::Rustup(name)) => ToolchainError::unfixable(
1021 detail,
1022 format!("Reinstall the toolchain with `rustup toolchain install {name} --force`."),
1023 ),
1024 (_, SelectedToolchain::Standalone) => {
1025 ToolchainError::unfixable(detail, "Reinstall Rust via rustup from https://rustup.rs.")
1026 }
1027 }
1028}
1029
1030fn parse_installed_rustc_version(
1031 version_output: &str,
1032) -> Result<Version, ToolchainError<RustToolchainInstallation>> {
1033 parse_rustc_version(version_output).map_err(|error| {
1034 ToolchainError::unfixable(
1035 format!(
1036 "Failed to parse `rustc --version` output `{}`: {error}",
1037 version_output.trim()
1038 ),
1039 "Run `rustc --version` manually. If output is malformed, reinstall rustup from https://rustup.rs.",
1040 )
1041 })
1042}
1043
1044fn parse_required_rustc_version(
1045 minimum_version: &str,
1046) -> Result<Version, ToolchainError<RustToolchainInstallation>> {
1047 parse_semver_version(minimum_version).map_err(|error| {
1048 ToolchainError::unfixable(
1049 format!("Invalid required Rust version `{minimum_version}`: {error}"),
1050 "Reinstall waterui-cli from source to restore a valid embedded Rust requirement.",
1051 )
1052 })
1053}
1054
1055async fn check_required_targets_and_components(
1056 host: &Host,
1057 pin: Option<&ToolchainPin>,
1058 selected: &SelectedToolchain,
1059 installation: &mut RustToolchainInstallation,
1060) -> Result<(), ToolchainError<RustToolchainInstallation>> {
1061 let SelectedToolchain::Rustup(name) = selected else {
1062 return Ok(());
1063 };
1064 if !toolchain_is_rustup_managed(name) {
1065 return Ok(());
1068 }
1069
1070 let host_target = host
1071 .run("rustc", ["-vV"])
1072 .await
1073 .map_err(|error| {
1074 ToolchainError::unfixable(
1075 format!("`rustc -vV` failed: {error}"),
1076 "Run `rustc -vV` manually; if it fails, reinstall rustup from https://rustup.rs.",
1077 )
1078 })
1079 .and_then(|output| {
1080 parse_host_target(&output).map_err(|error| {
1081 ToolchainError::unfixable(
1082 format!("Could not parse host target from `rustc -vV`: {error}"),
1083 "Ensure `rustc -vV` includes a `host: <target>` line; reinstall rustup if the output is incomplete.",
1084 )
1085 })
1086 })?;
1087
1088 let mut required_targets: Vec<String> = vec![host_target.clone()];
1089 if let Some(pin) = pin {
1090 for target in &pin.targets {
1091 if !required_targets.contains(target) {
1092 required_targets.push(target.clone());
1093 }
1094 }
1095 }
1096
1097 let installed_targets = installed_rustup_targets(host, name).await?;
1098 for target in required_targets {
1099 if !installed_targets.contains(&target) {
1100 installation.require_target(name, target);
1101 }
1102 }
1103
1104 if let Some(pin) = pin
1105 && !pin.components.is_empty()
1106 {
1107 let installed_components = installed_rustup_components(host, name).await?;
1108 for component in &pin.components {
1109 if !installed_components
1110 .iter()
1111 .any(|installed| component_is_installed(installed, component, &host_target))
1112 {
1113 installation.require_component(name, component.clone());
1114 }
1115 }
1116 }
1117
1118 Ok(())
1119}
1120
1121fn parse_active_toolchain(output: &str) -> Result<String, RustParseError> {
1122 output
1123 .split_whitespace()
1124 .next()
1125 .filter(|toolchain| !toolchain.is_empty())
1126 .map(ToOwned::to_owned)
1127 .ok_or(RustParseError::ActiveToolchain)
1128}
1129
1130fn parse_rustc_version(output: &str) -> Result<Version, RustParseError> {
1131 let version_token = output
1132 .split_whitespace()
1133 .nth(1)
1134 .ok_or(RustParseError::RustcVersion)?;
1135 Ok(parse_semver_version(version_token)?)
1136}
1137
1138fn parse_host_target(output: &str) -> Result<String, RustParseError> {
1139 output
1140 .lines()
1141 .find_map(|line| {
1142 line.strip_prefix("host:")
1143 .map(str::trim)
1144 .filter(|target| !target.is_empty())
1145 .map(ToOwned::to_owned)
1146 })
1147 .ok_or(RustParseError::HostLine)
1148}
1149
1150fn is_no_active_toolchain_error(error: &str) -> bool {
1151 let normalized = error.to_ascii_lowercase();
1152 normalized.contains("no active toolchain") || normalized.contains("no default toolchain")
1153}
1154
1155pub async fn nightly_toolchain_with_rust_src(host: &Host) -> eyre::Result<String> {
1172 let list = host
1173 .run("rustup", ["toolchain", "list"])
1174 .await
1175 .map_err(|error| {
1176 eyre::eyre!(
1177 "Android preview needs a nightly Rust toolchain to build `std` from source, \
1178 and `rustup toolchain list` failed: {error}"
1179 )
1180 })?;
1181 let host_triple = target_lexicon::Triple::host().to_string();
1182 let Some(toolchain) = pick_nightly(&list, &host_triple) else {
1183 eyre::bail!(
1184 "Android preview needs a nightly Rust toolchain to build `std` from source. \
1185 Install one with `rustup toolchain install nightly --component rust-src`."
1186 );
1187 };
1188
1189 let components = host
1190 .run(
1191 "rustup",
1192 [
1193 "component",
1194 "list",
1195 "--toolchain",
1196 &toolchain,
1197 "--installed",
1198 ],
1199 )
1200 .await
1201 .map_err(|error| {
1202 eyre::eyre!("Failed to list components of Rust toolchain `{toolchain}`: {error}")
1203 })?;
1204 let has_rust_src = components
1205 .lines()
1206 .map(str::trim)
1207 .any(|line| line == "rust-src" || line.starts_with("rust-src "));
1208 if !has_rust_src {
1209 eyre::bail!(
1210 "Android preview needs the `rust-src` component on `{toolchain}` to build `std` from source. \
1211 Install it with `rustup component add --toolchain {toolchain} rust-src`."
1212 );
1213 }
1214 Ok(toolchain)
1215}
1216
1217pub async fn rustc_verbose_version(host: &Host, toolchain: &str) -> eyre::Result<String> {
1224 host.run("rustup", ["run", toolchain, "rustc", "-vV"])
1225 .await
1226 .map_err(|error| {
1227 eyre::eyre!("Failed to read `rustc -vV` of Rust toolchain `{toolchain}`: {error}")
1228 })
1229}
1230
1231fn pick_nightly(list_output: &str, host_triple: &str) -> Option<String> {
1235 let default_nightly = format!("nightly-{host_triple}");
1236 let suffix = format!("-{host_triple}");
1237 let mut dated = Vec::new();
1238 for name in list_output
1239 .lines()
1240 .filter_map(|line| line.split_whitespace().next())
1241 {
1242 if name == default_nightly {
1243 return Some(name.to_string());
1244 }
1245 let Some(date) = name
1246 .strip_prefix("nightly-")
1247 .and_then(|rest| rest.strip_suffix(&suffix))
1248 else {
1249 continue;
1250 };
1251 let mut fields = date.split('-');
1255 let is_dated = matches!(
1256 (fields.next(), fields.next(), fields.next(), fields.next()),
1257 (Some(year), Some(month), Some(day), None)
1258 if year.len() == 4 && month.len() == 2 && day.len() == 2
1259 && year.bytes().chain(month.bytes()).chain(day.bytes())
1260 .all(|byte| byte.is_ascii_digit())
1261 );
1262 if is_dated {
1263 dated.push(name.to_string());
1264 }
1265 }
1266 dated.sort_unstable();
1267 dated.pop()
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272 use semver::Version;
1273
1274 use super::{
1275 ChannelKind, RustToolchainInstallation, classify_channel, component_is_installed,
1276 parse_active_toolchain, parse_host_target, parse_rustc_version, pick_nightly,
1277 };
1278
1279 #[test]
1280 fn pick_nightly_prefers_the_plain_channel_then_the_newest_date() {
1281 let host = "aarch64-apple-darwin";
1282 let list = "stable-aarch64-apple-darwin (default)\nnightly-aarch64-apple-darwin\nnightly-2026-05-28-aarch64-apple-darwin\n";
1283 assert_eq!(
1284 pick_nightly(list, host).as_deref(),
1285 Some("nightly-aarch64-apple-darwin")
1286 );
1287
1288 let dated =
1289 "nightly-2026-05-28-aarch64-apple-darwin\nnightly-2026-09-09-aarch64-apple-darwin\n";
1290 assert_eq!(
1291 pick_nightly(dated, host).as_deref(),
1292 Some("nightly-2026-09-09-aarch64-apple-darwin")
1293 );
1294
1295 assert_eq!(pick_nightly("stable-aarch64-apple-darwin\n", host), None);
1296 }
1297
1298 #[test]
1299 fn pick_nightly_ignores_custom_toolchains_shaped_like_dated_ones() {
1300 let host = "aarch64-apple-darwin";
1301 let list = "nightly-2026-05-28-aarch64-apple-darwin\nnightly-zzz-aarch64-apple-darwin\n";
1304 assert_eq!(
1305 pick_nightly(list, host).as_deref(),
1306 Some("nightly-2026-05-28-aarch64-apple-darwin")
1307 );
1308 assert_eq!(
1309 pick_nightly("nightly-zzz-aarch64-apple-darwin\n", host),
1310 None
1311 );
1312 }
1313
1314 #[test]
1315 fn parse_rustc_version_accepts_prerelease() {
1316 let parsed =
1317 parse_rustc_version("rustc 1.88.0-nightly (d9a5f4fa4 2026-01-01)").expect("version");
1318 assert_eq!(
1319 parsed,
1320 Version::parse("1.88.0-nightly").expect("expected semver")
1321 );
1322 }
1323
1324 #[test]
1325 fn parse_active_toolchain_preserves_selected_channel() {
1326 let toolchain = parse_active_toolchain("nightly-aarch64-apple-darwin (default)")
1327 .expect("active toolchain");
1328 assert_eq!(toolchain, "nightly-aarch64-apple-darwin");
1329 }
1330
1331 #[test]
1332 fn parse_host_target_extracts_host_line() {
1333 let output = "rustc 1.88.0 (aabbcc 2026-01-01)\nbinary: rustc\nhost: x86_64-unknown-linux-gnu\nrelease: 1.88.0\n";
1334 let host = parse_host_target(output).expect("host target");
1335 assert_eq!(host, "x86_64-unknown-linux-gnu");
1336 }
1337
1338 #[test]
1339 fn channel_classification() {
1340 assert_eq!(classify_channel("stable"), ChannelKind::Moving);
1341 assert_eq!(classify_channel("nightly"), ChannelKind::Moving);
1342 assert_eq!(classify_channel("nightly-2026-01-15"), ChannelKind::Dated);
1343 assert_eq!(classify_channel("1.85"), ChannelKind::Version);
1344 assert_eq!(classify_channel("1.85.0"), ChannelKind::Version);
1345 assert_eq!(classify_channel("esp"), ChannelKind::Custom);
1346 assert_eq!(classify_channel("stage0"), ChannelKind::Custom);
1347 }
1348
1349 #[test]
1350 fn component_matching_accepts_target_suffix() {
1351 let host = "aarch64-apple-darwin";
1352 assert!(component_is_installed(
1353 "clippy-aarch64-apple-darwin",
1354 "clippy",
1355 host
1356 ));
1357 assert!(component_is_installed("rust-src", "rust-src", host));
1358 assert!(!component_is_installed("rustfmt-preview", "rustfmt", host));
1359 assert!(!component_is_installed(
1360 "cargo-aarch64-apple-darwin",
1361 "clippy",
1362 host
1363 ));
1364 assert!(!component_is_installed(
1365 "clippy-x86_64-unknown-linux-gnu",
1366 "clippy",
1367 host
1368 ));
1369 }
1370
1371 #[test]
1372 fn installation_summary_lists_actions() {
1373 let mut installation = RustToolchainInstallation::default();
1374 installation.require_toolchain_update(String::from("nightly-aarch64-apple-darwin"));
1375 installation.require_target(
1376 "stable-aarch64-apple-darwin",
1377 String::from("x86_64-unknown-linux-gnu"),
1378 );
1379 installation.require_component("stable-aarch64-apple-darwin", String::from("clippy"));
1380 let summary = installation.summary();
1381 assert!(summary.contains("rustup update nightly-aarch64-apple-darwin"));
1382 assert!(summary.contains(
1383 "rustup target add --toolchain stable-aarch64-apple-darwin x86_64-unknown-linux-gnu"
1384 ));
1385 assert!(
1386 summary.contains("rustup component add --toolchain stable-aarch64-apple-darwin clippy")
1387 );
1388 }
1389}
1390
1391#[cfg(test)]
1392mod host_tests {
1393 use super::{
1394 CLI_MINIMUM_RUST_VERSION, RustToolchain, nightly_toolchain_with_rust_src,
1395 rustc_verbose_version, tool_binary_name,
1396 };
1397 use crate::toolchain::testing::TestMachine;
1398 use crate::toolchain::{Installation, Toolchain, ToolchainError};
1399
1400 const FAKE_TARGET: &str = "wasm32test-test-none";
1401
1402 fn complete_machine() -> TestMachine {
1404 let machine = TestMachine::new();
1405 for tool in ["rustup", "cargo", "rustc"] {
1406 machine.install(tool);
1407 }
1408 machine
1409 }
1410
1411 fn complete_vars() -> Vec<(String, String)> {
1415 vec![
1416 (
1417 String::from("WATERUI_FAKE_RUSTC_VERSION"),
1418 format!("{CLI_MINIMUM_RUST_VERSION}.0"),
1419 ),
1420 (
1421 String::from("WATERUI_FAKE_RUSTC_HOST"),
1422 FAKE_TARGET.to_string(),
1423 ),
1424 (
1425 String::from("WATERUI_FAKE_RUSTUP_ACTIVE_TOOLCHAIN"),
1426 format!("stable-{FAKE_TARGET} (default)"),
1427 ),
1428 (
1429 String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
1430 FAKE_TARGET.to_string(),
1431 ),
1432 ]
1433 }
1434
1435 #[test]
1436 fn check_unfixable_when_no_rust_tools_exist() {
1437 let machine = TestMachine::new();
1438 let host = machine.host(Vec::<(String, String)>::new());
1439 let result = smol::block_on(RustToolchain::default().check(&host));
1440 assert!(
1441 matches!(result, Err(ToolchainError::Unfixable(_))),
1442 "bare host must report an unfixable Rust toolchain: {result:?}"
1443 );
1444 }
1445
1446 #[test]
1447 fn check_ok_on_complete_fake_toolchain() {
1448 let machine = complete_machine();
1449 let host = machine.host(complete_vars());
1450 smol::block_on(RustToolchain::default().check(&host))
1451 .expect("complete fake toolchain must be ok");
1452 }
1453
1454 #[test]
1455 fn check_fixable_when_rustc_too_old() {
1456 let machine = complete_machine();
1457 let mut vars = complete_vars();
1458 vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTC_VERSION");
1459 vars.push((
1460 String::from("WATERUI_FAKE_RUSTC_VERSION"),
1461 String::from("1.0.0"),
1462 ));
1463 let host = machine.host(vars);
1464 let result = smol::block_on(RustToolchain::default().check(&host));
1465 assert!(
1466 matches!(result, Err(ToolchainError::Fixable(_))),
1467 "outdated rustc under rustup must be fixable: {result:?}"
1468 );
1469 }
1470
1471 #[test]
1472 fn check_unfixable_when_rustc_too_old_without_rustup() {
1473 let machine = TestMachine::new();
1474 machine.install("cargo");
1475 machine.install("rustc");
1476 let host = machine.host([(
1477 String::from("WATERUI_FAKE_RUSTC_VERSION"),
1478 String::from("1.0.0"),
1479 )]);
1480 let result = smol::block_on(RustToolchain::default().check(&host));
1481 assert!(
1482 matches!(result, Err(ToolchainError::Unfixable(_))),
1483 "outdated rustc without rustup cannot be fixed automatically: {result:?}"
1484 );
1485 }
1486
1487 #[test]
1488 fn check_fixable_when_no_active_toolchain() {
1489 let machine = complete_machine();
1490 let mut vars = complete_vars();
1491 vars.push((
1492 String::from("WATERUI_FAKE_RUSTUP_NO_ACTIVE_TOOLCHAIN"),
1493 String::from("1"),
1494 ));
1495 let host = machine.host(vars);
1496 let result = smol::block_on(RustToolchain::default().check(&host));
1497 assert!(
1498 matches!(result, Err(ToolchainError::Fixable(_))),
1499 "rustup without an active toolchain must plan a default install: {result:?}"
1500 );
1501 }
1502
1503 #[test]
1504 fn check_fixable_when_host_target_not_installed() {
1505 let machine = complete_machine();
1506 let mut vars = complete_vars();
1507 vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS");
1508 vars.push((
1509 String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
1510 String::from("some-other-target"),
1511 ));
1512 let host = machine.host(vars);
1513 let result = smol::block_on(RustToolchain::default().check(&host));
1514 assert!(
1515 matches!(result, Err(ToolchainError::Fixable(_))),
1516 "missing host target must plan `rustup target add`: {result:?}"
1517 );
1518 }
1519
1520 #[test]
1521 fn check_fixable_when_pinned_toolchain_missing() {
1522 let machine = complete_machine();
1523 machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"1.90\"\n");
1524 let mut vars = complete_vars();
1525 vars.push((
1526 String::from("WATERUI_FAKE_RUSTUP_TOOLCHAIN_NOT_INSTALLED"),
1527 String::from("1.90"),
1528 ));
1529 let host = machine.host(vars);
1530 let result = smol::block_on(RustToolchain::default().check(&host));
1531 match &result {
1532 Err(ToolchainError::Fixable(installation)) => {
1533 assert!(
1534 installation
1535 .summary()
1536 .contains("rustup toolchain install 1.90"),
1537 "the repair must install the pinned channel: {}",
1538 installation.summary()
1539 );
1540 }
1541 other => panic!("missing pinned toolchain must be fixable: {other:?}"),
1542 }
1543 }
1544
1545 #[test]
1546 fn check_unfixable_when_pinned_custom_toolchain_missing() {
1547 let machine = complete_machine();
1548 machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"esp\"\n");
1549 let mut vars = complete_vars();
1550 vars.push((
1551 String::from("WATERUI_FAKE_RUSTUP_TOOLCHAIN_NOT_INSTALLED"),
1552 String::from("esp"),
1553 ));
1554 let host = machine.host(vars);
1555 let result = smol::block_on(RustToolchain::default().check(&host));
1556 match &result {
1557 Err(ToolchainError::Unfixable(error)) => {
1558 assert!(
1559 error.suggestion().contains("espup install"),
1560 "an `esp` pin must name the espup repair: {}",
1561 error.suggestion()
1562 );
1563 }
1564 other => panic!("missing custom toolchain must be manual: {other:?}"),
1565 }
1566 }
1567
1568 #[test]
1569 fn check_unfixable_when_version_pin_below_floor() {
1570 let machine = complete_machine();
1571 machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"1.50\"\n");
1572 let mut vars = complete_vars();
1573 vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTC_VERSION");
1574 vars.push((
1575 String::from("WATERUI_FAKE_RUSTC_VERSION"),
1576 String::from("1.50.0"),
1577 ));
1578 let host = machine.host(vars);
1579 let result = smol::block_on(RustToolchain::default().check(&host));
1580 match &result {
1581 Err(ToolchainError::Unfixable(error)) => {
1582 assert!(
1583 error.message().contains("1.50"),
1584 "the pin must be named in the diagnostic: {}",
1585 error.message()
1586 );
1587 }
1588 other => panic!("a version pin below the floor must be manual: {other:?}"),
1589 }
1590 }
1591
1592 #[test]
1593 fn nightly_with_rust_src_returns_the_selected_toolchain() {
1594 let machine = TestMachine::new();
1595 machine.install("rustup");
1596 let host_triple = target_lexicon::Triple::host().to_string();
1597 let nightly = format!("nightly-{host_triple}");
1598 machine.file(
1599 "home/.fake-rustup-toolchains",
1600 &format!("stable-{host_triple}\n{nightly}\n"),
1601 );
1602 machine.respond("RUSTUP_INSTALLED_COMPONENTS", "cargo\nrust-src\n");
1603 let host = machine.host(Vec::<(String, String)>::new());
1604 let toolchain = smol::block_on(nightly_toolchain_with_rust_src(&host))
1605 .expect("a nightly carrying rust-src must be selected");
1606 assert_eq!(toolchain, nightly);
1607 }
1608
1609 #[test]
1610 fn nightly_without_rust_src_fails_with_the_exact_component_command() {
1611 let machine = TestMachine::new();
1612 machine.install("rustup");
1613 let host_triple = target_lexicon::Triple::host().to_string();
1614 let nightly = format!("nightly-{host_triple}");
1615 machine.file(
1616 "home/.fake-rustup-toolchains",
1617 &format!("stable-{host_triple}\n{nightly}\n"),
1618 );
1619 let host = machine.host(Vec::<(String, String)>::new());
1622 let error = smol::block_on(nightly_toolchain_with_rust_src(&host))
1623 .expect_err("missing rust-src must fail");
1624 assert!(
1625 error.to_string().contains(&format!(
1626 "rustup component add --toolchain {nightly} rust-src"
1627 )),
1628 "the error must name the exact install command: {error}"
1629 );
1630 }
1631
1632 #[test]
1633 fn rustc_verbose_version_proxies_through_rustup_run() {
1634 let machine = TestMachine::new();
1635 machine.install("rustup");
1636 machine.install("rustc");
1637 let host = machine.host([(
1638 String::from("WATERUI_FAKE_RUSTC_HOST"),
1639 String::from("aarch64-apple-darwin"),
1640 )]);
1641 let version = smol::block_on(rustc_verbose_version(&host, "nightly-fake"))
1642 .expect("`rustup run` must dispatch to the sibling rustc");
1643 assert!(
1644 version.contains("host: aarch64-apple-darwin"),
1645 "the toolchain's `rustc -vV` identity must come back: {version}"
1646 );
1647 }
1648
1649 #[test]
1650 fn check_fixable_when_pinned_component_missing() {
1651 let machine = complete_machine();
1652 machine.file(
1653 "rust-toolchain.toml",
1654 "[toolchain]\nchannel = \"stable\"\ncomponents = [\"clippy\", \"rustfmt\"]\n",
1655 );
1656 let host = machine.host(complete_vars());
1657 let result = smol::block_on(RustToolchain::default().check(&host));
1658 match &result {
1659 Err(ToolchainError::Fixable(installation)) => {
1660 let summary = installation.summary();
1661 assert!(
1662 summary.contains("rustup component add") && summary.contains("clippy"),
1663 "missing pin components must plan component add: {summary}"
1664 );
1665 }
1666 other => panic!("missing pin components must be fixable: {other:?}"),
1667 }
1668 }
1669
1670 #[test]
1671 fn check_ok_when_pinned_components_installed() {
1672 let machine = complete_machine();
1673 machine.file(
1674 "rust-toolchain.toml",
1675 "[toolchain]\nchannel = \"stable\"\ncomponents = [\"clippy\"]\ntargets = [\"wasm32test-test-none\"]\n",
1676 );
1677 let mut vars = complete_vars();
1678 vars.push((
1679 String::from("WATERUI_FAKE_RUSTUP_INSTALLED_COMPONENTS"),
1680 String::from("clippy-wasm32test-test-none"),
1681 ));
1682 let host = machine.host(vars);
1683 smol::block_on(RustToolchain::default().check(&host))
1684 .expect("pin-declared targets and components installed must be ok");
1685 }
1686
1687 #[test]
1688 fn check_unfixable_when_proxies_missing_from_path() {
1689 let machine = TestMachine::new();
1690 machine.install("rustup");
1691 machine.file(
1694 format!("home/.cargo/bin/{}", tool_binary_name("cargo")),
1695 "proxy",
1696 );
1697 machine.file(
1698 format!("home/.cargo/bin/{}", tool_binary_name("rustc")),
1699 "proxy",
1700 );
1701 let host = machine.host(Vec::<(String, String)>::new());
1702 let result = smol::block_on(RustToolchain::default().check(&host));
1703 match &result {
1704 Err(ToolchainError::Unfixable(error)) => {
1705 assert!(
1706 error.message().contains("not on PATH"),
1707 "the PATH gap must be diagnosed: {}",
1708 error.message()
1709 );
1710 }
1711 other => panic!("proxies off PATH must be a manual diagnosis: {other:?}"),
1712 }
1713 }
1714
1715 #[test]
1716 fn check_unfixable_when_proxies_never_installed() {
1717 let machine = TestMachine::new();
1718 machine.install("rustup");
1719 let host = machine.host(Vec::<(String, String)>::new());
1720 let result = smol::block_on(RustToolchain::default().check(&host));
1721 match &result {
1722 Err(ToolchainError::Unfixable(error)) => {
1723 assert!(
1724 error.suggestion().contains("rustup"),
1725 "missing proxies must name the reinstall: {}",
1726 error.suggestion()
1727 );
1728 }
1729 other => panic!("absent proxies must be a manual diagnosis: {other:?}"),
1730 }
1731 }
1732
1733 #[test]
1734 fn install_repairs_missing_pinned_toolchain_and_recheck_passes() {
1735 let machine = complete_machine();
1736 machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"1.90\"\n");
1737 let mut vars = complete_vars();
1738 vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTUP_ACTIVE_TOOLCHAIN");
1739 vars.push((
1740 String::from("WATERUI_FAKE_RUSTUP_TOOLCHAIN_NOT_INSTALLED"),
1741 String::from("1.90"),
1742 ));
1743 let host = machine.host(vars);
1744
1745 let Err(ToolchainError::Fixable(installation)) =
1746 smol::block_on(RustToolchain::default().check(&host))
1747 else {
1748 panic!("missing pinned toolchain must be fixable");
1749 };
1750 smol::block_on(installation.install(&host)).expect("fake rustup install must succeed");
1751
1752 smol::block_on(RustToolchain::default().check(&host))
1753 .expect("after `rustup toolchain install`, the check must pass");
1754 }
1755}