1use std::fmt;
12use std::path::{Path, PathBuf};
13use std::process::Command;
14
15#[derive(Debug)]
18pub struct Tool {
19 pub name: &'static str,
21 pub command: &'static str,
23 pub version_args: &'static [&'static str],
25 pub install: &'static str,
27 pub purpose: &'static str,
29}
30
31pub 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
40pub 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
49pub 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
62pub 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
71pub 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
80pub const CHROME_INSTALL: &str = "https://www.google.com/chrome/";
82
83const DEFAULT_CHROME: &str = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
85
86pub static NEW_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];
89
90pub static BUILD_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG];
94
95pub static CHECK_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];
97
98pub static ALL_TOOLS: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];
100
101#[derive(Debug)]
104pub struct MissingTools {
105 pub verb: &'static str,
107 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
135pub 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
167pub 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
184pub 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
203pub 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 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}