Skip to main content

waterui_cli/toolchain/
rust.rs

1//! Rust toolchain checks and remediation.
2
3use semver::Version;
4
5use crate::{
6    toolchain::{Host, Installation, Toolchain, ToolchainError},
7    utils::{CommandError, parse_semver_version},
8};
9
10const REQUIRED_RUST_VERSION: &str = env!("CARGO_PKG_RUST_VERSION");
11
12/// Rust toolchain checker.
13#[derive(Debug, Clone, Copy, Default)]
14pub struct RustToolchain;
15
16/// Installation plan for Rust toolchain fixes.
17#[derive(Debug, Clone, Default)]
18pub struct RustToolchainInstallation {
19    install_stable_toolchain: bool,
20    update_toolchain: Option<String>,
21    add_host_target: Option<String>,
22}
23
24impl RustToolchainInstallation {
25    fn require_stable_install(&mut self) {
26        self.install_stable_toolchain = true;
27        self.update_toolchain = None;
28    }
29
30    fn require_toolchain_update(&mut self, toolchain: String) {
31        if !self.install_stable_toolchain {
32            self.update_toolchain = Some(toolchain);
33        }
34    }
35
36    fn require_host_target(&mut self, target: String) {
37        self.add_host_target = Some(target);
38    }
39
40    /// Returns `true` when at least one automatic fix action is planned.
41    #[must_use]
42    pub const fn has_actions(&self) -> bool {
43        self.install_stable_toolchain
44            || self.update_toolchain.is_some()
45            || self.add_host_target.is_some()
46    }
47
48    /// Human-readable summary of automatic fixes this installation will run.
49    #[must_use]
50    pub fn summary(&self) -> String {
51        let mut actions = Vec::new();
52        if self.install_stable_toolchain {
53            actions.push(String::from("install `rustup toolchain install stable`"));
54        }
55        if let Some(toolchain) = &self.update_toolchain {
56            actions.push(format!("update `{toolchain}` via rustup"));
57        }
58        if let Some(target) = &self.add_host_target {
59            actions.push(format!("add host target via `rustup target add {target}`"));
60        }
61
62        if actions.is_empty() {
63            String::from("no automatic actions required")
64        } else {
65            actions.join(", ")
66        }
67    }
68}
69
70/// Errors that can occur during Rust toolchain installation.
71#[derive(Debug, thiserror::Error)]
72pub enum FailToInstallRustToolchain {
73    /// rustup was not found.
74    #[error("rustup is required for automatic Rust toolchain fixes but is not on PATH.")]
75    RustupNotFound,
76    /// Failed to install stable toolchain.
77    #[error("Failed to install Rust stable toolchain: {0}")]
78    InstallStableToolchain(#[source] CommandError),
79    /// Failed to update the active toolchain.
80    #[error("Failed to update active Rust toolchain `{toolchain}`: {source}")]
81    UpdateToolchain {
82        /// Active rustup toolchain that failed to update.
83        toolchain: String,
84        /// Underlying command error.
85        source: CommandError,
86    },
87    /// Failed to add host target.
88    #[error("Failed to add Rust host target `{target}`: {source}")]
89    AddHostTarget {
90        /// Target triple that failed to install.
91        target: String,
92        /// Underlying command error.
93        source: CommandError,
94    },
95}
96
97/// Parsing rustup/rustc output or a version string failed.
98#[derive(Debug, thiserror::Error)]
99enum RustParseError {
100    /// `rustup show active-toolchain` printed no toolchain token.
101    #[error("expected `<toolchain> (<reason>)` output")]
102    ActiveToolchain,
103    /// `rustc --version` printed no version token.
104    #[error("expected `rustc <version>` output")]
105    RustcVersion,
106    /// `rustc -vV` printed no `host:` line.
107    #[error("missing `host:` line")]
108    HostLine,
109    /// The version string could not be normalized to semver.
110    #[error(transparent)]
111    Version(#[from] crate::utils::VersionParseError),
112}
113
114impl Installation for RustToolchainInstallation {
115    type Error = FailToInstallRustToolchain;
116
117    async fn install(&self, host: &Host) -> Result<(), Self::Error> {
118        if !self.has_actions() {
119            return Ok(());
120        }
121
122        if host.which("rustup").await.is_err() {
123            return Err(FailToInstallRustToolchain::RustupNotFound);
124        }
125
126        if self.install_stable_toolchain {
127            host.run("rustup", ["toolchain", "install", "stable"])
128                .await
129                .map_err(FailToInstallRustToolchain::InstallStableToolchain)?;
130        }
131
132        if let Some(toolchain) = &self.update_toolchain {
133            host.run("rustup", ["update", toolchain.as_str()])
134                .await
135                .map_err(|source| FailToInstallRustToolchain::UpdateToolchain {
136                    toolchain: toolchain.clone(),
137                    source,
138                })?;
139        }
140
141        if let Some(target) = &self.add_host_target {
142            host.run("rustup", ["target", "add", target.as_str()])
143                .await
144                .map_err(|source| FailToInstallRustToolchain::AddHostTarget {
145                    target: target.clone(),
146                    source,
147                })?;
148        }
149
150        Ok(())
151    }
152}
153
154impl Toolchain for RustToolchain {
155    type Installation = RustToolchainInstallation;
156
157    async fn check(&self, host: &Host) -> Result<(), ToolchainError<Self::Installation>> {
158        let availability = detect_rust_tool_availability(host).await;
159        ensure_minimum_rust_tools(availability)?;
160        let mut installation = RustToolchainInstallation::default();
161        let active_toolchain =
162            check_active_toolchain(host, availability.rustup_available, &mut installation).await?;
163        ensure_cargo_available(availability, &mut installation)?;
164        let host_target = check_rustc_version_and_host_target(
165            host,
166            availability,
167            active_toolchain.as_deref(),
168            &mut installation,
169        )
170        .await?;
171        check_installed_targets(
172            host,
173            availability.rustup_available,
174            &installation,
175            host_target,
176        )
177        .await?;
178
179        installation
180            .has_actions()
181            .then_some(ToolchainError::fixable(installation))
182            .map_or(Ok(()), Err)
183    }
184}
185
186#[derive(Debug, Clone, Copy)]
187struct RustToolAvailability {
188    rustup_available: bool,
189    cargo_available: bool,
190    rustc_available: bool,
191}
192
193fn ensure_minimum_rust_tools(
194    availability: RustToolAvailability,
195) -> Result<(), ToolchainError<RustToolchainInstallation>> {
196    if !availability.rustup_available
197        && (!availability.cargo_available || !availability.rustc_available)
198    {
199        return Err(ToolchainError::unfixable(
200            "Rust toolchain is incomplete (`cargo` and/or `rustc` is missing from PATH).",
201            "Install rustup from https://rustup.rs, then run `rustup toolchain install stable`.",
202        ));
203    }
204    Ok(())
205}
206
207async fn detect_rust_tool_availability(host: &Host) -> RustToolAvailability {
208    RustToolAvailability {
209        rustup_available: host.which("rustup").await.is_ok(),
210        cargo_available: host.which("cargo").await.is_ok(),
211        rustc_available: host.which("rustc").await.is_ok(),
212    }
213}
214
215async fn check_active_toolchain(
216    host: &Host,
217    rustup_available: bool,
218    installation: &mut RustToolchainInstallation,
219) -> Result<Option<String>, ToolchainError<RustToolchainInstallation>> {
220    if !rustup_available {
221        return Ok(None);
222    }
223
224    match host.run("rustup", ["show", "active-toolchain"]).await {
225        Ok(output) => parse_active_toolchain(&output).map(Some).map_err(|error| {
226            ToolchainError::unfixable(
227                format!("Could not parse the active rustup toolchain: {error}"),
228                "Run `rustup show active-toolchain`; repair or reinstall rustup if it does not return a toolchain name.",
229            )
230        }),
231        Err(error) => {
232            let error_message = error.to_string();
233            if is_no_active_toolchain_error(&error_message) {
234                installation.require_stable_install();
235                Ok(None)
236            } else {
237                Err(ToolchainError::unfixable(
238                    format!(
239                        "rustup is installed but cannot report an active toolchain: {error_message}"
240                    ),
241                    "Run `rustup self update` and `rustup toolchain install stable`; if that fails, reinstall rustup from https://rustup.rs.",
242                ))
243            }
244        }
245    }
246}
247
248fn ensure_cargo_available(
249    availability: RustToolAvailability,
250    installation: &mut RustToolchainInstallation,
251) -> Result<(), ToolchainError<RustToolchainInstallation>> {
252    if availability.cargo_available {
253        return Ok(());
254    }
255    if availability.rustup_available {
256        installation.require_stable_install();
257        Ok(())
258    } else {
259        Err(ToolchainError::unfixable(
260            "`cargo` is not available on PATH.",
261            "Install rustup from https://rustup.rs, then run `rustup toolchain install stable`.",
262        ))
263    }
264}
265
266async fn check_rustc_version_and_host_target(
267    host: &Host,
268    availability: RustToolAvailability,
269    active_toolchain: Option<&str>,
270    installation: &mut RustToolchainInstallation,
271) -> Result<Option<String>, ToolchainError<RustToolchainInstallation>> {
272    if !availability.rustc_available {
273        return handle_missing_rustc(availability.rustup_available, installation);
274    }
275
276    let version_output = host.run("rustc", ["--version"]).await.map_err(|error| {
277        let error_message = error.to_string();
278        rustc_run_error(availability.rustup_available, &error_message)
279    })?;
280    let installed_version = parse_installed_rustc_version(&version_output)?;
281    let required_version = parse_required_rustc_version()?;
282    maybe_require_rust_update(
283        availability.rustup_available,
284        active_toolchain,
285        &installed_version,
286        &required_version,
287        installation,
288    )?;
289
290    if !availability.rustup_available || installation.install_stable_toolchain {
291        return Ok(None);
292    }
293
294    let rustc_verbose = host.run("rustc", ["-vV"]).await.map_err(|error| {
295        ToolchainError::unfixable(
296            format!("`rustc -vV` failed: {error}"),
297            "Run `rustc -vV` manually; if it fails, reinstall rustup from https://rustup.rs.",
298        )
299    })?;
300    parse_host_target_value(&rustc_verbose).map(Some)
301}
302
303fn handle_missing_rustc(
304    rustup_available: bool,
305    installation: &mut RustToolchainInstallation,
306) -> Result<Option<String>, ToolchainError<RustToolchainInstallation>> {
307    if rustup_available {
308        installation.require_stable_install();
309        Ok(None)
310    } else {
311        Err(ToolchainError::unfixable(
312            "`rustc` is not available on PATH.",
313            "Install rustup from https://rustup.rs, then run `rustup toolchain install stable`.",
314        ))
315    }
316}
317
318fn rustc_run_error(
319    rustup_available: bool,
320    error_message: &str,
321) -> ToolchainError<RustToolchainInstallation> {
322    if rustup_available {
323        let mut installation = RustToolchainInstallation::default();
324        installation.require_stable_install();
325        ToolchainError::fixable(installation)
326    } else {
327        ToolchainError::unfixable(
328            format!("`rustc` exists on PATH but failed to run: {error_message}"),
329            "Reinstall Rust toolchain via rustup from https://rustup.rs.",
330        )
331    }
332}
333
334fn parse_installed_rustc_version(
335    version_output: &str,
336) -> Result<Version, ToolchainError<RustToolchainInstallation>> {
337    parse_rustc_version(version_output).map_err(|error| {
338        ToolchainError::unfixable(
339            format!(
340                "Failed to parse `rustc --version` output `{}`: {error}",
341                version_output.trim()
342            ),
343            "Run `rustc --version` manually. If output is malformed, reinstall rustup from https://rustup.rs.",
344        )
345    })
346}
347
348fn parse_required_rustc_version() -> Result<Version, ToolchainError<RustToolchainInstallation>> {
349    required_rust_version().map_err(|error| {
350        ToolchainError::unfixable(
351            format!("Invalid required Rust version `{REQUIRED_RUST_VERSION}`: {error}"),
352            "Reinstall waterui-cli from source to restore a valid embedded Rust requirement.",
353        )
354    })
355}
356
357fn maybe_require_rust_update(
358    rustup_available: bool,
359    active_toolchain: Option<&str>,
360    installed_version: &Version,
361    required_version: &Version,
362    installation: &mut RustToolchainInstallation,
363) -> Result<(), ToolchainError<RustToolchainInstallation>> {
364    if installed_version >= required_version {
365        return Ok(());
366    }
367
368    if rustup_available {
369        match active_toolchain {
370            Some(toolchain) => installation.require_toolchain_update(toolchain.to_owned()),
371            None => installation.require_stable_install(),
372        }
373        Ok(())
374    } else {
375        Err(ToolchainError::unfixable(
376            format!(
377                "Detected Rust {installed_version}, but waterui-cli requires at least Rust {required_version}."
378            ),
379            format!(
380                "Install Rust {required_version} or newer. Recommended: install rustup from https://rustup.rs, then run `rustup update stable`."
381            ),
382        ))
383    }
384}
385
386fn parse_host_target_value(
387    rustc_verbose: &str,
388) -> Result<String, ToolchainError<RustToolchainInstallation>> {
389    parse_host_target(rustc_verbose).map_err(|error| {
390        ToolchainError::unfixable(
391            format!("Could not parse host target from `rustc -vV`: {error}"),
392            "Ensure `rustc -vV` includes a `host: <target>` line; reinstall rustup if the output is incomplete.",
393        )
394    })
395}
396
397async fn check_installed_targets(
398    host: &Host,
399    rustup_available: bool,
400    installation: &RustToolchainInstallation,
401    host_target: Option<String>,
402) -> Result<(), ToolchainError<RustToolchainInstallation>> {
403    if !rustup_available || installation.install_stable_toolchain {
404        return Ok(());
405    }
406
407    let Some(host_target) = host_target else {
408        return Ok(());
409    };
410
411    let installed_targets = installed_rustup_targets(host).await.map_err(|error| {
412        ToolchainError::unfixable(
413            format!("Failed to list installed Rust targets: {error}"),
414            "Run `rustup target list --installed`; if it fails, repair rustup with `rustup self update` or reinstall rustup.",
415        )
416    })?;
417
418    if installed_targets
419        .iter()
420        .any(|target| target == &host_target)
421    {
422        return Ok(());
423    }
424
425    let mut installation = installation.clone();
426    installation.require_host_target(host_target);
427    Err(ToolchainError::fixable(installation))
428}
429
430async fn installed_rustup_targets(host: &Host) -> Result<Vec<String>, CommandError> {
431    let installed = host
432        .run("rustup", ["target", "list", "--installed"])
433        .await?;
434    Ok(installed
435        .lines()
436        .map(str::trim)
437        .filter(|line| !line.is_empty())
438        .map(ToOwned::to_owned)
439        .collect())
440}
441
442fn required_rust_version() -> Result<Version, RustParseError> {
443    Ok(parse_semver_version(REQUIRED_RUST_VERSION)?)
444}
445
446fn parse_active_toolchain(output: &str) -> Result<String, RustParseError> {
447    output
448        .split_whitespace()
449        .next()
450        .filter(|toolchain| !toolchain.is_empty())
451        .map(ToOwned::to_owned)
452        .ok_or(RustParseError::ActiveToolchain)
453}
454
455fn parse_rustc_version(output: &str) -> Result<Version, RustParseError> {
456    let version_token = output
457        .split_whitespace()
458        .nth(1)
459        .ok_or(RustParseError::RustcVersion)?;
460    Ok(parse_semver_version(version_token)?)
461}
462
463fn parse_host_target(output: &str) -> Result<String, RustParseError> {
464    output
465        .lines()
466        .find_map(|line| {
467            line.strip_prefix("host:")
468                .map(str::trim)
469                .filter(|target| !target.is_empty())
470                .map(ToOwned::to_owned)
471        })
472        .ok_or(RustParseError::HostLine)
473}
474
475fn is_no_active_toolchain_error(error: &str) -> bool {
476    let normalized = error.to_ascii_lowercase();
477    normalized.contains("no active toolchain")
478        || normalized.contains("default toolchain")
479        || normalized.contains("not installed")
480}
481
482#[cfg(test)]
483mod tests {
484    use semver::Version;
485
486    use super::{
487        RustToolchainInstallation, parse_active_toolchain, parse_host_target, parse_rustc_version,
488    };
489
490    #[test]
491    fn parse_rustc_version_accepts_prerelease() {
492        let parsed =
493            parse_rustc_version("rustc 1.88.0-nightly (d9a5f4fa4 2026-01-01)").expect("version");
494        assert_eq!(
495            parsed,
496            Version::parse("1.88.0-nightly").expect("expected semver")
497        );
498    }
499
500    #[test]
501    fn parse_active_toolchain_preserves_selected_channel() {
502        let toolchain = parse_active_toolchain("nightly-aarch64-apple-darwin (default)")
503            .expect("active toolchain");
504        assert_eq!(toolchain, "nightly-aarch64-apple-darwin");
505    }
506
507    #[test]
508    fn parse_host_target_extracts_host_line() {
509        let output = "rustc 1.88.0 (aabbcc 2026-01-01)\nbinary: rustc\nhost: x86_64-unknown-linux-gnu\nrelease: 1.88.0\n";
510        let host = parse_host_target(output).expect("host target");
511        assert_eq!(host, "x86_64-unknown-linux-gnu");
512    }
513
514    #[test]
515    fn installation_summary_lists_actions() {
516        let mut installation = RustToolchainInstallation::default();
517        installation.require_toolchain_update(String::from("nightly-aarch64-apple-darwin"));
518        installation.require_host_target(String::from("x86_64-unknown-linux-gnu"));
519        let summary = installation.summary();
520        assert!(summary.contains("update `nightly-aarch64-apple-darwin` via rustup"));
521        assert!(summary.contains("rustup target add x86_64-unknown-linux-gnu"));
522    }
523}
524
525#[cfg(test)]
526mod host_tests {
527    use super::{REQUIRED_RUST_VERSION, RustToolchain};
528    use crate::toolchain::testing::TestMachine;
529    use crate::toolchain::{Toolchain, ToolchainError};
530
531    const FAKE_TARGET: &str = "wasm32test-test-none";
532
533    /// A host with rustup/cargo/rustc fakes reporting a compliant toolchain.
534    fn complete_machine() -> TestMachine {
535        let machine = TestMachine::new();
536        for tool in ["rustup", "cargo", "rustc"] {
537            machine.install(tool);
538        }
539        machine
540    }
541
542    /// Declared vars for a complete machine: current rustc, an active
543    /// toolchain, and `FAKE_TARGET` both as the rustc host triple and among
544    /// the installed rustup targets.
545    fn complete_vars() -> Vec<(String, String)> {
546        vec![
547            (
548                String::from("WATERUI_FAKE_RUSTC_VERSION"),
549                format!("{REQUIRED_RUST_VERSION}.0"),
550            ),
551            (
552                String::from("WATERUI_FAKE_RUSTC_HOST"),
553                FAKE_TARGET.to_string(),
554            ),
555            (
556                String::from("WATERUI_FAKE_RUSTUP_ACTIVE_TOOLCHAIN"),
557                format!("stable-{FAKE_TARGET} (default)"),
558            ),
559            (
560                String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
561                FAKE_TARGET.to_string(),
562            ),
563        ]
564    }
565
566    #[test]
567    fn check_unfixable_when_no_rust_tools_exist() {
568        let machine = TestMachine::new();
569        let host = machine.host(Vec::<(String, String)>::new());
570        let result = smol::block_on(RustToolchain.check(&host));
571        assert!(
572            matches!(result, Err(ToolchainError::Unfixable(_))),
573            "bare host must report an unfixable Rust toolchain: {result:?}"
574        );
575    }
576
577    #[test]
578    fn check_ok_on_complete_fake_toolchain() {
579        let machine = complete_machine();
580        let host = machine.host(complete_vars());
581        smol::block_on(RustToolchain.check(&host)).expect("complete fake toolchain must be ok");
582    }
583
584    #[test]
585    fn check_fixable_when_rustc_too_old() {
586        let machine = complete_machine();
587        let mut vars = complete_vars();
588        vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTC_VERSION");
589        vars.push((
590            String::from("WATERUI_FAKE_RUSTC_VERSION"),
591            String::from("1.0.0"),
592        ));
593        let host = machine.host(vars);
594        let result = smol::block_on(RustToolchain.check(&host));
595        assert!(
596            matches!(result, Err(ToolchainError::Fixable(_))),
597            "outdated rustc under rustup must be fixable: {result:?}"
598        );
599    }
600
601    #[test]
602    fn check_unfixable_when_rustc_too_old_without_rustup() {
603        let machine = TestMachine::new();
604        machine.install("cargo");
605        machine.install("rustc");
606        let host = machine.host([(
607            String::from("WATERUI_FAKE_RUSTC_VERSION"),
608            String::from("1.0.0"),
609        )]);
610        let result = smol::block_on(RustToolchain.check(&host));
611        assert!(
612            matches!(result, Err(ToolchainError::Unfixable(_))),
613            "outdated rustc without rustup cannot be fixed automatically: {result:?}"
614        );
615    }
616
617    #[test]
618    fn check_fixable_when_no_active_toolchain() {
619        let machine = complete_machine();
620        let mut vars = complete_vars();
621        vars.push((
622            String::from("WATERUI_FAKE_RUSTUP_NO_ACTIVE_TOOLCHAIN"),
623            String::from("1"),
624        ));
625        let host = machine.host(vars);
626        let result = smol::block_on(RustToolchain.check(&host));
627        assert!(
628            matches!(result, Err(ToolchainError::Fixable(_))),
629            "rustup without an active toolchain must plan a stable install: {result:?}"
630        );
631    }
632
633    #[test]
634    fn check_fixable_when_host_target_not_installed() {
635        let machine = complete_machine();
636        let mut vars = complete_vars();
637        vars.retain(|(key, _)| key != "WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS");
638        vars.push((
639            String::from("WATERUI_FAKE_RUSTUP_INSTALLED_TARGETS"),
640            String::from("some-other-target"),
641        ));
642        let host = machine.host(vars);
643        let result = smol::block_on(RustToolchain.check(&host));
644        assert!(
645            matches!(result, Err(ToolchainError::Fixable(_))),
646            "missing host target must plan `rustup target add`: {result:?}"
647        );
648    }
649}