1use 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#[derive(Debug, Clone, Copy, Default)]
14pub struct RustToolchain;
15
16#[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 #[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 #[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#[derive(Debug, thiserror::Error)]
72pub enum FailToInstallRustToolchain {
73 #[error("rustup is required for automatic Rust toolchain fixes but is not on PATH.")]
75 RustupNotFound,
76 #[error("Failed to install Rust stable toolchain: {0}")]
78 InstallStableToolchain(#[source] CommandError),
79 #[error("Failed to update active Rust toolchain `{toolchain}`: {source}")]
81 UpdateToolchain {
82 toolchain: String,
84 source: CommandError,
86 },
87 #[error("Failed to add Rust host target `{target}`: {source}")]
89 AddHostTarget {
90 target: String,
92 source: CommandError,
94 },
95}
96
97#[derive(Debug, thiserror::Error)]
99enum RustParseError {
100 #[error("expected `<toolchain> (<reason>)` output")]
102 ActiveToolchain,
103 #[error("expected `rustc <version>` output")]
105 RustcVersion,
106 #[error("missing `host:` line")]
108 HostLine,
109 #[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 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 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}