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