use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use crate::commands::{self, PackStatusResult};
use crate::datastore::{CommandOutput, CommandRunner, CommandSpec};
use crate::handlers::HANDLER_HOMEBREW;
use crate::packs::orchestration::ExecutionContext;
use crate::paths::Pather;
use crate::provisioners::availability::ProvisionHost;
use crate::testing::TempEnvironment;
use crate::Result;
fn env_with_a_brewfile() -> TempEnvironment {
TempEnvironment::builder()
.pack("dev")
.file("Brewfile", "npm \"typescript\"\n")
.file("home.vimrc", "set nocompatible")
.done()
.build()
}
fn brew_path(env: &TempEnvironment) -> PathBuf {
env.home.join("fake-prefix/bin/brew")
}
fn install_fake_brew(env: &TempEnvironment) {
let path = brew_path(env);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
}
#[derive(Clone)]
struct Spawn {
executable: String,
arguments: Vec<String>,
environment: Vec<(String, String)>,
}
struct BrewSaying {
version: String,
spawns: Mutex<Vec<Spawn>>,
}
impl BrewSaying {
fn new(version: &str) -> Arc<Self> {
Arc::new(Self {
version: version.to_string(),
spawns: Mutex::new(Vec::new()),
})
}
fn version_probes(&self) -> Vec<Spawn> {
self.spawns
.lock()
.unwrap()
.iter()
.filter(|s| s.arguments == ["--version"])
.cloned()
.collect()
}
fn ran_the_brewfile(&self) -> bool {
self.spawns
.lock()
.unwrap()
.iter()
.any(|s| s.arguments.first().map(String::as_str) == Some("bundle"))
}
}
impl CommandRunner for BrewSaying {
fn run(&self, command: CommandSpec<'_>) -> Result<CommandOutput> {
self.spawns.lock().unwrap().push(Spawn {
executable: command.executable.to_string(),
arguments: command.arguments.to_vec(),
environment: command.environment.to_vec(),
});
let stdout = if command.arguments == ["--version"] {
format!("Homebrew {}\n", self.version)
} else {
String::new()
};
Ok(CommandOutput {
exit_code: 0,
stdout,
stderr: String::new(),
})
}
}
const BUNDLE_PARSE_ERROR: &str = "Error: Unknown Brewfile method: npm";
struct BrewTooOldForTheBundle;
impl CommandRunner for BrewTooOldForTheBundle {
fn run(&self, command: CommandSpec<'_>) -> Result<CommandOutput> {
use std::io::Write;
if command.arguments == ["--version"] {
return Ok(CommandOutput {
exit_code: 0,
stdout: "Homebrew 5.0.16\n".to_string(),
stderr: String::new(),
});
}
if command.arguments.first().map(String::as_str) == Some("bundle") {
let _ = writeln!(std::io::stderr(), "{BUNDLE_PARSE_ERROR}");
return Err(crate::DodotError::CommandFailed {
command: format!("{} bundle", command.executable),
exit_code: 1,
stderr: format!("{BUNDLE_PARSE_ERROR}\n"),
});
}
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
static STDERR_CAPTURE: Mutex<()> = Mutex::new(());
fn capturing_stderr<T: Send>(body: impl FnOnce() -> T + Send) -> (T, String) {
use std::io::Write;
use std::os::unix::io::AsRawFd;
let _serialized = STDERR_CAPTURE.lock().unwrap_or_else(|e| e.into_inner());
let file = tempfile::NamedTempFile::new().expect("a file to capture stderr into");
let saved = unsafe { libc::dup(libc::STDERR_FILENO) };
assert!(saved >= 0, "could not duplicate stderr");
assert!(
unsafe { libc::dup2(file.as_file().as_raw_fd(), libc::STDERR_FILENO) } >= 0,
"could not redirect stderr"
);
let outcome = std::thread::scope(|scope| scope.spawn(body).join());
let _ = std::io::stderr().flush();
unsafe {
libc::dup2(saved, libc::STDERR_FILENO);
libc::close(saved);
}
let printed = std::fs::read_to_string(file.path()).expect("the capture file reads back");
match outcome {
Ok(value) => (value, printed),
Err(panic) => {
eprint!("{printed}");
std::panic::resume_unwind(panic)
}
}
}
#[derive(Default)]
struct NeverAsksTheVersion;
impl CommandRunner for NeverAsksTheVersion {
fn run(&self, command: CommandSpec<'_>) -> Result<CommandOutput> {
assert_ne!(
command.arguments,
["--version"],
"a passive command asked {} for its version — the fitness probe spawns, \
and only `dodot up` may reach it",
command.executable
);
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
fn ctx_with(env: &TempEnvironment, runner: Arc<dyn CommandRunner>) -> ExecutionContext {
let mut ctx = super::support::make_ctx_with_runner(env, runner);
ctx.no_provision = false;
ctx.provision_host = Arc::new(ProvisionHost::with_candidates(
HANDLER_HOMEBREW,
vec![brew_path(env)],
));
ctx
}
fn warning_about_the_version(result: &PackStatusResult) -> Option<&String> {
result
.warnings
.iter()
.find(|w| w.contains("5.1.2") || w.contains("did not report its version"))
}
#[test]
fn a_brew_below_the_floor_is_named_with_its_remedy_and_the_file_still_runs() {
let env = env_with_a_brewfile();
install_fake_brew(&env);
let runner = BrewSaying::new("5.0.16");
let result = commands::up::up(None, &ctx_with(&env, runner.clone())).unwrap();
let warning = warning_about_the_version(&result)
.unwrap_or_else(|| panic!("no version warning in {:?}", result.warnings));
assert!(warning.contains("5.0.16"), "{warning}");
assert!(
warning.contains("brew update"),
"the remedy is what makes this a condition rather than a complaint: {warning}"
);
assert!(
!warning.contains("not installed"),
"an old brew is not a missing one: {warning}"
);
assert!(
runner.ran_the_brewfile(),
"dodot has not read the Brewfile, so it does not know the floor matters here — \
refusing the run would turn an unverified warning into a failed deployment"
);
assert!(
!result.failed,
"a below-floor manager is a warning, not a failed run"
);
}
#[test]
fn the_version_question_is_asked_before_the_brewfile_runs() {
let env = env_with_a_brewfile();
install_fake_brew(&env);
let runner = BrewSaying::new("5.0.16");
commands::up::up(None, &ctx_with(&env, runner.clone())).unwrap();
let spawns = runner.spawns.lock().unwrap();
let asked = spawns
.iter()
.position(|s| s.arguments == ["--version"])
.expect("brew was asked its version");
let ran = spawns
.iter()
.position(|s| s.arguments.first().map(String::as_str) == Some("bundle"))
.expect("the Brewfile ran");
assert!(asked < ran, "the version question comes first");
}
#[test]
fn the_warning_reaches_the_terminal_before_the_bundle_does() {
let env = env_with_a_brewfile();
install_fake_brew(&env);
let ctx = ctx_with(&env, Arc::new(BrewTooOldForTheBundle));
let (result, printed) = capturing_stderr(|| commands::up::up(None, &ctx).unwrap());
let warned = printed
.find("older than 5.1.2")
.unwrap_or_else(|| panic!("the version warning never reached stderr:\n{printed}"));
let failed = printed
.find(BUNDLE_PARSE_ERROR)
.unwrap_or_else(|| panic!("the bundle's own error never reached stderr:\n{printed}"));
assert!(
warned < failed,
"the warning must explain the parse error, not follow it:\n{printed}"
);
assert!(
printed.contains(&format!(
"dodot: {}",
warning_about_the_version(&result).expect("the warning is returned as well")
)),
"what is printed is the same warning the result carries:\n{printed}"
);
assert!(
result.failed,
"the bundle failed on its own terms; the warning does not change that"
);
}
#[test]
fn a_brew_at_or_above_the_floor_produces_no_extra_output() {
for version in ["5.1.2", "6.0.18"] {
let env = env_with_a_brewfile();
install_fake_brew(&env);
let runner = BrewSaying::new(version);
let result = commands::up::up(None, &ctx_with(&env, runner.clone())).unwrap();
assert_eq!(
warning_about_the_version(&result),
None,
"brew {version} clears the floor and is not news: {:?}",
result.warnings
);
assert!(runner.ran_the_brewfile());
}
}
#[test]
fn a_brew_that_will_not_say_is_a_probe_failure_not_an_absent_manager() {
struct BrokenVersion;
impl CommandRunner for BrokenVersion {
fn run(&self, command: CommandSpec<'_>) -> Result<CommandOutput> {
if command.arguments == ["--version"] {
return Ok(CommandOutput {
exit_code: 127,
stdout: String::new(),
stderr: "dyld: library not loaded\n".to_string(),
});
}
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
let env = env_with_a_brewfile();
install_fake_brew(&env);
let result = commands::up::up(None, &ctx_with(&env, Arc::new(BrokenVersion))).unwrap();
let warning = warning_about_the_version(&result)
.unwrap_or_else(|| panic!("no version warning in {:?}", result.warnings));
assert!(warning.contains("dyld"), "brew's own words: {warning}");
assert!(
!warning.contains("not installed") && !warning.contains("probed"),
"the presence probe already answered; this is a different question: {warning}"
);
let row = result.packs[0]
.files
.iter()
.find(|f| f.name == "Brewfile")
.expect("the Brewfile has a row");
assert_ne!(row.status_label, "homebrew not installed");
}
#[test]
fn one_spawn_per_manager_however_many_packs_declare_a_brewfile() {
let env = TempEnvironment::builder()
.pack("dev")
.file("Brewfile", "npm \"typescript\"\n")
.done()
.pack("ops")
.file("Brewfile", "brew \"ripgrep\"\n")
.done()
.build();
install_fake_brew(&env);
let runner = BrewSaying::new("5.0.16");
let result = commands::up::up(None, &ctx_with(&env, runner.clone())).unwrap();
assert_eq!(
runner.version_probes().len(),
1,
"the answer is a fact about the machine, not about the pack"
);
assert_eq!(
result
.warnings
.iter()
.filter(|w| w.contains("5.1.2"))
.count(),
1,
"and it is said once: {:?}",
result.warnings
);
}
#[test]
fn a_brewfile_that_is_not_going_to_run_costs_no_version_probe() {
let env = env_with_a_brewfile();
install_fake_brew(&env);
commands::up::up(None, &ctx_with(&env, BrewSaying::new("6.0.18"))).unwrap();
let second = BrewSaying::new("6.0.18");
commands::up::up(None, &ctx_with(&env, second.clone())).unwrap();
assert!(
!second.ran_the_brewfile(),
"precondition: the receipt from the first run holds"
);
assert!(
second.version_probes().is_empty(),
"nothing was going to be spawned, so there was nothing to ask about"
);
}
#[test]
fn provision_rerun_asks_again_because_the_file_runs_again() {
let env = env_with_a_brewfile();
install_fake_brew(&env);
commands::up::up(None, &ctx_with(&env, BrewSaying::new("5.0.16"))).unwrap();
let rerun_runner = BrewSaying::new("5.0.16");
let mut rerun = ctx_with(&env, rerun_runner.clone());
rerun.provision_rerun = true;
let result = commands::up::up(None, &rerun).unwrap();
assert!(
rerun_runner.ran_the_brewfile(),
"precondition: --provision-rerun runs the file despite the receipt"
);
assert_eq!(
rerun_runner.version_probes().len(),
1,
"a file that is going to run is a file worth warning about"
);
assert!(warning_about_the_version(&result).is_some());
}
#[test]
fn the_version_probe_carries_the_same_environment_the_run_will() {
let env = env_with_a_brewfile();
install_fake_brew(&env);
let runner = BrewSaying::new("5.0.16");
commands::up::up(None, &ctx_with(&env, runner.clone())).unwrap();
let probes = runner.version_probes();
assert_eq!(probes.len(), 1);
assert_eq!(PathBuf::from(&probes[0].executable), brew_path(&env));
assert_eq!(
probes[0].environment.as_slice(),
[("HOMEBREW_NO_AUTO_UPDATE".to_string(), "1".to_string())]
);
}
#[test]
fn status_and_dry_run_never_ask_a_manager_for_its_version() {
let env = env_with_a_brewfile();
install_fake_brew(&env);
let ctx = ctx_with(&env, Arc::new(NeverAsksTheVersion));
commands::status::status(None, &ctx).unwrap();
let mut dry = ctx_with(&env, Arc::new(NeverAsksTheVersion));
dry.dry_run = true;
commands::up::up(None, &dry).unwrap();
commands::up::up(None, &ctx_with(&env, BrewSaying::new("6.0.18"))).unwrap();
commands::status::status(None, &ctx_with(&env, Arc::new(NeverAsksTheVersion))).unwrap();
}
#[test]
fn the_fitness_verdict_leaves_nothing_behind() {
fn entries(env: &TempEnvironment) -> Vec<String> {
fn walk(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec<String>) {
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
for entry in read {
let path = entry.unwrap().path();
out.push(
path.strip_prefix(root)
.unwrap()
.to_string_lossy()
.into_owned(),
);
if path.is_dir() && !path.is_symlink() {
walk(&path, root, out);
}
}
}
let mut out = Vec::new();
walk(env.paths.data_dir(), env.paths.data_dir(), &mut out);
out.sort();
out
}
let below = env_with_a_brewfile();
install_fake_brew(&below);
commands::up::up(None, &ctx_with(&below, BrewSaying::new("5.0.16"))).unwrap();
let above = env_with_a_brewfile();
install_fake_brew(&above);
commands::up::up(None, &ctx_with(&above, BrewSaying::new("6.0.18"))).unwrap();
assert_eq!(
entries(&below),
entries(&above),
"the fitness answer is a fact about this machine right now — \
nothing about it belongs in the datastore"
);
assert!(
!entries(&below).is_empty(),
"precondition: the Brewfile ran and left its receipt"
);
}