Skip to main content

shuyi_cli/contract/
doctor.rs

1//! `doctor`: checks the toolchain a game on the engine builds with: Rust
2//! and Cargo, rustup's targets, MSYS2's linker and `objcopy` on the
3//! Windows GNU toolchain (`AGENTS.md`), on Linux the C linker, `pkg-config` and the
4//! system libraries the backend links and opens, and the web target.
5//! Each check says what it found and, if it failed, how to fix it (its
6//! help): on Linux, the install command for the distribution's package
7//! manager (`/etc/os-release`), and `scripts/setup-linux.sh`, which
8//! installs the same list. It passes when
9//! every check does; when one fails, it answers [`codes::TOOLCHAIN`] and
10//! exits 3.
11//!
12//! What it runs and looks at goes through a [`Probe`], so its tests can
13//! give it any machine.
14
15use 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
27/// The web build's target.
28pub const WASM_TARGET: &str = "wasm32-unknown-unknown";
29
30/// Where MSYS2 puts the mingw-w64 GCC that links on the Windows GNU
31/// toolchain (the engine's `.cargo/config.toml` names it as the linker).
32pub const MSYS2_GCC: &str = "C:/msys64/mingw64/bin/gcc.exe";
33
34/// What `doctor` checks: everything (nothing to choose yet).
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct DoctorInput {}
38
39/// One check.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
41#[serde(deny_unknown_fields)]
42pub struct Check {
43    /// What it checks (`rust`, `wasm-target`…).
44    pub name: String,
45    /// Whether it passed.
46    pub ok: bool,
47    /// What it found, if anything.
48    pub found: Option<String>,
49    /// How to fix it, if it failed.
50    pub help: Option<String>,
51}
52
53/// What `doctor` found.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(deny_unknown_fields)]
56pub struct DoctorOutput {
57    /// The Rust host's target triple, if Rust was found.
58    pub host: Option<String>,
59    /// Every check, in order.
60    pub checks: Vec<Check>,
61}
62
63/// How `doctor` looks at the machine.
64pub trait Probe {
65    /// What `program` with `args` prints on stdout, if it runs and
66    /// succeeds.
67    fn output(&self, program: &str, args: &[&str]) -> Option<String>;
68
69    /// Whether the file at `path` exists.
70    fn exists(&self, path: &Path) -> bool;
71
72    /// The text of the file at `path`, if it can be read.
73    fn read(&self, path: &Path) -> Option<String>;
74}
75
76/// The machine `doctor` runs on.
77#[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
97/// A check that passed, having found `found`.
98fn 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
107/// A check that failed: `found` (if anything) and how to fix it
108/// (`help`).
109fn 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
118/// The first line of `text`, trimmed.
119fn first_line(text: &str) -> String {
120    text.lines().next().unwrap_or_default().trim().to_owned()
121}
122
123/// The `host:` line of `rustc -vV`'s output.
124fn 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
131/// `doctor`'s checks of the machine `probe` looks at.
132pub 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    // `export --release` strips the package's copy of the program with
189    // binutils' objcopy. On Linux the C linker's packages (the `cc` row)
190    // bring binutils.
191    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
218/// `doctor` on the machine `probe` looks at.
219pub 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
239/// What `doctor` found, for people.
240pub 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        // Writing to a `String` can't fail.
248        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    /// A machine with these programs' outputs, these files and these
268    /// files' texts.
269    #[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        /// The machine, with `text` in the file at `path`.
283        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        // At MSYS2's place, or on PATH, it passes.
420        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        // Another host's MSVC toolchain needs no MSYS2.
462        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}