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 project_rustup_toolchain(
672 project_root: &Path,
673) -> Result<String, UnfixableToolchain> {
674 selected_rustup_toolchain(&Host::current().with_cwd(project_root)).await
675}
676
677pub(crate) async fn installed_rustup_targets(
679 host: &Host,
680 toolchain: &str,
681) -> Result<Vec<String>, UnfixableToolchain> {
682 let output = host
683 .run(
684 "rustup",
685 ["target", "list", "--installed", "--toolchain", toolchain],
686 )
687 .await
688 .map_err(|error| {
689 UnfixableToolchain::new(
690 format!("Failed to list installed Rust targets for `{toolchain}`: {error}"),
691 "Run `rustup target list --installed`; if it fails, repair rustup with `rustup self update` or reinstall rustup.",
692 )
693 })?;
694 Ok(output
695 .lines()
696 .map(str::trim)
697 .filter(|line| !line.is_empty())
698 .map(ToOwned::to_owned)
699 .collect())
700}
701
702async fn installed_rustup_components(
707 host: &Host,
708 toolchain: &str,
709) -> Result<Vec<String>, UnfixableToolchain> {
710 let output = host
711 .run(
712 "rustup",
713 [
714 "component",
715 "list",
716 "--installed",
717 "--toolchain",
718 toolchain,
719 ],
720 )
721 .await
722 .map_err(|error| {
723 UnfixableToolchain::new(
724 format!("Failed to list installed Rust components for `{toolchain}`: {error}"),
725 "Run `rustup component list --installed`; if it fails, repair rustup with `rustup self update` or reinstall rustup.",
726 )
727 })?;
728 Ok(output
729 .lines()
730 .map(str::trim)
731 .filter(|line| !line.is_empty())
732 .map(ToOwned::to_owned)
733 .collect())
734}
735
736fn component_is_installed(installed: &str, component: &str, host_target: &str) -> bool {
741 installed == component || installed == format!("{component}-{host_target}")
742}
743
744#[derive(Debug, Clone)]
746pub struct RustTargetAdditions {
747 toolchain: String,
748 targets: Vec<String>,
749}
750
751impl RustTargetAdditions {
752 #[must_use]
754 pub const fn new(toolchain: String, targets: Vec<String>) -> Self {
755 Self { toolchain, targets }
756 }
757}
758
759#[derive(Debug, thiserror::Error)]
761pub enum FailToAddRustTargets {
762 #[error("rustup is required to add Rust targets but is not on PATH.")]
764 RustupNotFound,
765 #[error("Failed to add Rust target `{target}` to toolchain `{toolchain}`: {source}")]
767 AddTarget {
768 toolchain: String,
770 target: String,
772 source: CommandError,
774 },
775}
776
777impl Installation for RustTargetAdditions {
778 type Error = FailToAddRustTargets;
779
780 async fn install(&self, host: &Host) -> Result<(), Self::Error> {
781 if host.which("rustup").await.is_err() {
782 return Err(FailToAddRustTargets::RustupNotFound);
783 }
784 for target in &self.targets {
785 host.run(
786 "rustup",
787 [
788 "target",
789 "add",
790 "--toolchain",
791 self.toolchain.as_str(),
792 target.as_str(),
793 ],
794 )
795 .await
796 .map_err(|source| FailToAddRustTargets::AddTarget {
797 toolchain: self.toolchain.clone(),
798 target: target.clone(),
799 source,
800 })?;
801 }
802 Ok(())
803 }
804}
805
806#[derive(Debug, Clone)]
810pub struct SelectedToolchainTargets {
811 required: Vec<String>,
812}
813
814impl SelectedToolchainTargets {
815 #[must_use]
817 pub const fn new(required: Vec<String>) -> Self {
818 Self { required }
819 }
820}
821
822impl Toolchain for SelectedToolchainTargets {
823 type Installation = RustTargetAdditions;
824
825 async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
826 let toolchain = selected_rustup_toolchain(host).await?;
827 if !toolchain_is_rustup_managed(&toolchain) {
828 return Err(ToolchainError::unfixable(
829 format!(
830 "the selected toolchain `{toolchain}` is not rustup-managed, so targets cannot be verified or added"
831 ),
832 "The project pins a custom toolchain; install its targets through the toolchain's provider.",
833 ));
834 }
835 let installed = installed_rustup_targets(host, &toolchain).await?;
836 let missing: Vec<String> = self
837 .required
838 .iter()
839 .filter(|target| !installed.contains(*target))
840 .cloned()
841 .collect();
842 if missing.is_empty() {
843 Ok(())
844 } else {
845 Err(ToolchainError::fixable(RustTargetAdditions::new(
846 toolchain, missing,
847 )))
848 }
849 }
850}
851
852async fn select_toolchain(
853 host: &Host,
854 availability: RustToolAvailability,
855 pin: Option<&ToolchainPin>,
856 installation: &mut RustToolchainInstallation,
857) -> Result<SelectedToolchain, ToolchainError<RustToolchainInstallation>> {
858 if !availability.rustup_available {
859 return Ok(SelectedToolchain::Standalone);
860 }
861
862 match host.run("rustup", ["show", "active-toolchain"]).await {
863 Ok(output) => parse_active_toolchain(&output)
864 .map(SelectedToolchain::Rustup)
865 .map_err(|error| {
866 ToolchainError::unfixable(
867 format!("Could not parse the active rustup toolchain: {error}"),
868 "Run `rustup show active-toolchain`; repair or reinstall rustup if it does not return a toolchain name.",
869 )
870 }),
871 Err(error) => {
872 let error_message = error.to_string();
873 if error_message.contains("not installed") {
874 missing_pinned_toolchain(pin, installation, &error_message)
875 } else if is_no_active_toolchain_error(&error_message) {
876 installation.require_default_install("stable");
877 Err(ToolchainError::fixable(std::mem::take(installation)))
878 } else {
879 Err(ToolchainError::unfixable(
880 format!(
881 "rustup is installed but cannot report an active toolchain: {error_message}"
882 ),
883 "Run `rustup self update` and `rustup toolchain install stable`; if that fails, reinstall rustup from https://rustup.rs.",
884 ))
885 }
886 }
887 }
888}
889
890fn missing_pinned_toolchain(
895 pin: Option<&ToolchainPin>,
896 installation: &mut RustToolchainInstallation,
897 error_message: &str,
898) -> Result<SelectedToolchain, ToolchainError<RustToolchainInstallation>> {
899 let Some(channel) = pin.and_then(|pin| pin.channel.clone()) else {
900 installation.require_default_install("stable");
903 return Err(ToolchainError::fixable(std::mem::take(installation)));
904 };
905 if classify_channel(&channel) == ChannelKind::Custom {
906 return Err(ToolchainError::unfixable(
907 format!("rust-toolchain pin `{channel}` is not a rustup channel: {error_message}"),
908 if channel == "esp" {
909 "Install the Espressif Rust toolchain with `espup install` (install `espup` first with `cargo install espup`)."
910 } else {
911 "Install the toolchain through its provider; rustup only installs stable/beta/nightly and released versions."
912 },
913 ));
914 }
915 installation.require_toolchain_install(channel);
916 Err(ToolchainError::fixable(std::mem::take(installation)))
917}
918
919async fn check_rustc_version(
920 host: &Host,
921 minimum_version: &str,
922 pin: Option<&ToolchainPin>,
923 selected: &SelectedToolchain,
924 installation: &mut RustToolchainInstallation,
925) -> Result<(), ToolchainError<RustToolchainInstallation>> {
926 let version_output = host.run("rustc", ["--version"]).await.map_err(|error| {
927 let error_message = error.to_string();
928 rustc_run_error(pin, selected, &error_message)
929 })?;
930 let installed_version = parse_installed_rustc_version(&version_output)?;
931 let required_version = parse_required_rustc_version(minimum_version)?;
932
933 if installed_version >= required_version {
934 return Ok(());
935 }
936
937 let channel = pin.and_then(|pin| pin.channel.as_deref());
938 match (selected, channel.map(classify_channel)) {
939 (SelectedToolchain::Standalone, _) => Err(ToolchainError::unfixable(
940 format!(
941 "Detected Rust {installed_version}, but the project requires at least Rust {required_version}."
942 ),
943 format!(
944 "Install Rust {required_version} or newer. Recommended: install rustup from https://rustup.rs, then run `rustup update stable`."
945 ),
946 )),
947 (SelectedToolchain::Rustup(_), Some(ChannelKind::Custom)) => {
948 Err(ToolchainError::unfixable(
949 format!(
950 "The pinned toolchain `{}` provides Rust {installed_version}, below the required {required_version}.",
951 channel.unwrap_or_default()
952 ),
953 if channel == Some("esp") {
954 String::from("Update the Espressif Rust toolchain with `espup update`.")
955 } else {
956 String::from(
957 "Update the pinned toolchain through its provider, or raise the floor in `rust-toolchain.toml`.",
958 )
959 },
960 ))
961 }
962 (SelectedToolchain::Rustup(_), Some(ChannelKind::Version | ChannelKind::Dated)) => {
963 Err(ToolchainError::unfixable(
964 format!(
965 "The project pins Rust toolchain `{}` (providing {installed_version}), below the required {required_version}.",
966 channel.unwrap_or_default()
967 ),
968 format!(
969 "Update the `channel` in `rust-toolchain.toml` to a release providing Rust {required_version} or newer."
970 ),
971 ))
972 }
973 (SelectedToolchain::Rustup(name), Some(ChannelKind::Moving)) => {
974 installation.require_toolchain_update(name.clone());
975 Ok(())
976 }
977 (SelectedToolchain::Rustup(name), None) => {
978 if toolchain_is_rustup_managed(name) && !is_version_or_dated_name(name) {
979 installation.require_toolchain_update(name.clone());
980 } else if toolchain_is_rustup_managed(name) {
981 installation.require_default_install("stable");
984 } else {
985 return Err(ToolchainError::unfixable(
986 format!(
987 "The default toolchain `{name}` provides Rust {installed_version}, below the required {required_version}."
988 ),
989 format!(
990 "`{name}` is a custom toolchain; select a rustup channel with `rustup default stable` or update the custom toolchain through its provider."
991 ),
992 ));
993 }
994 Ok(())
995 }
996 }
997}
998
999fn is_version_or_dated_name(name: &str) -> bool {
1002 let mut segments = name.split('-');
1003 let channel = segments.next().unwrap_or(name);
1004 if channel
1005 .chars()
1006 .next()
1007 .is_some_and(|first| first.is_ascii_digit())
1008 {
1009 return true;
1010 }
1011 if !matches!(channel, "stable" | "beta" | "nightly") {
1012 return false;
1013 }
1014 let is_digits = |segment: Option<&str>, len: usize| {
1016 segment.is_some_and(|segment| {
1017 segment.len() == len && segment.bytes().all(|byte| byte.is_ascii_digit())
1018 })
1019 };
1020 is_digits(segments.next(), 4) && is_digits(segments.next(), 2) && is_digits(segments.next(), 2)
1021}
1022
1023fn rustc_run_error(
1024 pin: Option<&ToolchainPin>,
1025 selected: &SelectedToolchain,
1026 error_message: &str,
1027) -> ToolchainError<RustToolchainInstallation> {
1028 let detail = format!("`rustc` exists on PATH but failed to run: {error_message}");
1029 match (pin.and_then(|pin| pin.channel.as_deref()), selected) {
1030 (Some(channel), SelectedToolchain::Rustup(_)) => ToolchainError::unfixable(
1031 detail,
1032 format!(
1033 "Reinstall the pinned toolchain with `rustup toolchain install {channel} --force`."
1034 ),
1035 ),
1036 (None, SelectedToolchain::Rustup(name)) => ToolchainError::unfixable(
1037 detail,
1038 format!("Reinstall the toolchain with `rustup toolchain install {name} --force`."),
1039 ),
1040 (_, SelectedToolchain::Standalone) => {
1041 ToolchainError::unfixable(detail, "Reinstall Rust via rustup from https://rustup.rs.")
1042 }
1043 }
1044}
1045
1046fn parse_installed_rustc_version(
1047 version_output: &str,
1048) -> Result<Version, ToolchainError<RustToolchainInstallation>> {
1049 parse_rustc_version(version_output).map_err(|error| {
1050 ToolchainError::unfixable(
1051 format!(
1052 "Failed to parse `rustc --version` output `{}`: {error}",
1053 version_output.trim()
1054 ),
1055 "Run `rustc --version` manually. If output is malformed, reinstall rustup from https://rustup.rs.",
1056 )
1057 })
1058}
1059
1060fn parse_required_rustc_version(
1061 minimum_version: &str,
1062) -> Result<Version, ToolchainError<RustToolchainInstallation>> {
1063 parse_semver_version(minimum_version).map_err(|error| {
1064 ToolchainError::unfixable(
1065 format!("Invalid required Rust version `{minimum_version}`: {error}"),
1066 "Reinstall waterui-cli from source to restore a valid embedded Rust requirement.",
1067 )
1068 })
1069}
1070
1071async fn check_required_targets_and_components(
1072 host: &Host,
1073 pin: Option<&ToolchainPin>,
1074 selected: &SelectedToolchain,
1075 installation: &mut RustToolchainInstallation,
1076) -> Result<(), ToolchainError<RustToolchainInstallation>> {
1077 let SelectedToolchain::Rustup(name) = selected else {
1078 return Ok(());
1079 };
1080 if !toolchain_is_rustup_managed(name) {
1081 return Ok(());
1084 }
1085
1086 let host_target = host
1087 .run("rustc", ["-vV"])
1088 .await
1089 .map_err(|error| {
1090 ToolchainError::unfixable(
1091 format!("`rustc -vV` failed: {error}"),
1092 "Run `rustc -vV` manually; if it fails, reinstall rustup from https://rustup.rs.",
1093 )
1094 })
1095 .and_then(|output| {
1096 parse_host_target(&output).map_err(|error| {
1097 ToolchainError::unfixable(
1098 format!("Could not parse host target from `rustc -vV`: {error}"),
1099 "Ensure `rustc -vV` includes a `host: <target>` line; reinstall rustup if the output is incomplete.",
1100 )
1101 })
1102 })?;
1103
1104 let mut required_targets: Vec<String> = vec![host_target.clone()];
1105 if let Some(pin) = pin {
1106 for target in &pin.targets {
1107 if !required_targets.contains(target) {
1108 required_targets.push(target.clone());
1109 }
1110 }
1111 }
1112
1113 let installed_targets = installed_rustup_targets(host, name).await?;
1114 for target in required_targets {
1115 if !installed_targets.contains(&target) {
1116 installation.require_target(name, target);
1117 }
1118 }
1119
1120 if let Some(pin) = pin
1121 && !pin.components.is_empty()
1122 {
1123 let installed_components = installed_rustup_components(host, name).await?;
1124 for component in &pin.components {
1125 if !installed_components
1126 .iter()
1127 .any(|installed| component_is_installed(installed, component, &host_target))
1128 {
1129 installation.require_component(name, component.clone());
1130 }
1131 }
1132 }
1133
1134 Ok(())
1135}
1136
1137fn parse_active_toolchain(output: &str) -> Result<String, RustParseError> {
1138 output
1139 .split_whitespace()
1140 .next()
1141 .filter(|toolchain| !toolchain.is_empty())
1142 .map(ToOwned::to_owned)
1143 .ok_or(RustParseError::ActiveToolchain)
1144}
1145
1146fn parse_rustc_version(output: &str) -> Result<Version, RustParseError> {
1147 let version_token = output
1148 .split_whitespace()
1149 .nth(1)
1150 .ok_or(RustParseError::RustcVersion)?;
1151 Ok(parse_semver_version(version_token)?)
1152}
1153
1154fn parse_host_target(output: &str) -> Result<String, RustParseError> {
1155 output
1156 .lines()
1157 .find_map(|line| {
1158 line.strip_prefix("host:")
1159 .map(str::trim)
1160 .filter(|target| !target.is_empty())
1161 .map(ToOwned::to_owned)
1162 })
1163 .ok_or(RustParseError::HostLine)
1164}
1165
1166fn is_no_active_toolchain_error(error: &str) -> bool {
1167 let normalized = error.to_ascii_lowercase();
1168 normalized.contains("no active toolchain") || normalized.contains("no default toolchain")
1169}
1170
1171pub async fn nightly_toolchain_with_rust_src(host: &Host) -> eyre::Result<String> {
1188 let list = host
1189 .run("rustup", ["toolchain", "list"])
1190 .await
1191 .map_err(|error| {
1192 eyre::eyre!(
1193 "Android preview needs a nightly Rust toolchain to build `std` from source, \
1194 and `rustup toolchain list` failed: {error}"
1195 )
1196 })?;
1197 let host_triple = target_lexicon::Triple::host().to_string();
1198 let Some(toolchain) = pick_nightly(&list, &host_triple) else {
1199 eyre::bail!(
1200 "Android preview needs a nightly Rust toolchain to build `std` from source. \
1201 Install one with `rustup toolchain install nightly --component rust-src`."
1202 );
1203 };
1204
1205 let components = host
1206 .run(
1207 "rustup",
1208 [
1209 "component",
1210 "list",
1211 "--toolchain",
1212 &toolchain,
1213 "--installed",
1214 ],
1215 )
1216 .await
1217 .map_err(|error| {
1218 eyre::eyre!("Failed to list components of Rust toolchain `{toolchain}`: {error}")
1219 })?;
1220 let has_rust_src = components
1221 .lines()
1222 .map(str::trim)
1223 .any(|line| line == "rust-src" || line.starts_with("rust-src "));
1224 if !has_rust_src {
1225 eyre::bail!(
1226 "Android preview needs the `rust-src` component on `{toolchain}` to build `std` from source. \
1227 Install it with `rustup component add --toolchain {toolchain} rust-src`."
1228 );
1229 }
1230 Ok(toolchain)
1231}
1232
1233pub async fn rustc_verbose_version(host: &Host, toolchain: &str) -> eyre::Result<String> {
1240 host.run("rustup", ["run", toolchain, "rustc", "-vV"])
1241 .await
1242 .map_err(|error| {
1243 eyre::eyre!("Failed to read `rustc -vV` of Rust toolchain `{toolchain}`: {error}")
1244 })
1245}
1246
1247fn pick_nightly(list_output: &str, host_triple: &str) -> Option<String> {
1251 let default_nightly = format!("nightly-{host_triple}");
1252 let suffix = format!("-{host_triple}");
1253 let mut dated = Vec::new();
1254 for name in list_output
1255 .lines()
1256 .filter_map(|line| line.split_whitespace().next())
1257 {
1258 if name == default_nightly {
1259 return Some(name.to_string());
1260 }
1261 let Some(date) = name
1262 .strip_prefix("nightly-")
1263 .and_then(|rest| rest.strip_suffix(&suffix))
1264 else {
1265 continue;
1266 };
1267 let mut fields = date.split('-');
1271 let is_dated = matches!(
1272 (fields.next(), fields.next(), fields.next(), fields.next()),
1273 (Some(year), Some(month), Some(day), None)
1274 if year.len() == 4 && month.len() == 2 && day.len() == 2
1275 && year.bytes().chain(month.bytes()).chain(day.bytes())
1276 .all(|byte| byte.is_ascii_digit())
1277 );
1278 if is_dated {
1279 dated.push(name.to_string());
1280 }
1281 }
1282 dated.sort_unstable();
1283 dated.pop()
1284}
1285
1286#[cfg(test)]
1287mod tests {
1288 use semver::Version;
1289
1290 use super::{
1291 ChannelKind, RustToolchainInstallation, classify_channel, component_is_installed,
1292 parse_active_toolchain, parse_host_target, parse_rustc_version, pick_nightly,
1293 };
1294
1295 #[test]
1296 fn pick_nightly_prefers_the_plain_channel_then_the_newest_date() {
1297 let host = "aarch64-apple-darwin";
1298 let list = "stable-aarch64-apple-darwin (default)\nnightly-aarch64-apple-darwin\nnightly-2026-05-28-aarch64-apple-darwin\n";
1299 assert_eq!(
1300 pick_nightly(list, host).as_deref(),
1301 Some("nightly-aarch64-apple-darwin")
1302 );
1303
1304 let dated =
1305 "nightly-2026-05-28-aarch64-apple-darwin\nnightly-2026-09-09-aarch64-apple-darwin\n";
1306 assert_eq!(
1307 pick_nightly(dated, host).as_deref(),
1308 Some("nightly-2026-09-09-aarch64-apple-darwin")
1309 );
1310
1311 assert_eq!(pick_nightly("stable-aarch64-apple-darwin\n", host), None);
1312 }
1313
1314 #[test]
1315 fn pick_nightly_ignores_custom_toolchains_shaped_like_dated_ones() {
1316 let host = "aarch64-apple-darwin";
1317 let list = "nightly-2026-05-28-aarch64-apple-darwin\nnightly-zzz-aarch64-apple-darwin\n";
1320 assert_eq!(
1321 pick_nightly(list, host).as_deref(),
1322 Some("nightly-2026-05-28-aarch64-apple-darwin")
1323 );
1324 assert_eq!(
1325 pick_nightly("nightly-zzz-aarch64-apple-darwin\n", host),
1326 None
1327 );
1328 }
1329
1330 #[test]
1331 fn parse_rustc_version_accepts_prerelease() {
1332 let parsed =
1333 parse_rustc_version("rustc 1.88.0-nightly (d9a5f4fa4 2026-01-01)").expect("version");
1334 assert_eq!(
1335 parsed,
1336 Version::parse("1.88.0-nightly").expect("expected semver")
1337 );
1338 }
1339
1340 #[test]
1341 fn parse_active_toolchain_preserves_selected_channel() {
1342 let toolchain = parse_active_toolchain("nightly-aarch64-apple-darwin (default)")
1343 .expect("active toolchain");
1344 assert_eq!(toolchain, "nightly-aarch64-apple-darwin");
1345 }
1346
1347 #[test]
1348 fn parse_host_target_extracts_host_line() {
1349 let output = "rustc 1.88.0 (aabbcc 2026-01-01)\nbinary: rustc\nhost: x86_64-unknown-linux-gnu\nrelease: 1.88.0\n";
1350 let host = parse_host_target(output).expect("host target");
1351 assert_eq!(host, "x86_64-unknown-linux-gnu");
1352 }
1353
1354 #[test]
1355 fn channel_classification() {
1356 assert_eq!(classify_channel("stable"), ChannelKind::Moving);
1357 assert_eq!(classify_channel("nightly"), ChannelKind::Moving);
1358 assert_eq!(classify_channel("nightly-2026-01-15"), ChannelKind::Dated);
1359 assert_eq!(classify_channel("1.85"), ChannelKind::Version);
1360 assert_eq!(classify_channel("1.85.0"), ChannelKind::Version);
1361 assert_eq!(classify_channel("esp"), ChannelKind::Custom);
1362 assert_eq!(classify_channel("stage0"), ChannelKind::Custom);
1363 }
1364
1365 #[test]
1366 fn component_matching_accepts_target_suffix() {
1367 let host = "aarch64-apple-darwin";
1368 assert!(component_is_installed(
1369 "clippy-aarch64-apple-darwin",
1370 "clippy",
1371 host
1372 ));
1373 assert!(component_is_installed("rust-src", "rust-src", host));
1374 assert!(!component_is_installed("rustfmt-preview", "rustfmt", host));
1375 assert!(!component_is_installed(
1376 "cargo-aarch64-apple-darwin",
1377 "clippy",
1378 host
1379 ));
1380 assert!(!component_is_installed(
1381 "clippy-x86_64-unknown-linux-gnu",
1382 "clippy",
1383 host
1384 ));
1385 }
1386
1387 #[test]
1388 fn installation_summary_lists_actions() {
1389 let mut installation = RustToolchainInstallation::default();
1390 installation.require_toolchain_update(String::from("nightly-aarch64-apple-darwin"));
1391 installation.require_target(
1392 "stable-aarch64-apple-darwin",
1393 String::from("x86_64-unknown-linux-gnu"),
1394 );
1395 installation.require_component("stable-aarch64-apple-darwin", String::from("clippy"));
1396 let summary = installation.summary();
1397 assert!(summary.contains("rustup update nightly-aarch64-apple-darwin"));
1398 assert!(summary.contains(
1399 "rustup target add --toolchain stable-aarch64-apple-darwin x86_64-unknown-linux-gnu"
1400 ));
1401 assert!(
1402 summary.contains("rustup component add --toolchain stable-aarch64-apple-darwin clippy")
1403 );
1404 }
1405}
1406
1407#[cfg(test)]
1408mod host_tests {
1409 use super::{
1410 CLI_MINIMUM_RUST_VERSION, RustToolchain, nightly_toolchain_with_rust_src,
1411 rustc_verbose_version, tool_binary_name,
1412 };
1413 use crate::toolchain::testing::TestMachine;
1414 use crate::toolchain::{Installation, Toolchain, ToolchainError};
1415
1416 const FAKE_TARGET: &str = "wasm32test-test-none";
1417
1418 fn complete_machine() -> TestMachine {
1420 let machine = TestMachine::new();
1421 for tool in ["rustup", "cargo", "rustc"] {
1422 machine.install(tool);
1423 }
1424 machine
1425 }
1426
1427 fn complete_vars() -> Vec<(String, String)> {
1431 vec![
1432 (
1433 String::from("WATERUI_FAKE_RUSTC_VERSION"),
1434 format!("{CLI_MINIMUM_RUST_VERSION}.0"),
1435 ),
1436 (
1437 String::from("WATERUI_FAKE_RUSTC_HOST"),
1438 FAKE_TARGET.to_string(),
1439 ),
1440 (
1441 String::from("WATERUI_FAKE_RUSTUP_ACTIVE_TOOLCHAIN"),
1442 format!("stable-{FAKE_TARGET} (default)"),
1443 ),
1444 (
1445 String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
1446 FAKE_TARGET.to_string(),
1447 ),
1448 ]
1449 }
1450
1451 #[test]
1452 fn check_unfixable_when_no_rust_tools_exist() {
1453 let machine = TestMachine::new();
1454 let host = machine.host(Vec::<(String, String)>::new());
1455 let result = smol::block_on(RustToolchain::default().check(&host));
1456 assert!(
1457 matches!(result, Err(ToolchainError::Unfixable(_))),
1458 "bare host must report an unfixable Rust toolchain: {result:?}"
1459 );
1460 }
1461
1462 #[test]
1463 fn check_ok_on_complete_fake_toolchain() {
1464 let machine = complete_machine();
1465 let host = machine.host(complete_vars());
1466 smol::block_on(RustToolchain::default().check(&host))
1467 .expect("complete fake toolchain must be ok");
1468 }
1469
1470 #[test]
1471 fn check_fixable_when_rustc_too_old() {
1472 let machine = complete_machine();
1473 let mut vars = complete_vars();
1474 vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTC_VERSION");
1475 vars.push((
1476 String::from("WATERUI_FAKE_RUSTC_VERSION"),
1477 String::from("1.0.0"),
1478 ));
1479 let host = machine.host(vars);
1480 let result = smol::block_on(RustToolchain::default().check(&host));
1481 assert!(
1482 matches!(result, Err(ToolchainError::Fixable(_))),
1483 "outdated rustc under rustup must be fixable: {result:?}"
1484 );
1485 }
1486
1487 #[test]
1488 fn check_unfixable_when_rustc_too_old_without_rustup() {
1489 let machine = TestMachine::new();
1490 machine.install("cargo");
1491 machine.install("rustc");
1492 let host = machine.host([(
1493 String::from("WATERUI_FAKE_RUSTC_VERSION"),
1494 String::from("1.0.0"),
1495 )]);
1496 let result = smol::block_on(RustToolchain::default().check(&host));
1497 assert!(
1498 matches!(result, Err(ToolchainError::Unfixable(_))),
1499 "outdated rustc without rustup cannot be fixed automatically: {result:?}"
1500 );
1501 }
1502
1503 #[test]
1504 fn check_fixable_when_no_active_toolchain() {
1505 let machine = complete_machine();
1506 let mut vars = complete_vars();
1507 vars.push((
1508 String::from("WATERUI_FAKE_RUSTUP_NO_ACTIVE_TOOLCHAIN"),
1509 String::from("1"),
1510 ));
1511 let host = machine.host(vars);
1512 let result = smol::block_on(RustToolchain::default().check(&host));
1513 assert!(
1514 matches!(result, Err(ToolchainError::Fixable(_))),
1515 "rustup without an active toolchain must plan a default install: {result:?}"
1516 );
1517 }
1518
1519 #[test]
1520 fn check_fixable_when_host_target_not_installed() {
1521 let machine = complete_machine();
1522 let mut vars = complete_vars();
1523 vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS");
1524 vars.push((
1525 String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
1526 String::from("some-other-target"),
1527 ));
1528 let host = machine.host(vars);
1529 let result = smol::block_on(RustToolchain::default().check(&host));
1530 assert!(
1531 matches!(result, Err(ToolchainError::Fixable(_))),
1532 "missing host target must plan `rustup target add`: {result:?}"
1533 );
1534 }
1535
1536 #[test]
1537 fn check_fixable_when_pinned_toolchain_missing() {
1538 let machine = complete_machine();
1539 machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"1.90\"\n");
1540 let mut vars = complete_vars();
1541 vars.push((
1542 String::from("WATERUI_FAKE_RUSTUP_TOOLCHAIN_NOT_INSTALLED"),
1543 String::from("1.90"),
1544 ));
1545 let host = machine.host(vars);
1546 let result = smol::block_on(RustToolchain::default().check(&host));
1547 match &result {
1548 Err(ToolchainError::Fixable(installation)) => {
1549 assert!(
1550 installation
1551 .summary()
1552 .contains("rustup toolchain install 1.90"),
1553 "the repair must install the pinned channel: {}",
1554 installation.summary()
1555 );
1556 }
1557 other => panic!("missing pinned toolchain must be fixable: {other:?}"),
1558 }
1559 }
1560
1561 #[test]
1562 fn check_unfixable_when_pinned_custom_toolchain_missing() {
1563 let machine = complete_machine();
1564 machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"esp\"\n");
1565 let mut vars = complete_vars();
1566 vars.push((
1567 String::from("WATERUI_FAKE_RUSTUP_TOOLCHAIN_NOT_INSTALLED"),
1568 String::from("esp"),
1569 ));
1570 let host = machine.host(vars);
1571 let result = smol::block_on(RustToolchain::default().check(&host));
1572 match &result {
1573 Err(ToolchainError::Unfixable(error)) => {
1574 assert!(
1575 error.suggestion().contains("espup install"),
1576 "an `esp` pin must name the espup repair: {}",
1577 error.suggestion()
1578 );
1579 }
1580 other => panic!("missing custom toolchain must be manual: {other:?}"),
1581 }
1582 }
1583
1584 #[test]
1585 fn check_unfixable_when_version_pin_below_floor() {
1586 let machine = complete_machine();
1587 machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"1.50\"\n");
1588 let mut vars = complete_vars();
1589 vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTC_VERSION");
1590 vars.push((
1591 String::from("WATERUI_FAKE_RUSTC_VERSION"),
1592 String::from("1.50.0"),
1593 ));
1594 let host = machine.host(vars);
1595 let result = smol::block_on(RustToolchain::default().check(&host));
1596 match &result {
1597 Err(ToolchainError::Unfixable(error)) => {
1598 assert!(
1599 error.message().contains("1.50"),
1600 "the pin must be named in the diagnostic: {}",
1601 error.message()
1602 );
1603 }
1604 other => panic!("a version pin below the floor must be manual: {other:?}"),
1605 }
1606 }
1607
1608 #[test]
1609 fn nightly_with_rust_src_returns_the_selected_toolchain() {
1610 let machine = TestMachine::new();
1611 machine.install("rustup");
1612 let host_triple = target_lexicon::Triple::host().to_string();
1613 let nightly = format!("nightly-{host_triple}");
1614 machine.file(
1615 "home/.fake-rustup-toolchains",
1616 &format!("stable-{host_triple}\n{nightly}\n"),
1617 );
1618 machine.respond("RUSTUP_INSTALLED_COMPONENTS", "cargo\nrust-src\n");
1619 let host = machine.host(Vec::<(String, String)>::new());
1620 let toolchain = smol::block_on(nightly_toolchain_with_rust_src(&host))
1621 .expect("a nightly carrying rust-src must be selected");
1622 assert_eq!(toolchain, nightly);
1623 }
1624
1625 #[test]
1626 fn nightly_without_rust_src_fails_with_the_exact_component_command() {
1627 let machine = TestMachine::new();
1628 machine.install("rustup");
1629 let host_triple = target_lexicon::Triple::host().to_string();
1630 let nightly = format!("nightly-{host_triple}");
1631 machine.file(
1632 "home/.fake-rustup-toolchains",
1633 &format!("stable-{host_triple}\n{nightly}\n"),
1634 );
1635 let host = machine.host(Vec::<(String, String)>::new());
1638 let error = smol::block_on(nightly_toolchain_with_rust_src(&host))
1639 .expect_err("missing rust-src must fail");
1640 assert!(
1641 error.to_string().contains(&format!(
1642 "rustup component add --toolchain {nightly} rust-src"
1643 )),
1644 "the error must name the exact install command: {error}"
1645 );
1646 }
1647
1648 #[test]
1649 fn rustc_verbose_version_proxies_through_rustup_run() {
1650 let machine = TestMachine::new();
1651 machine.install("rustup");
1652 machine.install("rustc");
1653 let host = machine.host([(
1654 String::from("WATERUI_FAKE_RUSTC_HOST"),
1655 String::from("aarch64-apple-darwin"),
1656 )]);
1657 let version = smol::block_on(rustc_verbose_version(&host, "nightly-fake"))
1658 .expect("`rustup run` must dispatch to the sibling rustc");
1659 assert!(
1660 version.contains("host: aarch64-apple-darwin"),
1661 "the toolchain's `rustc -vV` identity must come back: {version}"
1662 );
1663 }
1664
1665 #[test]
1666 fn check_fixable_when_pinned_component_missing() {
1667 let machine = complete_machine();
1668 machine.file(
1669 "rust-toolchain.toml",
1670 "[toolchain]\nchannel = \"stable\"\ncomponents = [\"clippy\", \"rustfmt\"]\n",
1671 );
1672 let host = machine.host(complete_vars());
1673 let result = smol::block_on(RustToolchain::default().check(&host));
1674 match &result {
1675 Err(ToolchainError::Fixable(installation)) => {
1676 let summary = installation.summary();
1677 assert!(
1678 summary.contains("rustup component add") && summary.contains("clippy"),
1679 "missing pin components must plan component add: {summary}"
1680 );
1681 }
1682 other => panic!("missing pin components must be fixable: {other:?}"),
1683 }
1684 }
1685
1686 #[test]
1687 fn check_ok_when_pinned_components_installed() {
1688 let machine = complete_machine();
1689 machine.file(
1690 "rust-toolchain.toml",
1691 "[toolchain]\nchannel = \"stable\"\ncomponents = [\"clippy\"]\ntargets = [\"wasm32test-test-none\"]\n",
1692 );
1693 let mut vars = complete_vars();
1694 vars.push((
1695 String::from("WATERUI_FAKE_RUSTUP_INSTALLED_COMPONENTS"),
1696 String::from("clippy-wasm32test-test-none"),
1697 ));
1698 let host = machine.host(vars);
1699 smol::block_on(RustToolchain::default().check(&host))
1700 .expect("pin-declared targets and components installed must be ok");
1701 }
1702
1703 #[test]
1704 fn check_unfixable_when_proxies_missing_from_path() {
1705 let machine = TestMachine::new();
1706 machine.install("rustup");
1707 machine.file(
1710 format!("home/.cargo/bin/{}", tool_binary_name("cargo")),
1711 "proxy",
1712 );
1713 machine.file(
1714 format!("home/.cargo/bin/{}", tool_binary_name("rustc")),
1715 "proxy",
1716 );
1717 let host = machine.host(Vec::<(String, String)>::new());
1718 let result = smol::block_on(RustToolchain::default().check(&host));
1719 match &result {
1720 Err(ToolchainError::Unfixable(error)) => {
1721 assert!(
1722 error.message().contains("not on PATH"),
1723 "the PATH gap must be diagnosed: {}",
1724 error.message()
1725 );
1726 }
1727 other => panic!("proxies off PATH must be a manual diagnosis: {other:?}"),
1728 }
1729 }
1730
1731 #[test]
1732 fn check_unfixable_when_proxies_never_installed() {
1733 let machine = TestMachine::new();
1734 machine.install("rustup");
1735 let host = machine.host(Vec::<(String, String)>::new());
1736 let result = smol::block_on(RustToolchain::default().check(&host));
1737 match &result {
1738 Err(ToolchainError::Unfixable(error)) => {
1739 assert!(
1740 error.suggestion().contains("rustup"),
1741 "missing proxies must name the reinstall: {}",
1742 error.suggestion()
1743 );
1744 }
1745 other => panic!("absent proxies must be a manual diagnosis: {other:?}"),
1746 }
1747 }
1748
1749 #[test]
1750 fn install_repairs_missing_pinned_toolchain_and_recheck_passes() {
1751 let machine = complete_machine();
1752 machine.file("rust-toolchain.toml", "[toolchain]\nchannel = \"1.90\"\n");
1753 let mut vars = complete_vars();
1754 vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTUP_ACTIVE_TOOLCHAIN");
1755 vars.push((
1756 String::from("WATERUI_FAKE_RUSTUP_TOOLCHAIN_NOT_INSTALLED"),
1757 String::from("1.90"),
1758 ));
1759 let host = machine.host(vars);
1760
1761 let Err(ToolchainError::Fixable(installation)) =
1762 smol::block_on(RustToolchain::default().check(&host))
1763 else {
1764 panic!("missing pinned toolchain must be fixable");
1765 };
1766 smol::block_on(installation.install(&host)).expect("fake rustup install must succeed");
1767
1768 smol::block_on(RustToolchain::default().check(&host))
1769 .expect("after `rustup toolchain install`, the check must pass");
1770 }
1771}