use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug)]
pub struct Tool {
pub name: &'static str,
pub command: &'static str,
pub version_args: &'static [&'static str],
pub install: &'static str,
pub purpose: &'static str,
}
pub static RUST: Tool = Tool {
name: "Rust toolchain",
command: "cargo",
version_args: &["--version"],
install: "https://rustup.rs",
purpose: "builds and tests the application's host",
};
pub static GLEAM: Tool = Tool {
name: "Gleam",
command: "gleam",
version_args: &["--version"],
install: "https://gleam.run/getting-started/installing/",
purpose: "compiles and tests the application's component",
};
pub static ERLANG: Tool = Tool {
name: "Erlang/OTP",
command: "erl",
version_args: &[
"-noshell",
"-eval",
"io:put_chars(erlang:system_info(otp_release)), halt().",
],
install: "https://gleam.run/getting-started/installing/",
purpose: "the VM toolchain Gleam compiles through (installed alongside Gleam)",
};
pub static NODE: Tool = Tool {
name: "Node.js",
command: "node",
version_args: &["--version"],
install: "https://nodejs.org/",
purpose: "typechecks the page and drives the real-browser proof",
};
pub static NPM: Tool = Tool {
name: "npm",
command: "npm",
version_args: &["--version"],
install: "https://nodejs.org/",
purpose: "installs the page's type and proof toolchain (ships with Node.js)",
};
pub const CHROME_INSTALL: &str = "https://www.google.com/chrome/";
const DEFAULT_CHROME: &str = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
pub static NEW_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];
pub static BUILD_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG];
pub static CHECK_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];
pub static ALL_TOOLS: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];
#[derive(Debug)]
pub struct MissingTools {
pub verb: &'static str,
pub missing: Vec<(&'static Tool, String)>,
}
impl fmt::Display for MissingTools {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(
formatter,
"{} needs tools this machine is missing:",
self.verb
)?;
for (tool, failure) in &self.missing {
writeln!(
formatter,
" - {} (`{}`): {failure}\n it {}; install it from {}",
tool.name, tool.command, tool.purpose, tool.install
)?;
}
write!(
formatter,
"install the tools above, then re-run {} (or run `frame doctor` for the full walkthrough)",
self.verb
)
}
}
impl std::error::Error for MissingTools {}
pub fn probe(tool: &Tool) -> Result<String, String> {
let output = Command::new(tool.command)
.args(tool.version_args)
.output()
.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
"not found on PATH".to_owned()
} else {
format!("could not be started: {error}")
}
})?;
if !output.status.success() {
return Err(format!(
"version probe exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
}
let report = if output.stdout.is_empty() {
String::from_utf8_lossy(&output.stderr)
} else {
String::from_utf8_lossy(&output.stdout)
};
Ok(report.lines().next().unwrap_or("").trim().to_owned())
}
pub fn require(tools: &[&'static Tool], verb: &'static str) -> Result<(), MissingTools> {
let missing: Vec<(&'static Tool, String)> = tools
.iter()
.filter_map(|tool| probe(tool).err().map(|failure| (*tool, failure)))
.collect();
if missing.is_empty() {
Ok(())
} else {
Err(MissingTools { verb, missing })
}
}
pub fn require_chrome() -> Result<PathBuf, String> {
let path = std::env::var("CHROME_BIN").unwrap_or_else(|_| DEFAULT_CHROME.to_owned());
if Path::new(&path).is_file() {
return Ok(PathBuf::from(path));
}
Err(format!(
"no installed Chrome found at `{path}`; the real-browser proof drives an installed \
Chrome (it downloads no browser of its own). Install Google Chrome from \
{CHROME_INSTALL}, or set CHROME_BIN to an installed Chrome/Chromium executable."
))
}
pub fn warn_if_chrome_missing() {
if let Err(reason) = require_chrome() {
eprintln!("warning: {reason}");
eprintln!("warning: `frame test` includes the real-browser proof and needs it.");
}
}
#[cfg(test)]
mod tests {
use super::{ALL_TOOLS, MissingTools, NPM, RUST, require_chrome};
#[test]
fn missing_tools_refusal_names_every_tool_with_its_install_link() {
let refusal = MissingTools {
verb: "frame new",
missing: vec![
(&RUST, "not found on PATH".to_owned()),
(&NPM, "not found on PATH".to_owned()),
],
}
.to_string();
assert!(refusal.contains("frame new needs tools this machine is missing"));
assert!(refusal.contains("Rust toolchain"));
assert!(refusal.contains("https://rustup.rs"));
assert!(refusal.contains("npm"));
assert!(refusal.contains("https://nodejs.org/"));
assert!(refusal.contains("frame doctor"));
}
#[test]
fn every_walkthrough_tool_carries_an_install_link_and_a_purpose() {
for tool in ALL_TOOLS {
assert!(tool.install.starts_with("https://"), "{}", tool.name);
assert!(!tool.purpose.is_empty(), "{}", tool.name);
}
}
#[test]
fn chrome_refusal_carries_the_probed_path_and_the_install_link() {
match require_chrome() {
Ok(path) => assert!(path.is_file()),
Err(reason) => {
assert!(reason.contains("real-browser proof"));
assert!(reason.contains(super::CHROME_INSTALL));
assert!(reason.contains("CHROME_BIN"));
}
}
}
}