brew: inject a command runner so the shell calls are testable
Brew and BrewBundle took Utils::sh directly, so their branches depended
on whether Homebrew was installed and their install paths could not run
under test at all. The runner is a generic defaulting to SystemRunner,
so Lua, the registry, and every Fennel script are unchanged.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/frork-cli/src/assertions.rs b/frork-cli/src/assertions.rs
index b432877..06ee7b5 100644
--- a/frork-cli/src/assertions.rs
+++ b/frork-cli/src/assertions.rs
@@ -285,28 +285,77 @@ impl AssertionType for Git {
}
}
-pub struct Brew;
+/// A seam over shelling out, so assertion types can be exercised without
+/// running the real command. Production code always uses [`SystemRunner`];
+/// only tests substitute anything else.
+pub trait CommandRunner {
+ fn has_bin(&self, bin: &str) -> bool;
+ fn run(&self, cmd: &str, args: &[&str]) -> Result<(String, i32)>;
+ fn run_with_envs(
+ &self,
+ cmd: &str,
+ args: &[&str],
+ envs: &[(&str, &str)],
+ ) -> Result<(String, i32)>;
+
+ fn require_bin(&self, bin: &str) -> Result<()> {
+ if self.has_bin(bin) {
+ Ok(())
+ } else {
+ Err(miette!("Required binary '{bin}' not found in PATH"))
+ }
+ }
+}
+
+pub struct SystemRunner;
+
+impl CommandRunner for SystemRunner {
+ fn has_bin(&self, bin: &str) -> bool {
+ Utils::assert_bin(bin).is_ok()
+ }
-impl FromLuaMulti for Brew {
+ fn run(&self, cmd: &str, args: &[&str]) -> Result<(String, i32)> {
+ Utils::sh(cmd, args)
+ }
+
+ fn run_with_envs(
+ &self,
+ cmd: &str,
+ args: &[&str],
+ envs: &[(&str, &str)],
+ ) -> Result<(String, i32)> {
+ Utils::sh_with_envs(cmd, args, envs)
+ }
+}
+
+pub struct Brew<R = SystemRunner> {
+ runner: R,
+}
+
+// Only the production configuration is constructible from Lua; tests build
+// Brew directly with a fake runner.
+impl FromLuaMulti for Brew<SystemRunner> {
fn from_lua_multi(_args: LuaMultiValue, _lua: &Lua) -> LuaResult<Self> {
- Ok(Self)
+ Ok(Self {
+ runner: SystemRunner,
+ })
}
}
-impl std::fmt::Display for Brew {
+impl<R> std::fmt::Display for Brew<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "brew")
}
}
-impl AssertionType for Brew {
+impl<R: CommandRunner> AssertionType for Brew<R> {
fn status(&self) -> Result<Status> {
// Early return if brew command is not in PATH.
- if Utils::assert_bin("brew").is_err() {
+ if !self.runner.has_bin("brew") {
return Ok(Status::Missing);
}
- let (_output, exit_code) = Utils::sh("brew", &["--version"])?;
+ let (_output, exit_code) = self.runner.run("brew", &["--version"])?;
if exit_code == 0 {
Ok(Status::Ok)
} else {
@@ -316,13 +365,11 @@ impl AssertionType for Brew {
// This needs sudo — figure out how to make this work through frork.
fn install(&self) -> Result<()> {
- let (output, exit_code) = Utils::sh(
- "bash",
- &[
- "-c",
- r#"/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)""#,
- ],
- )?;
+ let install_args = [
+ "-c",
+ r#"/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)""#,
+ ];
+ let (output, exit_code) = self.runner.run("bash", &install_args)?;
if exit_code != 0 {
return Err(miette!("Failed to install Homebrew: {}", output));
@@ -333,24 +380,30 @@ impl AssertionType for Brew {
}
}
-pub struct BrewBundle {
+pub struct BrewBundle<R = SystemRunner> {
pub brewfile: ExpandedPath,
+ // Only read on macOS; every other platform bails before shelling out.
+ #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
+ runner: R,
}
-impl FromLuaMulti for BrewBundle {
+impl FromLuaMulti for BrewBundle<SystemRunner> {
fn from_lua_multi(args: LuaMultiValue, lua: &Lua) -> LuaResult<Self> {
let brewfile = ExpandedPath::from_lua_multi(args, lua)?;
- Ok(Self { brewfile })
+ Ok(Self {
+ brewfile,
+ runner: SystemRunner,
+ })
}
}
-impl std::fmt::Display for BrewBundle {
+impl<R> std::fmt::Display for BrewBundle<R> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "brew-bundle {}", self.brewfile)
}
}
-impl AssertionType for BrewBundle {
+impl<R: CommandRunner> AssertionType for BrewBundle<R> {
#[cfg(not(target_os = "macos"))]
fn status(&self) -> Result<Status> {
Err(miette!("brew-bundle only supported on Darwin/macOS"))
@@ -358,27 +411,26 @@ impl AssertionType for BrewBundle {
#[cfg(target_os = "macos")]
fn status(&self) -> Result<Status> {
- Utils::assert_bin("brew")?;
+ self.runner.require_bin("brew")?;
- // First check: brew bundle check --no-upgrade.
let file_arg = format!("--file={}", self.brewfile);
- let (_output, exit_code) = Utils::sh_with_envs(
- "brew",
- &["bundle", "check", "--no-upgrade", &file_arg],
- &[("HOMEBREW_NO_AUTO_UPDATE", "true")],
- )?;
+ let no_auto_update = [("HOMEBREW_NO_AUTO_UPDATE", "true")];
+
+ // First check: brew bundle check --no-upgrade.
+ let strict_args = ["bundle", "check", "--no-upgrade", file_arg.as_str()];
+ let (_output, exit_code) =
+ self.runner
+ .run_with_envs("brew", &strict_args, &no_auto_update)?;
if exit_code != 0 {
return Ok(Status::Missing);
}
// Second check: brew bundle check (without --no-upgrade).
- let file_arg = format!("--file={}", self.brewfile);
- let (_output, exit_code) = Utils::sh_with_envs(
- "brew",
- &["bundle", "check", &file_arg],
- &[("HOMEBREW_NO_AUTO_UPDATE", "true")],
- )?;
+ let check_args = ["bundle", "check", file_arg.as_str()];
+ let (_output, exit_code) =
+ self.runner
+ .run_with_envs("brew", &check_args, &no_auto_update)?;
if exit_code != 0 {
return Ok(Status::ConflictUpgrade(Conflict {
@@ -397,10 +449,12 @@ impl AssertionType for BrewBundle {
#[cfg(target_os = "macos")]
fn install(&self) -> Result<()> {
- Utils::assert_bin("brew")?;
+ self.runner.require_bin("brew")?;
let file_arg = format!("--file={}", self.brewfile);
- let (_output, exit_code) = Utils::sh("brew", &["bundle", "install", &file_arg])?;
+ let (_output, exit_code) = self
+ .runner
+ .run("brew", &["bundle", "install", file_arg.as_str()])?;
if exit_code != 0 {
return Err(miette!("Failed to install brew bundle"));
@@ -549,6 +603,8 @@ impl AssertionType for LuaAssertion {
#[cfg(test)]
mod tests {
use super::*;
+ use std::cell::RefCell;
+ use std::collections::VecDeque;
use tempfile::TempDir;
fn multi(lua: &Lua, script: &str) -> LuaMultiValue {
@@ -858,6 +914,86 @@ mod tests {
// --- Brew ---
+ /// Records every command it is asked to run and replays canned exit codes.
+ #[derive(Default)]
+ struct FakeRunner {
+ missing_bins: Vec<&'static str>,
+ exit_codes: RefCell<VecDeque<i32>>,
+ calls: RefCell<Vec<String>>,
+ }
+
+ impl FakeRunner {
+ fn new(exit_codes: &[i32]) -> Self {
+ Self {
+ exit_codes: RefCell::new(exit_codes.iter().copied().collect()),
+ ..Default::default()
+ }
+ }
+
+ fn without_bin(bin: &'static str) -> Self {
+ Self {
+ missing_bins: vec![bin],
+ ..Default::default()
+ }
+ }
+
+ fn calls(&self) -> Vec<String> {
+ self.calls.borrow().clone()
+ }
+ }
+
+ impl CommandRunner for FakeRunner {
+ fn has_bin(&self, bin: &str) -> bool {
+ !self.missing_bins.contains(&bin)
+ }
+
+ fn run(&self, cmd: &str, args: &[&str]) -> Result<(String, i32)> {
+ self.run_with_envs(cmd, args, &[])
+ }
+
+ fn run_with_envs(
+ &self,
+ cmd: &str,
+ args: &[&str],
+ _envs: &[(&str, &str)],
+ ) -> Result<(String, i32)> {
+ self.calls
+ .borrow_mut()
+ .push(format!("{cmd} {}", args.join(" ")));
+ let exit_code = self.exit_codes.borrow_mut().pop_front().unwrap_or(0);
+ Ok((format!("output of {cmd}"), exit_code))
+ }
+ }
+
+ #[test]
+ fn test_system_runner_shells_out_for_real() {
+ let runner = SystemRunner;
+
+ assert!(runner.has_bin("sh"));
+ assert!(!runner.has_bin("frork-does-not-exist"));
+
+ let (stdout, exit_code) = runner.run("echo", &["hello"]).unwrap();
+ assert_eq!(stdout.trim(), "hello");
+ assert_eq!(exit_code, 0);
+
+ let (stdout, exit_code) = runner
+ .run_with_envs("sh", &["-c", "echo $RUNNER_VAR"], &[("RUNNER_VAR", "set")])
+ .unwrap();
+ assert_eq!(stdout.trim(), "set");
+ assert_eq!(exit_code, 0);
+ }
+
+ #[test]
+ fn test_command_runner_require_bin() {
+ let runner = FakeRunner::without_bin("brew");
+
+ assert!(runner.require_bin("git").is_ok());
+ let error = runner.require_bin("brew").unwrap_err();
+ assert!(error.to_string().contains("not found in PATH"));
+ }
+
+ // --- Brew ---
+
#[test]
fn test_brew_display_and_from_lua() {
let lua = Lua::new();
@@ -866,6 +1002,55 @@ mod tests {
assert_eq!(brew.to_string(), "brew");
}
+ #[test]
+ fn test_brew_status_without_brew_installed() {
+ let brew = Brew {
+ runner: FakeRunner::without_bin("brew"),
+ };
+
+ assert!(matches!(brew.status().unwrap(), Status::Missing));
+ assert!(brew.runner.calls().is_empty());
+ }
+
+ #[test]
+ fn test_brew_status_reflects_version_exit_code() {
+ let brew = Brew {
+ runner: FakeRunner::new(&[0]),
+ };
+ assert!(matches!(brew.status().unwrap(), Status::Ok));
+ assert_eq!(brew.runner.calls(), ["brew --version"]);
+
+ // Present on PATH but not runnable.
+ let brew = Brew {
+ runner: FakeRunner::new(&[1]),
+ };
+ assert!(matches!(brew.status().unwrap(), Status::Missing));
+ }
+
+ #[test]
+ fn test_brew_install() {
+ let brew = Brew {
+ runner: FakeRunner::new(&[0]),
+ };
+ brew.install().unwrap();
+ assert!(brew.runner.calls()[0].contains("install.sh"));
+
+ let brew = Brew {
+ runner: FakeRunner::new(&[1]),
+ };
+ let error = brew.install().unwrap_err();
+ assert!(error.to_string().contains("Failed to install Homebrew"));
+ }
+
+ // --- BrewBundle ---
+
+ fn brew_bundle(runner: FakeRunner) -> BrewBundle<FakeRunner> {
+ BrewBundle {
+ brewfile: ExpandedPath::try_from("/tmp/Brewfile").unwrap(),
+ runner,
+ }
+ }
+
#[test]
fn test_brew_bundle_display_and_from_lua() {
let lua = Lua::new();
@@ -876,6 +1061,65 @@ mod tests {
assert_eq!(bundle.brewfile.as_str(), "/tmp/Brewfile");
}
+ #[cfg(not(target_os = "macos"))]
+ #[test]
+ fn test_brew_bundle_is_macos_only() {
+ let bundle = brew_bundle(FakeRunner::default());
+
+ assert!(bundle.status().is_err());
+ assert!(bundle.install().is_err());
+ }
+
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn test_brew_bundle_status_requires_brew() {
+ let bundle = brew_bundle(FakeRunner::without_bin("brew"));
+
+ assert!(bundle.status().unwrap_err().to_string().contains("brew"));
+ assert!(bundle.install().unwrap_err().to_string().contains("brew"));
+ }
+
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn test_brew_bundle_status_branches() {
+ // First check fails: packages are missing outright.
+ let bundle = brew_bundle(FakeRunner::new(&[1]));
+ assert!(matches!(bundle.status().unwrap(), Status::Missing));
+
+ // First check passes, second fails: installed but out of date.
+ let bundle = brew_bundle(FakeRunner::new(&[0, 1]));
+ let Status::ConflictUpgrade(conflict) = bundle.status().unwrap() else {
+ panic!("expected a conflict when packages need upgrading"); // cov-excl-line
+ };
+ assert_eq!(conflict.actual, "packages need upgrade");
+
+ // Both checks pass.
+ let bundle = brew_bundle(FakeRunner::new(&[0, 0]));
+ assert!(matches!(bundle.status().unwrap(), Status::Ok));
+ assert_eq!(
+ bundle.runner.calls(),
+ [
+ "brew bundle check --no-upgrade --file=/tmp/Brewfile",
+ "brew bundle check --file=/tmp/Brewfile",
+ ]
+ );
+ }
+
+ #[cfg(target_os = "macos")]
+ #[test]
+ fn test_brew_bundle_install() {
+ let bundle = brew_bundle(FakeRunner::new(&[0]));
+ bundle.install().unwrap();
+ assert_eq!(
+ bundle.runner.calls(),
+ ["brew bundle install --file=/tmp/Brewfile"]
+ );
+
+ let bundle = brew_bundle(FakeRunner::new(&[1]));
+ let error = bundle.install().unwrap_err();
+ assert!(error.to_string().contains("Failed to install brew bundle"));
+ }
+
// --- Debug ---
#[test]