Skip to main content

waterui_cli/toolchain/
rust.rs

1//! Rust toolchain checks and remediation.
2
3use 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
12/// The CLI's own `rust-version` — always part of the version floor the doctor
13/// enforces, because the crates it generates are compiled with this release's
14/// language features and edition.
15pub const CLI_MINIMUM_RUST_VERSION: &str = env!("CARGO_PKG_RUST_VERSION");
16
17/// Rust toolchain checker.
18///
19/// `minimum_version` is the rustc floor the build path must satisfy — the
20/// caller (the doctor) derives it from the CLI's `rust-version` raised by
21/// whatever the project's manifest and selected framework declare. The
22/// `rust-toolchain.toml`/`rust-toolchain` pin is read from the host's working
23/// directory at check time, matching how rustup resolves it for the commands
24/// the build path spawns there.
25#[derive(Debug, Clone)]
26pub struct RustToolchain {
27    minimum_version: String,
28}
29
30impl RustToolchain {
31    /// A checker enforcing `minimum_version` as the rustc floor.
32    #[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/// Installation plan for Rust toolchain fixes.
49#[derive(Debug, Clone, Default)]
50pub struct RustToolchainInstallation {
51    /// `rustup default <channel>` — installs the channel and selects it; the
52    /// repair for a rustup with no active toolchain and for a default pinned
53    /// below the version floor.
54    set_default: Option<String>,
55    /// `rustup toolchain install <channel>` — installs the toolchain a
56    /// `rust-toolchain.toml` pin names without touching the default.
57    install_toolchain: Option<String>,
58    /// `rustup update <toolchain>` — updates an installed moving-channel
59    /// toolchain that fell behind the version floor.
60    update_toolchain: Option<String>,
61    /// `rustup target add --toolchain <toolchain> <target>` pairs.
62    add_targets: Vec<(String, String)>,
63    /// `rustup component add --toolchain <toolchain> <component>` pairs.
64    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    /// Returns `true` when at least one automatic fix action is planned.
96    #[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    /// Human-readable summary of automatic fixes this installation will run.
106    #[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/// Errors that can occur during Rust toolchain installation.
140#[derive(Debug, thiserror::Error)]
141pub enum FailToInstallRustToolchain {
142    /// rustup was not found.
143    #[error("rustup is required for automatic Rust toolchain fixes but is not on PATH.")]
144    RustupNotFound,
145    /// Failed to install and select the default toolchain.
146    #[error("Failed to install default Rust toolchain `{toolchain}`: {source}")]
147    SetDefault {
148        /// Channel that failed to install.
149        toolchain: String,
150        /// Underlying command error.
151        source: CommandError,
152    },
153    /// Failed to install a pinned toolchain.
154    #[error("Failed to install Rust toolchain `{toolchain}`: {source}")]
155    InstallToolchain {
156        /// Channel that failed to install.
157        toolchain: String,
158        /// Underlying command error.
159        source: CommandError,
160    },
161    /// Failed to update the active toolchain.
162    #[error("Failed to update Rust toolchain `{toolchain}`: {source}")]
163    UpdateToolchain {
164        /// Active rustup toolchain that failed to update.
165        toolchain: String,
166        /// Underlying command error.
167        source: CommandError,
168    },
169    /// Failed to add a compilation target.
170    #[error("Failed to add Rust target `{target}` to toolchain `{toolchain}`: {source}")]
171    AddTarget {
172        /// Toolchain the target was added to.
173        toolchain: String,
174        /// Target triple that failed to install.
175        target: String,
176        /// Underlying command error.
177        source: CommandError,
178    },
179    /// Failed to add a component.
180    #[error("Failed to add Rust component `{component}` to toolchain `{toolchain}`: {source}")]
181    AddComponent {
182        /// Toolchain the component was added to.
183        toolchain: String,
184        /// Component that failed to install.
185        component: String,
186        /// Underlying command error.
187        source: CommandError,
188    },
189}
190
191/// Parsing rustup/rustc output or a version string failed.
192#[derive(Debug, thiserror::Error)]
193enum RustParseError {
194    /// `rustup show active-toolchain` printed no toolchain token.
195    #[error("expected `<toolchain> (<reason>)` output")]
196    ActiveToolchain,
197    /// `rustc --version` printed no version token.
198    #[error("expected `rustc <version>` output")]
199    RustcVersion,
200    /// `rustc -vV` printed no `host:` line.
201    #[error("missing `host:` line")]
202    HostLine,
203    /// The version string could not be normalized to semver.
204    #[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/// The toolchain the build path resolves for the host's working directory.
337#[derive(Debug)]
338enum SelectedToolchain {
339    /// rustup resolved a toolchain (the project pin or the rustup default).
340    Rustup(String),
341    /// No rustup on PATH; `rustc`/`cargo` are standalone binaries.
342    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
367/// The directory rustup writes its `cargo`/`rustc` proxies into on this host:
368/// `$CARGO_HOME/bin`, falling back to `~/.cargo/bin`.
369fn 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
376/// The directory rustup unpacks toolchains into on this host:
377/// `$RUSTUP_HOME/toolchains`, falling back to `~/.rustup/toolchains`.
378pub(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
385/// When `rustup` is reachable but `cargo`/`rustc` are not, the rustup proxies
386/// either sit in a directory missing from PATH or were never created. Both
387/// are manual repairs: doctor does not edit shell profiles or reinstall
388/// rustup.
389fn 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/// The `rust-toolchain.toml`/`rust-toolchain` override applying to a
462/// directory — the same file rustup reads when the build path spawns
463/// `cargo`/`rustc` there.
464#[derive(Debug)]
465struct ToolchainPin {
466    /// `channel = "…"`, when the file declares one.
467    channel: Option<String>,
468    /// `targets = […]` the toolchain must carry.
469    targets: Vec<String>,
470    /// `components = […]` the toolchain must carry.
471    components: Vec<String>,
472}
473
474/// A `rust-toolchain` file that exists but cannot be interpreted.
475#[derive(Debug)]
476struct MalformedToolchainPin {
477    path: PathBuf,
478    reason: String,
479}
480
481/// Read the toolchain pin applying to `dir`, walking ancestors the way rustup
482/// does: the nearest `rust-toolchain.toml` or `rust-toolchain` wins.
483fn 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        // The legacy `rust-toolchain` file may hold a bare channel name.
513        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        // A pin file may declare `channel`/`targets`/`components` at the top
527        // level; rustup treats the whole document as the toolchain table.
528        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        // rustup requires `channel` in a TOML toolchain file.
548        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/// How a `channel` value maps onto rustup's vocabulary.
572#[derive(Debug, Clone, Copy, PartialEq, Eq)]
573enum ChannelKind {
574    /// `stable`/`beta`/`nightly` — rustup installs and updates it.
575    Moving,
576    /// `stable-`/`beta-`/`nightly-YYYY-MM-DD` — installable, never updates.
577    Dated,
578    /// `1.85`, `1.85.0`, optionally with a host suffix — installable, never
579    /// updates past the pinned version.
580    Version,
581    /// A custom toolchain name (a linked toolchain, `esp`, `stage0`, …) —
582    /// rustup cannot install it; its provider does.
583    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
619/// Whether rustup can manage targets and components on a resolved toolchain
620/// name — `stable-aarch64-apple-darwin`, `nightly-2024-01-01-…`, `1.85.0-…`
621/// are managed; linked/custom names like `esp` or `stage0` are not.
622pub(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
631/// The toolchain rustup resolves for the host's working directory — the
632/// `rust-toolchain.toml` override when one is installed, else the rustup
633/// default.
634///
635/// Shared by every check that must qualify `rustup` commands with
636/// `--toolchain <name>`.
637pub(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
661/// The rustup toolchain a project's own directory selects.
662///
663/// The generated crates a build compiles live in the build cache, outside the
664/// project tree, so rustup would resolve them to its default toolchain rather
665/// than the project's `rust-toolchain.toml`; every cargo and rustc invocation
666/// made on the project's behalf names this toolchain explicitly instead.
667///
668/// # Errors
669/// Returns an error when rustup is missing or resolves no toolchain for the
670/// project directory.
671pub(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
677/// Targets installed on `toolchain`, via `rustup target list --installed`.
678pub(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
702/// Components installed on `toolchain`, via `rustup component list
703/// --installed`. rustup prints component names with the toolchain's target
704/// suffix (`clippy-aarch64-apple-darwin`), so membership tests use
705/// [`component_is_installed`].
706async 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
736/// `rustup component list` prints either a bare component name (`rust-src`)
737/// or `<component>-<host>` (`clippy-aarch64-apple-darwin`). A declared
738/// component is installed when a line matches one of the two exactly — a
739/// prefix match would count `rustfmt-preview` as `rustfmt`.
740fn component_is_installed(installed: &str, component: &str, host_target: &str) -> bool {
741    installed == component || installed == format!("{component}-{host_target}")
742}
743
744/// Installation plan adding rustup targets to a named toolchain.
745#[derive(Debug, Clone)]
746pub struct RustTargetAdditions {
747    toolchain: String,
748    targets: Vec<String>,
749}
750
751impl RustTargetAdditions {
752    /// Plan `rustup target add --toolchain <toolchain>` for each of `targets`.
753    #[must_use]
754    pub const fn new(toolchain: String, targets: Vec<String>) -> Self {
755        Self { toolchain, targets }
756    }
757}
758
759/// Errors from `rustup target add`.
760#[derive(Debug, thiserror::Error)]
761pub enum FailToAddRustTargets {
762    /// rustup was not found.
763    #[error("rustup is required to add Rust targets but is not on PATH.")]
764    RustupNotFound,
765    /// A target could not be added.
766    #[error("Failed to add Rust target `{target}` to toolchain `{toolchain}`: {source}")]
767    AddTarget {
768        /// Toolchain the target was added to.
769        toolchain: String,
770        /// Target triple that failed to install.
771        target: String,
772        /// Underlying command error.
773        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/// A required set of rustup targets on the toolchain selected for the host's
807/// working directory — how platform checkers verify the compilation targets
808/// the build path invokes.
809#[derive(Debug, Clone)]
810pub struct SelectedToolchainTargets {
811    required: Vec<String>,
812}
813
814impl SelectedToolchainTargets {
815    /// Check that `required` targets are installed on the selected toolchain.
816    #[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
890/// `rustup show active-toolchain` failed because the pinned (or defaulted)
891/// toolchain is not installed. When the pin names a channel rustup cannot
892/// install — `esp` being the common case — the repair is manual and names
893/// the toolchain's own installer.
894fn 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        // No pin, yet the recorded default is not installed: installing
901        // `stable` alone leaves no default selected, so install and select it.
902        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                // The rustup default is pinned to a version or date that no
982                // update can move past; select `stable` instead.
983                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
999/// Whether a resolved toolchain name pins a version or a date, so
1000/// `rustup update` cannot move it past the floor.
1001fn 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    // `<channel>-YYYY-MM-DD[-<host>]` names pin a date.
1015    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        // Custom toolchains (esp, linked) carry their own standard library;
1082        // rustup cannot add targets or components to them.
1083        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
1171/// A nightly toolchain that can compile the standard library from source.
1172///
1173/// `-Zbuild-std` needs a nightly cargo and the `rust-src` component. The
1174/// Android preview needs both: the only shared `libstd` rustup ships is
1175/// 4 KB-aligned, and a device with 16 KB pages refuses to map it, so the
1176/// preview runtime builds `std` itself under the page-size link flag.
1177///
1178/// The choice is deterministic — the plain `nightly` channel first, then the
1179/// newest dated nightly — because the support app and the preview module are
1180/// separate Cargo invocations that must land on the same `libstd-<hash>.so`.
1181///
1182/// # Errors
1183/// Returns an error when no nightly toolchain is installed, or when the
1184/// selected toolchain lacks `rust-src` — the error names the exact
1185/// `rustup component add` command rather than mutating the toolchain
1186/// silently.
1187pub 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
1233/// The `rustc -vV` identity of `toolchain` — what a cached `-Zbuild-std`
1234/// artifact pins to, because a channel name like `nightly` outlives the
1235/// compiler it resolves to after `rustup update`.
1236///
1237/// # Errors
1238/// Returns an error when `rustup` cannot run the toolchain's `rustc`.
1239pub 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
1247/// Pick the toolchain a `-Zbuild-std` build should use out of `rustup
1248/// toolchain list` output: `nightly-<host>` first, else the newest dated
1249/// nightly for the host.
1250fn 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        // Only the dated shape `nightly-YYYY-MM-DD-<host>` is ordered by
1268        // recency: a custom-linked toolchain named `nightly-anything-<host>`
1269        // must not sort last and silently outrank every dated nightly.
1270        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        // A linked toolchain named `nightly-zzz-<host>` sorts after every
1318        // dated nightly — without the date-shape check it would win.
1319        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    /// A host with rustup/cargo/rustc fakes reporting a compliant toolchain.
1419    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    /// Declared vars for a complete machine: current rustc, an active
1428    /// toolchain, and `FAKE_TARGET` both as the rustc host triple and among
1429    /// the installed rustup targets.
1430    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        // `component list` prints nothing — `rust-src` is absent, and the
1636        // check must refuse rather than mutate the toolchain silently.
1637        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        // rustup is on PATH, cargo/rustc are not — but their proxies exist
1708        // under the fake home's `.cargo/bin`.
1709        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}