frame-cli 0.3.0

CLI for Frame — five intention-verbs over one application: frame new scaffolds it, frame run serves it, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! The prerequisite walkthrough, mechanized — and the knowledge every other
//! verb inlines into its own failures.
//!
//! One table describes every tool a Frame application depends on: what it
//! is, which command proves it, and where to install it. `frame doctor`
//! walks the whole table; every other verb asks [`require`] for exactly the
//! subset it is about to use, so a missing tool surfaces at the earliest,
//! most fixable moment as a typed refusal carrying its install link — a raw
//! "command not found" never reaches the user.

use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;

/// One prerequisite tool: its user-facing name, the command that proves it,
/// and the install link a refusal carries.
#[derive(Debug)]
pub struct Tool {
    /// User-facing name, e.g. "Rust toolchain".
    pub name: &'static str,
    /// The command probed on PATH.
    pub command: &'static str,
    /// Arguments that make the command report its version and exit.
    pub version_args: &'static [&'static str],
    /// Where to install it.
    pub install: &'static str,
    /// What Frame needs it for.
    pub purpose: &'static str,
}

/// The Rust toolchain: builds and tests the application's host.
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",
};

/// Gleam: compiles and tests the application's component.
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",
};

/// Erlang/OTP: the VM toolchain Gleam compiles through.
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)",
};

/// Node.js: typechecks the page and drives the browser proof.
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",
};

/// npm: installs the page's type and proof toolchain.
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)",
};

/// Install link a missing-Chrome refusal carries.
pub const CHROME_INSTALL: &str = "https://www.google.com/chrome/";

/// Default macOS Chrome location, overridable with `CHROME_BIN`.
const DEFAULT_CHROME: &str = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";

/// Tools `frame new` refuses without: everything the generated application
/// needs to build, verify, and run.
pub static NEW_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];

/// Tools `frame run` and `frame build` refuse without: the host build chain
/// only — a fresh scaffold's page is already compiled, so Node enters only
/// when page sources change.
pub static BUILD_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG];

/// Tools `frame check` refuses without.
pub static CHECK_PREREQUISITES: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];

/// The whole walkthrough, in the order the doctor reports it.
pub static ALL_TOOLS: &[&Tool] = &[&RUST, &GLEAM, &ERLANG, &NODE, &NPM];

/// Every tool a verb needs but the machine lacks, with install links —
/// batched so one refusal names every gap instead of dribbling them out.
#[derive(Debug)]
pub struct MissingTools {
    /// The verb that refused.
    pub verb: &'static str,
    /// Each missing tool with the probe failure that condemned it.
    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 {}

/// Probes one tool and returns the first line of its version report.
///
/// # Errors
///
/// Returns a human-readable probe failure: the command is absent from PATH,
/// could not be spawned, or exited unsuccessfully.
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())
}

/// Requires every listed tool, batching all gaps into one typed refusal.
///
/// # Errors
///
/// Returns [`MissingTools`] naming every absent tool with its install link.
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 })
    }
}

/// Resolves the installed Chrome the browser proof drives: `CHROME_BIN`
/// when set, the default macOS installation path otherwise.
///
/// # Errors
///
/// Returns the refusal message: the path probed, why Chrome is needed, and
/// where to install it.
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."
    ))
}

/// Warns loudly (without refusing) when Chrome is missing: creating,
/// building, and running an application need no browser, but `frame test`'s
/// default verdict includes the real-browser proof and will refuse until
/// one is installed.
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() {
        // CHROME_BIN pointing nowhere forces the refusal deterministically
        // without touching the process environment: the resolver reads the
        // variable, so this test only asserts the refusal SHAPE via a
        // guaranteed-missing default probe when Chrome is absent — when a
        // real Chrome is installed the Ok arm is exercised instead.
        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"));
            }
        }
    }
}