1use std::fmt::Write as _;
16use std::fs;
17use std::path::Path;
18use std::process::Command;
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23use super::{Answer, Op, codes};
24
25pub(crate) mod linux;
26
27pub const WASM_TARGET: &str = "wasm32-unknown-unknown";
29
30pub const MSYS2_GCC: &str = "C:/msys64/mingw64/bin/gcc.exe";
33
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct DoctorInput {}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(deny_unknown_fields)]
42pub struct Check {
43 pub name: String,
45 pub ok: bool,
47 pub found: Option<String>,
49 pub help: Option<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(deny_unknown_fields)]
56pub struct DoctorOutput {
57 pub host: Option<String>,
59 pub checks: Vec<Check>,
61}
62
63pub trait Probe {
65 fn output(&self, program: &str, args: &[&str]) -> Option<String>;
68
69 fn exists(&self, path: &Path) -> bool;
71
72 fn read(&self, path: &Path) -> Option<String>;
74}
75
76#[derive(Debug, Clone, Copy, Default)]
78pub struct ThisMachine;
79
80impl Probe for ThisMachine {
81 fn output(&self, program: &str, args: &[&str]) -> Option<String> {
82 let out = Command::new(program).args(args).output().ok()?;
83 out.status
84 .success()
85 .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
86 }
87
88 fn exists(&self, path: &Path) -> bool {
89 path.is_file()
90 }
91
92 fn read(&self, path: &Path) -> Option<String> {
93 fs::read_to_string(path).ok()
94 }
95}
96
97fn pass(name: &str, found: impl Into<String>) -> Check {
99 Check {
100 name: name.to_owned(),
101 ok: true,
102 found: Some(found.into()),
103 help: None,
104 }
105}
106
107fn fail(name: &str, found: Option<String>, help: impl Into<String>) -> Check {
110 Check {
111 name: name.to_owned(),
112 ok: false,
113 found,
114 help: Some(help.into()),
115 }
116}
117
118fn first_line(text: &str) -> String {
120 text.lines().next().unwrap_or_default().trim().to_owned()
121}
122
123fn host_of(verbose: &str) -> Option<String> {
125 verbose
126 .lines()
127 .find_map(|line| line.strip_prefix("host: "))
128 .map(|host| host.trim().to_owned())
129}
130
131pub fn checks(probe: &dyn Probe) -> DoctorOutput {
133 let mut checks = Vec::new();
134 let rustc = probe.output("rustc", &["-vV"]);
135 let host = rustc.as_deref().and_then(host_of);
136 checks.push(match &rustc {
137 Some(text) => pass("rust", first_line(text)),
138 None => fail(
139 "rust",
140 None,
141 "install Rust with rustup: https://rustup.rs (on Windows choose the GNU toolchain, \
142 x86_64-pc-windows-gnu, if MSVC isn't installed)",
143 ),
144 });
145 checks.push(match probe.output("cargo", &["--version"]) {
146 Some(text) => pass("cargo", first_line(&text)),
147 None => fail(
148 "cargo",
149 None,
150 "install Rust with rustup, which brings cargo",
151 ),
152 });
153 let targets = probe.output("rustup", &["target", "list", "--installed"]);
154 checks.push(match &targets {
155 Some(text) => pass(
156 "rustup",
157 format!(
158 "targets: {}",
159 text.split_whitespace().collect::<Vec<_>>().join(", ")
160 ),
161 ),
162 None => fail(
163 "rustup",
164 None,
165 "install rustup (https://rustup.rs): the targets are added with it",
166 ),
167 });
168 if host.as_deref().is_some_and(|h| h.ends_with("windows-gnu")) {
169 let msys = Path::new(MSYS2_GCC);
170 checks.push(if probe.exists(msys) {
171 pass("msys2-gcc", MSYS2_GCC)
172 } else {
173 match probe.output("gcc", &["--version"]) {
174 Some(text) => pass("msys2-gcc", format!("gcc on PATH: {}", first_line(&text))),
175 None => fail(
176 "msys2-gcc",
177 None,
178 "the GNU toolchain links with MSYS2's mingw-w64 GCC: `winget install -e --id \
179 MSYS2.MSYS2`, then `C:\\msys64\\usr\\bin\\pacman.exe -S \
180 mingw-w64-x86_64-gcc`, and put C:\\msys64\\mingw64\\bin on PATH",
181 ),
182 }
183 });
184 }
185 if host.as_deref().is_some_and(|h| h.contains("-linux-")) {
186 checks.extend(linux::checks(probe));
187 }
188 if host.as_deref().is_some_and(|h| h.ends_with("windows-gnu")) {
192 checks.push(match probe.output("objcopy", &["--version"]) {
193 Some(text) => pass("objcopy", first_line(&text)),
194 None => fail(
195 "objcopy",
196 None,
197 "`export --release` strips the package's program with binutils' objcopy, which \
198 comes with MSYS2's mingw-w64 GCC: `C:\\msys64\\usr\\bin\\pacman.exe -S \
199 mingw-w64-x86_64-binutils`, with C:\\msys64\\mingw64\\bin on PATH",
200 ),
201 });
202 }
203 let has_wasm = targets
204 .as_deref()
205 .is_some_and(|t| t.split_whitespace().any(|line| line == WASM_TARGET));
206 checks.push(if has_wasm {
207 pass("wasm-target", WASM_TARGET)
208 } else {
209 fail(
210 "wasm-target",
211 None,
212 format!("for the web build: `rustup target add {WASM_TARGET}`"),
213 )
214 });
215 DoctorOutput { host, checks }
216}
217
218pub fn doctor(probe: &dyn Probe, _input: &DoctorInput) -> Answer<DoctorOutput> {
220 let output = checks(probe);
221 let ok = output.checks.iter().all(|c| c.ok);
222 let mut limits = Vec::new();
223 if output.host.is_none() {
224 limits.push(
225 "without rustc the host is unknown: MSYS2 and the Linux libraries were not checked"
226 .to_owned(),
227 );
228 }
229 let answer = Answer::new(Op::Doctor, ok, output, limits);
230 if ok {
231 answer
232 } else {
233 answer
234 .with_code(codes::TOOLCHAIN)
235 .with_help("fix each failed check as its help says, then run doctor again")
236 }
237}
238
239pub fn text(output: &DoctorOutput) -> String {
241 let mut out = format!(
242 "doctor: host {}\n",
243 output.host.as_deref().unwrap_or("unknown")
244 );
245 for check in &output.checks {
246 let mark = if check.ok { "ok " } else { "FAIL" };
247 let _ = write!(out, " {mark} {}", check.name);
249 if let Some(found) = &check.found {
250 let _ = write!(out, ": {found}");
251 }
252 out.push('\n');
253 if let Some(help) = &check.help {
254 let _ = writeln!(out, " help: {help}");
255 }
256 }
257 out
258}
259
260#[cfg(test)]
261mod tests {
262 use std::collections::BTreeMap;
263 use std::path::PathBuf;
264
265 use super::*;
266
267 #[derive(Default)]
270 pub(super) struct Fake {
271 pub(super) programs: BTreeMap<String, String>,
272 pub(super) files: Vec<PathBuf>,
273 texts: BTreeMap<PathBuf, String>,
274 }
275
276 impl Fake {
277 pub(super) fn with(mut self, command: &str, output: &str) -> Self {
278 self.programs.insert(command.to_owned(), output.to_owned());
279 self
280 }
281
282 pub(super) fn reading(mut self, path: &str, text: &str) -> Self {
284 self.texts.insert(PathBuf::from(path), text.to_owned());
285 self
286 }
287 }
288
289 impl Probe for Fake {
290 fn output(&self, program: &str, args: &[&str]) -> Option<String> {
291 let command = std::iter::once(program)
292 .chain(args.iter().copied())
293 .collect::<Vec<_>>()
294 .join(" ");
295 self.programs.get(&command).cloned()
296 }
297
298 fn exists(&self, path: &Path) -> bool {
299 self.files.iter().any(|f| f == path)
300 }
301
302 fn read(&self, path: &Path) -> Option<String> {
303 self.texts.get(path).cloned()
304 }
305 }
306
307 pub(super) fn rust(host: &str) -> Fake {
308 Fake::default()
309 .with(
310 "rustc -vV",
311 &format!("rustc 1.98.1 (abc 2026-09-01)\nbinary: rustc\nhost: {host}\n"),
312 )
313 .with("cargo --version", "cargo 1.98.1 (def 2026-09-01)\n")
314 }
315
316 fn names(output: &DoctorOutput, ok: bool) -> Vec<&str> {
317 output
318 .checks
319 .iter()
320 .filter(|c| c.ok == ok)
321 .map(|c| c.name.as_str())
322 .collect()
323 }
324
325 #[test]
326 fn a_complete_linux_toolchain_passes() {
327 let mut machine = rust("x86_64-unknown-linux-gnu")
328 .with(
329 "rustup target list --installed",
330 "x86_64-unknown-linux-gnu\nwasm32-unknown-unknown\n",
331 )
332 .with("cc --version", "cc (Ubuntu 13.3.0-6ubuntu2) 13.3.0\n")
333 .with("pkg-config --version", "1.8.1\n")
334 .with("pkg-config --modversion alsa", "1.2.11\n")
335 .with("pkg-config --modversion libudev", "255\n")
336 .reading("/etc/os-release", "ID=ubuntu\n");
337 for soname in [
338 "libX11.so.6",
339 "libXi.so.6",
340 "libxkbcommon.so.0",
341 "libGL.so.1",
342 ] {
343 machine
344 .files
345 .push(PathBuf::from(format!("/usr/lib/x86_64-linux-gnu/{soname}")));
346 }
347 let answer = doctor(&machine, &DoctorInput::default());
348 assert!(answer.ok, "{answer:?}");
349 assert_eq!((answer.code.as_deref(), answer.exit_code()), (None, 0));
350 let output = answer.result.unwrap();
351 assert_eq!(output.host.as_deref(), Some("x86_64-unknown-linux-gnu"));
352 assert_eq!(
353 names(&output, true),
354 [
355 "rust",
356 "cargo",
357 "rustup",
358 "cc",
359 "pkg-config",
360 "lib-alsa",
361 "lib-udev",
362 "lib-x11",
363 "lib-xi",
364 "lib-xkbcommon",
365 "lib-gl",
366 "wasm-target"
367 ]
368 );
369 assert_eq!(
370 output.checks[3].found.as_deref(),
371 Some("cc (Ubuntu 13.3.0-6ubuntu2) 13.3.0")
372 );
373 assert_eq!(
374 output.checks[0].found.as_deref(),
375 Some("rustc 1.98.1 (abc 2026-09-01)")
376 );
377 assert_eq!(
378 output.checks[2].found.as_deref(),
379 Some("targets: x86_64-unknown-linux-gnu, wasm32-unknown-unknown")
380 );
381 assert!(text(&output).contains(" ok wasm-target: wasm32-unknown-unknown\n"));
382 }
383
384 #[test]
385 fn windows_gnu_needs_msys2s_gcc_and_says_how_to_get_it() {
386 let targets = "x86_64-pc-windows-gnu\n";
387 let machine = rust("x86_64-pc-windows-gnu").with("rustup target list --installed", targets);
388 let output = checks(&machine);
389 assert_eq!(
390 names(&output, false),
391 ["msys2-gcc", "objcopy", "wasm-target"]
392 );
393 let msys = &output.checks[3];
394 assert!(msys.help.as_deref().unwrap().contains("MSYS2.MSYS2"));
395 let objcopy = &output.checks[4];
396 assert!(
397 objcopy
398 .help
399 .as_deref()
400 .unwrap()
401 .contains("mingw-w64-x86_64-binutils")
402 );
403 let with = machine.with("objcopy --version", "GNU objcopy (GNU Binutils) 2.45\n");
404 let check = &checks(&with).checks[4];
405 assert_eq!(
406 check.found.as_deref(),
407 Some("GNU objcopy (GNU Binutils) 2.45")
408 );
409 let wasm = output.checks.last().unwrap();
410 assert_eq!(
411 wasm.help.as_deref(),
412 Some("for the web build: `rustup target add wasm32-unknown-unknown`")
413 );
414 let said = text(&output);
415 assert!(
416 said.contains(" FAIL msys2-gcc\n help: the GNU toolchain"),
417 "{said}"
418 );
419 let mut installed = rust("x86_64-pc-windows-gnu");
421 installed.files.push(PathBuf::from(MSYS2_GCC));
422 assert!(checks(&installed).checks[3].ok);
423 let on_path = rust("x86_64-pc-windows-gnu").with("gcc --version", "gcc (Rev1) 14.2.0\n");
424 let check = &checks(&on_path).checks[3];
425 assert_eq!(
426 check.found.as_deref(),
427 Some("gcc on PATH: gcc (Rev1) 14.2.0")
428 );
429 }
430
431 #[test]
432 fn without_rust_every_check_says_how_to_install_it() {
433 let answer = doctor(&Fake::default(), &DoctorInput::default());
434 assert!(!answer.ok);
435 assert_eq!(answer.code.as_deref(), Some("SH1003"));
436 assert_eq!(answer.exit_code(), 3);
437 assert!(
438 answer
439 .help
440 .as_deref()
441 .unwrap()
442 .contains("then run doctor again")
443 );
444 let said = answer.to_text(text);
445 assert!(
446 said.contains("doctor: error[SH1003]: A piece of the toolchain is missing\n"),
447 "{said}"
448 );
449 let output = answer.result.as_ref().unwrap();
450 assert_eq!(output.host, None);
451 assert_eq!(
452 names(output, false),
453 ["rust", "cargo", "rustup", "wasm-target"]
454 );
455 assert!(output.checks.iter().all(|c| c.help.is_some()));
456 assert_eq!(
457 answer.limits,
458 ["without rustc the host is unknown: MSYS2 and the Linux libraries were not checked"]
459 );
460 assert!(text(output).starts_with("doctor: host unknown\n"));
461 assert_eq!(checks(&rust("x86_64-pc-windows-msvc")).checks.len(), 4);
463 }
464
465 #[test]
466 fn this_machine_runs_programs_and_finds_files() {
467 let cargo = ThisMachine.output("cargo", &["--version"]).unwrap();
468 assert!(cargo.starts_with("cargo "), "{cargo}");
469 assert_eq!(ThisMachine.output("cargo", &["--no-such-flag"]), None);
470 assert_eq!(ThisMachine.output("no-such-program-anywhere", &[]), None);
471 let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
472 assert!(ThisMachine.exists(&manifest));
473 assert!(!ThisMachine.exists(&manifest.with_file_name("Nope.toml")));
474 assert!(!ThisMachine.exists(Path::new(env!("CARGO_MANIFEST_DIR"))));
475 let text = ThisMachine.read(&manifest).unwrap();
476 assert!(text.contains("name = \"shuyi-cli\""), "{text}");
477 assert_eq!(
478 ThisMachine.read(&manifest.with_file_name("Nope.toml")),
479 None
480 );
481 }
482}