Skip to main content

frame_cli/
doctor.rs

1//! The prerequisite walkthrough, mechanized — and the knowledge every other
2//! verb inlines into its own failures.
3//!
4//! One table describes every tool a Frame application depends on: what it
5//! is, which command proves it, and where to install it. `frame doctor`
6//! walks the whole table; every other verb asks [`require`] for exactly the
7//! subset it is about to use, so a missing tool surfaces at the earliest,
8//! most fixable moment as a typed refusal carrying its install link — a raw
9//! "command not found" never reaches the user.
10
11use std::fmt;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15/// One prerequisite tool: its user-facing name, the command that proves it,
16/// and the install link a refusal carries.
17#[derive(Debug)]
18pub struct Tool {
19    /// User-facing name, e.g. "Rust toolchain".
20    pub name: &'static str,
21    /// The command probed on PATH.
22    pub command: &'static str,
23    /// Arguments that make the command report its version and exit.
24    pub version_args: &'static [&'static str],
25    /// Where to install it.
26    pub install: &'static str,
27    /// What Frame needs it for.
28    pub purpose: &'static str,
29}
30
31/// The Rust toolchain: builds and tests the application's host.
32pub static RUST: Tool = Tool {
33    name: "Rust toolchain",
34    command: "cargo",
35    version_args: &["--version"],
36    install: "https://rustup.rs",
37    purpose: "builds and tests the application's host",
38};
39
40/// Gleam: compiles and tests the application's component.
41pub static GLEAM: Tool = Tool {
42    name: "Gleam",
43    command: "gleam",
44    version_args: &["--version"],
45    install: "https://gleam.run/getting-started/installing/",
46    purpose: "compiles and tests the application's component",
47};
48
49/// Erlang/OTP: the VM toolchain Gleam compiles through.
50pub static ERLANG: Tool = Tool {
51    name: "Erlang/OTP",
52    command: "erl",
53    version_args: &[
54        "-noshell",
55        "-eval",
56        "io:put_chars(erlang:system_info(otp_release)), halt().",
57    ],
58    install: "https://gleam.run/getting-started/installing/",
59    purpose: "the VM toolchain Gleam compiles through (installed alongside Gleam)",
60};
61
62/// Node.js: typechecks the page and drives the browser proof.
63pub static NODE: Tool = Tool {
64    name: "Node.js",
65    command: "node",
66    version_args: &["--version"],
67    install: "https://nodejs.org/",
68    purpose: "typechecks the page and drives the real-browser proof",
69};
70
71/// npm: installs the page's type and proof toolchain.
72pub static NPM: Tool = Tool {
73    name: "npm",
74    command: "npm",
75    version_args: &["--version"],
76    install: "https://nodejs.org/",
77    purpose: "installs the page's type and proof toolchain (ships with Node.js)",
78};
79
80/// Install link a missing-Chrome refusal carries.
81pub const CHROME_INSTALL: &str = "https://www.google.com/chrome/";
82
83/// Default macOS Chrome location, overridable with `CHROME_BIN`.
84const DEFAULT_CHROME: &str = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
85
86/// Tools `frame new` refuses without: everything the generated application
87/// needs to build, verify, and run.
88pub static NEW_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];
89
90/// Tools `frame run` and `frame build` refuse without: the host build chain
91/// only — a fresh scaffold's page is already compiled, so Node enters only
92/// when page sources change.
93pub static BUILD_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG];
94
95/// Tools `frame check` refuses without.
96pub static CHECK_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];
97
98/// The whole walkthrough, in the order the doctor reports it.
99pub static ALL_TOOLS: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];
100
101/// Every tool a verb needs but the machine lacks, with install links —
102/// batched so one refusal names every gap instead of dribbling them out.
103#[derive(Debug)]
104pub struct MissingTools {
105    /// The verb that refused.
106    pub verb: &'static str,
107    /// Each missing tool with the probe failure that condemned it.
108    pub missing: Vec<(&'static Tool, String)>,
109}
110
111impl fmt::Display for MissingTools {
112    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
113        writeln!(
114            formatter,
115            "{} needs tools this machine is missing:",
116            self.verb
117        )?;
118        for (tool, failure) in &self.missing {
119            writeln!(
120                formatter,
121                "  - {} (`{}`): {failure}\n    it {}; install it from {}",
122                tool.name, tool.command, tool.purpose, tool.install
123            )?;
124        }
125        write!(
126            formatter,
127            "install the tools above, then re-run {} (or run `frame doctor` for the full walkthrough)",
128            self.verb
129        )
130    }
131}
132
133impl std::error::Error for MissingTools {}
134
135/// Probes one tool and returns the first line of its version report.
136///
137/// # Errors
138///
139/// Returns a human-readable probe failure: the command is absent from PATH,
140/// could not be spawned, or exited unsuccessfully.
141pub fn probe(tool: &Tool) -> Result<String, String> {
142    let output = Command::new(tool.command)
143        .args(tool.version_args)
144        .output()
145        .map_err(|error| {
146            if error.kind() == std::io::ErrorKind::NotFound {
147                "not found on PATH".to_owned()
148            } else {
149                format!("could not be started: {error}")
150            }
151        })?;
152    if !output.status.success() {
153        return Err(format!(
154            "version probe exited with {}: {}",
155            output.status,
156            String::from_utf8_lossy(&output.stderr).trim()
157        ));
158    }
159    let report = if output.stdout.is_empty() {
160        String::from_utf8_lossy(&output.stderr)
161    } else {
162        String::from_utf8_lossy(&output.stdout)
163    };
164    Ok(report.lines().next().unwrap_or("").trim().to_owned())
165}
166
167/// Requires every listed tool, batching all gaps into one typed refusal.
168///
169/// # Errors
170///
171/// Returns [`MissingTools`] naming every absent tool with its install link.
172pub fn require(tools: &[&'static Tool], verb: &'static str) -> Result<(), MissingTools> {
173    let missing: Vec<(&'static Tool, String)> = tools
174        .iter()
175        .filter_map(|tool| probe(tool).err().map(|failure| (*tool, failure)))
176        .collect();
177    if missing.is_empty() {
178        Ok(())
179    } else {
180        Err(MissingTools { verb, missing })
181    }
182}
183
184/// Resolves the installed Chrome the browser proof drives: `CHROME_BIN`
185/// when set, the default macOS installation path otherwise.
186///
187/// # Errors
188///
189/// Returns the refusal message: the path probed, why Chrome is needed, and
190/// where to install it.
191pub fn require_chrome() -> Result<PathBuf, String> {
192    let path = std::env::var("CHROME_BIN").unwrap_or_else(|_| DEFAULT_CHROME.to_owned());
193    if Path::new(&path).is_file() {
194        return Ok(PathBuf::from(path));
195    }
196    Err(format!(
197        "no installed Chrome found at `{path}`; the real-browser proof drives an installed \
198         Chrome (it downloads no browser of its own). Install Google Chrome from \
199         {CHROME_INSTALL}, or set CHROME_BIN to an installed Chrome/Chromium executable."
200    ))
201}
202
203/// Warns loudly (without refusing) when Chrome is missing: creating,
204/// building, and running an application need no browser, but `frame test`'s
205/// default verdict includes the real-browser proof and will refuse until
206/// one is installed.
207pub fn warn_if_chrome_missing() {
208    if let Err(reason) = require_chrome() {
209        eprintln!("warning: {reason}");
210        eprintln!("warning: `frame test` includes the real-browser proof and needs it.");
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::{ALL_TOOLS, MissingTools, NPM, RUST, require_chrome};
217
218    #[test]
219    fn missing_tools_refusal_names_every_tool_with_its_install_link() {
220        let refusal = MissingTools {
221            verb: "frame new",
222            missing: vec![
223                (&RUST, "not found on PATH".to_owned()),
224                (&NPM, "not found on PATH".to_owned()),
225            ],
226        }
227        .to_string();
228        assert!(refusal.contains("frame new needs tools this machine is missing"));
229        assert!(refusal.contains("Rust toolchain"));
230        assert!(refusal.contains("https://rustup.rs"));
231        assert!(refusal.contains("npm"));
232        assert!(refusal.contains("https://nodejs.org/"));
233        assert!(refusal.contains("frame doctor"));
234    }
235
236    #[test]
237    fn every_walkthrough_tool_carries_an_install_link_and_a_purpose() {
238        for tool in ALL_TOOLS {
239            assert!(tool.install.starts_with("https://"), "{}", tool.name);
240            assert!(!tool.purpose.is_empty(), "{}", tool.name);
241        }
242    }
243
244    #[test]
245    fn chrome_refusal_carries_the_probed_path_and_the_install_link() {
246        // CHROME_BIN pointing nowhere forces the refusal deterministically
247        // without touching the process environment: the resolver reads the
248        // variable, so this test only asserts the refusal SHAPE via a
249        // guaranteed-missing default probe when Chrome is absent — when a
250        // real Chrome is installed the Ok arm is exercised instead.
251        match require_chrome() {
252            Ok(path) => assert!(path.is_file()),
253            Err(reason) => {
254                assert!(reason.contains("real-browser proof"));
255                assert!(reason.contains(super::CHROME_INSTALL));
256                assert!(reason.contains("CHROME_BIN"));
257            }
258        }
259    }
260}