use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::debug;
use crate::config::DodotConfig;
use crate::datastore::CommandRunner;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::{DodotError, Result};
pub const DEFAULT_PREFIXES: [&str; 2] = ["/opt/homebrew", "/usr/local"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BrewBootstrapMode {
#[default]
Auto,
Off,
}
impl BrewBootstrapMode {
pub fn parse(value: &str) -> Result<Self> {
match value {
"auto" => Ok(Self::Auto),
"off" => Ok(Self::Off),
other => Err(DodotError::Config(format!(
"unknown `[shell] homebrew` value `{other}`: must be one of auto, off"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct BrewHost {
pub is_macos: bool,
pub prefix_candidates: Vec<PathBuf>,
}
impl BrewHost {
pub fn detect() -> Self {
Self::new(
cfg!(target_os = "macos"),
std::env::var("HOMEBREW_PREFIX").ok(),
)
}
pub fn new(is_macos: bool, prefix_env: Option<String>) -> Self {
let mut prefix_candidates: Vec<PathBuf> = Vec::with_capacity(3);
if let Some(prefix) = prefix_env.filter(|p| !p.trim().is_empty()) {
prefix_candidates.push(PathBuf::from(prefix));
}
prefix_candidates.extend(DEFAULT_PREFIXES.iter().map(PathBuf::from));
Self {
is_macos,
prefix_candidates,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BrewBlocks {
pub prefix: PathBuf,
pub sh: String,
pub zsh: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BrewCapture {
Absent,
Captured(BrewBlocks),
Failed(CaptureFailure),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CaptureFailure {
pub prefix: PathBuf,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedCapture {
pub blocks: Option<BrewBlocks>,
pub warning: Option<String>,
}
pub fn capture_and_persist(
fs: &dyn Fs,
runner: &dyn CommandRunner,
config: &DodotConfig,
paths: &dyn Pather,
) -> Result<PersistedCapture> {
let mode = BrewBootstrapMode::parse(&config.shell.homebrew)?;
capture_and_persist_at(
fs,
runner,
mode,
&BrewHost::detect(),
&paths.homebrew_cache_path(),
)
}
fn capture_and_persist_at(
fs: &dyn Fs,
runner: &dyn CommandRunner,
mode: BrewBootstrapMode,
host: &BrewHost,
cache_path: &Path,
) -> Result<PersistedCapture> {
match capture(fs, runner, mode, host) {
BrewCapture::Captured(blocks) => {
persist_cache(fs, cache_path, Some(&blocks))?;
Ok(PersistedCapture {
blocks: Some(blocks),
warning: None,
})
}
BrewCapture::Absent => {
persist_cache(fs, cache_path, None)?;
Ok(PersistedCapture {
blocks: None,
warning: None,
})
}
BrewCapture::Failed(failure) => {
let kept = load_cache(fs, cache_path).filter(|c| c.prefix == failure.prefix);
let fate = if kept.is_some() {
"Keeping the Homebrew block from the last successful capture."
} else {
"The init script carries no Homebrew block until brew answers on a later `dodot up`."
};
let warning = format!(
"Homebrew at {} did not answer for its environment: {}. {}",
failure.prefix.display(),
failure.reason,
fate
);
Ok(PersistedCapture {
blocks: kept,
warning: Some(warning),
})
}
}
}
pub fn cached_or_capture(
fs: &dyn Fs,
runner: &dyn CommandRunner,
config: &DodotConfig,
paths: &dyn Pather,
) -> Result<Option<BrewBlocks>> {
let mode = BrewBootstrapMode::parse(&config.shell.homebrew)?;
Ok(cached_or_capture_at(
fs,
runner,
mode,
&BrewHost::detect(),
&paths.homebrew_cache_path(),
))
}
fn cached_or_capture_at(
fs: &dyn Fs,
runner: &dyn CommandRunner,
mode: BrewBootstrapMode,
host: &BrewHost,
cache_path: &Path,
) -> Option<BrewBlocks> {
if mode == BrewBootstrapMode::Off || !host.is_macos {
return None;
}
let prefix = detect_prefix(fs, host)?;
if let Some(cached) = load_cache(fs, cache_path) {
if cached.prefix == prefix {
return Some(cached);
}
debug!(
cached = %cached.prefix.display(),
detected = %prefix.display(),
"homebrew cache is for another prefix; capturing live"
);
}
match capture_at(runner, &prefix) {
Ok(blocks) => Some(blocks),
Err(failure) => {
debug!(
prefix = %failure.prefix.display(),
reason = %failure.reason,
"brew failed to answer on the passive path; emitting no block"
);
None
}
}
}
fn load_cache(fs: &dyn Fs, path: &Path) -> Option<BrewBlocks> {
let text = fs.read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
fn persist_cache(fs: &dyn Fs, path: &Path, blocks: Option<&BrewBlocks>) -> Result<()> {
match blocks {
Some(blocks) => {
if let Some(parent) = path.parent() {
fs.mkdir_all(parent)?;
}
let json = serde_json::to_string_pretty(blocks)
.map_err(|e| DodotError::Other(format!("serialize homebrew cache: {e}")))?;
fs.write_atomic(path, json.as_bytes())
}
None => {
if fs.exists(path) {
fs.remove_file(path)?;
}
Ok(())
}
}
}
pub fn capture(
fs: &dyn Fs,
runner: &dyn CommandRunner,
mode: BrewBootstrapMode,
host: &BrewHost,
) -> BrewCapture {
if mode == BrewBootstrapMode::Off {
return BrewCapture::Absent;
}
if !host.is_macos {
return BrewCapture::Absent;
}
let Some(prefix) = detect_prefix(fs, host) else {
return BrewCapture::Absent;
};
match capture_at(runner, &prefix) {
Ok(blocks) => BrewCapture::Captured(blocks),
Err(failure) => BrewCapture::Failed(failure),
}
}
fn capture_at(
runner: &dyn CommandRunner,
prefix: &Path,
) -> std::result::Result<BrewBlocks, CaptureFailure> {
let brew = brew_binary(prefix);
let fail = |reason| CaptureFailure {
prefix: prefix.to_path_buf(),
reason,
};
let sh = shellenv(runner, &brew, "sh").map_err(fail)?;
let zsh = shellenv(runner, &brew, "zsh").map_err(fail)?;
Ok(BrewBlocks {
prefix: prefix.to_path_buf(),
sh,
zsh,
})
}
fn detect_prefix(fs: &dyn Fs, host: &BrewHost) -> Option<PathBuf> {
host.prefix_candidates
.iter()
.find(|prefix| is_executable_file(fs, &brew_binary(prefix)))
.cloned()
}
fn is_executable_file(fs: &dyn Fs, path: &Path) -> bool {
matches!(fs.stat(path), Ok(meta) if meta.is_file && meta.mode & 0o111 != 0)
}
fn brew_binary(prefix: &Path) -> PathBuf {
prefix.join("bin").join("brew")
}
fn shellenv(
runner: &dyn CommandRunner,
brew: &Path,
shell: &str,
) -> std::result::Result<String, String> {
let executable = brew.to_string_lossy().to_string();
let args = vec!["shellenv".to_string(), shell.to_string()];
match runner.run(&executable, &args) {
Ok(out) if out.exit_code == 0 && !out.stdout.trim().is_empty() => Ok(out.stdout),
Ok(out) => {
debug!(
brew = %executable,
shell,
exit_code = out.exit_code,
"brew shellenv produced nothing usable"
);
let stderr_line = out.stderr.trim().lines().next();
let detail = match (out.exit_code, stderr_line) {
(0, Some(line)) => format!("exited 0 but printed nothing ({line})"),
(0, None) => "exited 0 but printed nothing".to_string(),
(code, Some(line)) => format!("exited with status {code} ({line})"),
(code, None) => format!("exited with status {code}"),
};
Err(format!("`brew shellenv {shell}` {detail}"))
}
Err(e) => {
debug!(
brew = %executable,
shell,
error = %e,
"brew shellenv failed to run"
);
Err(format!("`brew shellenv {shell}` failed to run: {e}"))
}
}
}
pub(super) fn emit_homebrew_block(script: &mut String, blocks: &BrewBlocks) {
writeln!(script, "# ── Homebrew environment ──").unwrap();
writeln!(
script,
"# Captured from `brew shellenv` at {} on the last `dodot up`.",
blocks.prefix.display()
)
.unwrap();
writeln!(
script,
"# Emitted verbatim and first, so dodot's own PATH additions below win."
)
.unwrap();
writeln!(script, "if [ -n \"${{ZSH_VERSION:-}}\" ]; then").unwrap();
write_block(script, &blocks.zsh);
writeln!(script, "else").unwrap();
write_block(script, &blocks.sh);
writeln!(script, "fi").unwrap();
writeln!(script).unwrap();
}
fn write_block(script: &mut String, block: &str) {
script.push_str(block);
if !block.ends_with('\n') {
script.push('\n');
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datastore::CommandOutput;
use crate::testing::TempEnvironment;
use crate::Result;
use std::sync::Mutex;
const SH_BLOCK: &str = "export HOMEBREW_PREFIX=\"/opt/homebrew\";\n\
eval \"$(/usr/bin/env PATH_HELPER_ROOT=\"/opt/homebrew\" /usr/libexec/path_helper -s)\"\n";
const ZSH_BLOCK: &str = "export HOMEBREW_PREFIX=\"/opt/homebrew\";\n\
fpath[1,0]=\"/opt/homebrew/share/zsh/site-functions\";\n\
export FPATH;\n\
eval \"$(/usr/bin/env PATH_HELPER_ROOT=\"/opt/homebrew\" /usr/libexec/path_helper -s)\"\n";
struct FakeBrew {
calls: Mutex<Vec<String>>,
exit_code: i32,
stdout: Option<String>,
stderr: String,
fail_shell: Option<&'static str>,
}
impl FakeBrew {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
exit_code: 0,
stdout: None,
stderr: String::new(),
fail_shell: None,
}
}
fn failing(exit_code: i32) -> Self {
Self {
exit_code,
stdout: Some(String::new()),
..Self::new()
}
}
fn failing_with_stderr(exit_code: i32, stderr: &str) -> Self {
Self {
stderr: stderr.to_string(),
..Self::failing(exit_code)
}
}
fn failing_dialect(shell: &'static str) -> Self {
Self {
fail_shell: Some(shell),
..Self::new()
}
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
}
impl CommandRunner for FakeBrew {
fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
self.calls
.lock()
.unwrap()
.push(format!("{executable} {}", arguments.join(" ")));
let shell = arguments.get(1).map(String::as_str);
if let Some(fail) = self.fail_shell {
if shell == Some(fail) {
return Ok(CommandOutput {
exit_code: 1,
stdout: String::new(),
stderr: self.stderr.clone(),
});
}
} else if self.stdout.is_some() || self.exit_code != 0 {
return Ok(CommandOutput {
exit_code: self.exit_code,
stdout: self.stdout.clone().unwrap_or_default(),
stderr: self.stderr.clone(),
});
}
Ok(CommandOutput {
exit_code: 0,
stdout: match shell {
Some("zsh") => ZSH_BLOCK,
_ => SH_BLOCK,
}
.to_string(),
stderr: String::new(),
})
}
}
struct UnspawnableBrew;
impl CommandRunner for UnspawnableBrew {
fn run(&self, _executable: &str, _arguments: &[String]) -> Result<CommandOutput> {
Err(DodotError::Other("text file busy".to_string()))
}
}
fn install_brew(env: &TempEnvironment, prefix: &Path) {
install_brew_with_mode(env, prefix, 0o755);
}
fn install_brew_with_mode(env: &TempEnvironment, prefix: &Path, mode: u32) {
env.fs.mkdir_all(&prefix.join("bin")).unwrap();
env.fs
.write_file_with_mode(&brew_binary(prefix), b"#!/bin/sh\n", mode)
.unwrap();
}
fn host_with(prefixes: &[&Path]) -> BrewHost {
BrewHost {
is_macos: true,
prefix_candidates: prefixes.iter().map(PathBuf::from).collect(),
}
}
#[test]
fn mode_parses_its_vocabulary_and_rejects_the_rest() {
assert_eq!(
BrewBootstrapMode::parse("auto").unwrap(),
BrewBootstrapMode::Auto
);
assert_eq!(
BrewBootstrapMode::parse("off").unwrap(),
BrewBootstrapMode::Off
);
assert_eq!(BrewBootstrapMode::default(), BrewBootstrapMode::Auto);
let err = BrewBootstrapMode::parse("of").unwrap_err().to_string();
assert!(err.contains("auto, off"), "error was: {err}");
}
#[test]
fn homebrew_prefix_env_leads_the_candidates() {
let host = BrewHost::new(true, Some("/custom/brew".to_string()));
assert_eq!(
host.prefix_candidates,
vec![
PathBuf::from("/custom/brew"),
PathBuf::from("/opt/homebrew"),
PathBuf::from("/usr/local"),
]
);
}
#[test]
fn unset_or_blank_prefix_env_leaves_the_defaults() {
for env_value in [None, Some(String::new()), Some(" ".to_string())] {
let host = BrewHost::new(true, env_value);
assert_eq!(
host.prefix_candidates,
vec![PathBuf::from("/opt/homebrew"), PathBuf::from("/usr/local")]
);
}
}
#[test]
fn first_candidate_holding_brew_wins() {
let env = TempEnvironment::builder().build();
let first = env.dotfiles_root.join("opt/homebrew");
let second = env.dotfiles_root.join("usr/local");
install_brew(&env, &second);
let host = host_with(&[&first, &second]);
assert_eq!(detect_prefix(env.fs.as_ref(), &host), Some(second.clone()));
install_brew(&env, &first);
assert_eq!(detect_prefix(env.fs.as_ref(), &host), Some(first));
}
#[test]
fn a_non_executable_brew_is_not_a_brew() {
let env = TempEnvironment::builder().build();
let first = env.dotfiles_root.join("opt/homebrew");
let second = env.dotfiles_root.join("usr/local");
install_brew_with_mode(&env, &first, 0o644);
install_brew(&env, &second);
let host = host_with(&[&first, &second]);
assert_eq!(detect_prefix(env.fs.as_ref(), &host), Some(second));
let only_a_dir = env.dotfiles_root.join("bare");
env.fs.mkdir_all(&brew_binary(&only_a_dir)).unwrap();
assert_eq!(
detect_prefix(env.fs.as_ref(), &host_with(&[&only_a_dir])),
None
);
}
#[test]
fn capture_asks_brew_once_per_shell_dialect() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let runner = FakeBrew::new();
let BrewCapture::Captured(blocks) = capture(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
) else {
panic!("brew is installed under the temp prefix and answers");
};
let brew = brew_binary(&prefix).display().to_string();
assert_eq!(
runner.calls(),
vec![
format!("{brew} shellenv sh"),
format!("{brew} shellenv zsh"),
]
);
assert_eq!(blocks.prefix, prefix);
assert_eq!(blocks.sh, SH_BLOCK);
assert_eq!(blocks.zsh, ZSH_BLOCK);
}
#[test]
fn off_emits_nothing_and_never_spawns_brew() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let runner = FakeBrew::new();
assert_eq!(
capture(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Off,
&host_with(&[&prefix]),
),
BrewCapture::Absent
);
assert!(runner.calls().is_empty(), "`off` must not run brew at all");
}
#[test]
fn non_macos_emits_nothing() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let runner = FakeBrew::new();
let host = BrewHost {
is_macos: false,
prefix_candidates: vec![prefix],
};
assert_eq!(
capture(env.fs.as_ref(), &runner, BrewBootstrapMode::Auto, &host),
BrewCapture::Absent
);
assert!(runner.calls().is_empty());
}
#[test]
fn brew_absent_is_silent_absence() {
let env = TempEnvironment::builder().build();
let missing = env.dotfiles_root.join("opt/homebrew");
let runner = FakeBrew::new();
assert_eq!(
capture(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&missing]),
),
BrewCapture::Absent
);
assert!(runner.calls().is_empty());
}
#[test]
fn brew_that_fails_to_answer_is_a_finding_not_an_absence() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let runner = FakeBrew::failing_with_stderr(2, "Error: git is wedged\nmore detail");
let BrewCapture::Failed(failure) = capture(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
) else {
panic!("a detected brew that fails must be Failed, not Absent");
};
assert_eq!(failure.prefix, prefix);
assert!(
failure.reason.contains("`brew shellenv sh`")
&& failure.reason.contains("exited with status 2")
&& failure.reason.contains("Error: git is wedged"),
"reason was: {}",
failure.reason
);
}
#[test]
fn silent_exit_zero_still_carries_brews_stderr() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let runner = FakeBrew::failing_with_stderr(0, "Warning: brew is mid-upgrade\nmore detail");
let BrewCapture::Failed(failure) = capture(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
) else {
panic!("exit 0 with no stdout must be Failed, not Captured");
};
assert!(
failure.reason.contains("exited 0 but printed nothing")
&& failure.reason.contains("Warning: brew is mid-upgrade")
&& !failure.reason.contains("more detail"),
"reason was: {}",
failure.reason
);
}
#[test]
fn brew_that_fails_to_spawn_is_a_finding_carrying_the_error() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let BrewCapture::Failed(failure) = capture(
env.fs.as_ref(),
&UnspawnableBrew,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
) else {
panic!("a brew that cannot be spawned must be Failed");
};
assert!(
failure.reason.contains("failed to run") && failure.reason.contains("text file busy"),
"reason was: {}",
failure.reason
);
}
#[test]
fn one_failing_dialect_fails_the_capture_and_names_itself() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let runner = FakeBrew::failing_dialect("zsh");
let BrewCapture::Failed(failure) = capture(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
) else {
panic!("a half-answering brew must be Failed, not partially captured");
};
assert!(
failure.reason.contains("`brew shellenv zsh`"),
"reason must name the dialect that failed: {}",
failure.reason
);
}
#[test]
fn cache_round_trips_the_capture_byte_for_byte() {
let env = TempEnvironment::builder().build();
let path = env.paths.homebrew_cache_path();
let blocks = BrewBlocks {
prefix: PathBuf::from("/opt/homebrew"),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
};
persist_cache(env.fs.as_ref(), &path, Some(&blocks)).unwrap();
assert_eq!(load_cache(env.fs.as_ref(), &path), Some(blocks));
}
#[test]
fn persist_replaces_the_cache_by_rename_not_by_truncating_it() {
let env = TempEnvironment::builder().build();
let cache = env.paths.homebrew_cache_path();
let first = BrewBlocks {
prefix: PathBuf::from("/usr/local"),
sh: "export FIRST=1;\n".to_string(),
zsh: "export FIRST=1;\n".to_string(),
};
let second = BrewBlocks {
prefix: PathBuf::from("/opt/homebrew"),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
};
persist_cache(env.fs.as_ref(), &cache, Some(&first)).unwrap();
let witness = cache.parent().unwrap().join("witness.json");
std::fs::hard_link(&cache, &witness).unwrap();
persist_cache(env.fs.as_ref(), &cache, Some(&second)).unwrap();
assert_eq!(load_cache(env.fs.as_ref(), &cache), Some(second));
assert_eq!(
load_cache(env.fs.as_ref(), &witness),
Some(first),
"the old inode was rewritten: the cache is being truncated \
in place, so a reader can see a half-written file"
);
}
#[test]
fn persist_leaves_no_temp_file_behind() {
let env = TempEnvironment::builder().build();
let cache = env.paths.homebrew_cache_path();
let blocks = BrewBlocks {
prefix: PathBuf::from("/opt/homebrew"),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
};
persist_cache(env.fs.as_ref(), &cache, Some(&blocks)).unwrap();
persist_cache(env.fs.as_ref(), &cache, Some(&blocks)).unwrap();
let names: Vec<String> = env
.fs
.read_dir(cache.parent().unwrap())
.unwrap()
.into_iter()
.map(|entry| entry.name)
.collect();
assert_eq!(names, vec!["brew-shellenv.json".to_string()]);
}
#[test]
fn warm_cache_emits_without_spawning_brew() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let cache = env.paths.homebrew_cache_path();
let cached = BrewBlocks {
prefix: prefix.clone(),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
};
persist_cache(env.fs.as_ref(), &cache, Some(&cached)).unwrap();
let runner = FakeBrew::new();
let served = cached_or_capture_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
&cache,
);
assert_eq!(served, Some(cached));
assert!(
runner.calls().is_empty(),
"a warm cache must not spawn brew: {:?}",
runner.calls()
);
}
#[test]
fn cache_miss_captures_live_and_does_not_persist() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let cache = env.paths.homebrew_cache_path();
let runner = FakeBrew::new();
let served = cached_or_capture_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
&cache,
)
.expect("live capture must fill the miss");
assert_eq!(served.sh, SH_BLOCK);
assert_eq!(served.zsh, ZSH_BLOCK);
assert_eq!(runner.calls().len(), 2, "one capture, two dialects");
assert!(
!env.fs.exists(&cache),
"a passive command must never write the cache"
);
}
#[test]
fn cache_for_another_prefix_is_invalidated_not_served() {
let env = TempEnvironment::builder().build();
let old_prefix = env.dotfiles_root.join("usr/local");
let new_prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &new_prefix); let cache = env.paths.homebrew_cache_path();
persist_cache(
env.fs.as_ref(),
&cache,
Some(&BrewBlocks {
prefix: old_prefix.clone(),
sh: "export STALE=1;\n".to_string(),
zsh: "export STALE=1;\n".to_string(),
}),
)
.unwrap();
let runner = FakeBrew::new();
let served = cached_or_capture_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&old_prefix, &new_prefix]),
&cache,
)
.expect("the moved brew must be captured live");
assert_eq!(served.prefix, new_prefix);
assert_eq!(served.sh, SH_BLOCK, "stale block must never be served");
assert_eq!(runner.calls().len(), 2);
assert_eq!(
load_cache(env.fs.as_ref(), &cache).unwrap().prefix,
old_prefix
);
}
#[test]
fn corrupt_cache_is_a_miss_not_an_error() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let cache = env.paths.homebrew_cache_path();
env.fs.mkdir_all(cache.parent().unwrap()).unwrap();
env.fs.write_file(&cache, b"not json {").unwrap();
let runner = FakeBrew::new();
let served = cached_or_capture_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
&cache,
);
assert!(served.is_some());
assert_eq!(runner.calls().len(), 2);
}
#[test]
fn off_and_non_macos_ignore_a_warm_cache() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let cache = env.paths.homebrew_cache_path();
persist_cache(
env.fs.as_ref(),
&cache,
Some(&BrewBlocks {
prefix: prefix.clone(),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
}),
)
.unwrap();
let runner = FakeBrew::new();
assert!(cached_or_capture_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Off,
&host_with(&[&prefix]),
&cache,
)
.is_none());
let non_mac = BrewHost {
is_macos: false,
prefix_candidates: vec![prefix],
};
assert!(cached_or_capture_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&non_mac,
&cache,
)
.is_none());
assert!(runner.calls().is_empty());
}
#[test]
fn persisting_an_empty_capture_clears_the_cache() {
let env = TempEnvironment::builder().build();
let cache = env.paths.homebrew_cache_path();
persist_cache(
env.fs.as_ref(),
&cache,
Some(&BrewBlocks {
prefix: PathBuf::from("/opt/homebrew"),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
}),
)
.unwrap();
assert!(env.fs.exists(&cache));
persist_cache(env.fs.as_ref(), &cache, None).unwrap();
assert!(!env.fs.exists(&cache));
persist_cache(env.fs.as_ref(), &cache, None).unwrap();
}
#[test]
fn successful_capture_persists_and_carries_no_warning() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let cache = env.paths.homebrew_cache_path();
let runner = FakeBrew::new();
let result = capture_and_persist_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
&cache,
)
.unwrap();
assert!(result.warning.is_none());
assert_eq!(
result.blocks.as_ref().map(|b| b.sh.as_str()),
Some(SH_BLOCK)
);
assert_eq!(load_cache(env.fs.as_ref(), &cache), result.blocks);
}
#[test]
fn absent_brew_stays_silent_and_clears_the_cache() {
let env = TempEnvironment::builder().build();
let missing = env.dotfiles_root.join("opt/homebrew");
let cache = env.paths.homebrew_cache_path();
persist_cache(
env.fs.as_ref(),
&cache,
Some(&BrewBlocks {
prefix: missing.clone(),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
}),
)
.unwrap();
let runner = FakeBrew::new();
let result = capture_and_persist_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&missing]),
&cache,
)
.unwrap();
assert_eq!(
result,
PersistedCapture {
blocks: None,
warning: None
}
);
assert!(!env.fs.exists(&cache), "a lost brew must clear the cache");
}
#[test]
fn failed_answer_warns_and_keeps_the_previous_capture() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let cache = env.paths.homebrew_cache_path();
let previous = BrewBlocks {
prefix: prefix.clone(),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
};
persist_cache(env.fs.as_ref(), &cache, Some(&previous)).unwrap();
let runner = FakeBrew::failing_with_stderr(1, "Error: brew is mid-upgrade");
let result = capture_and_persist_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
&cache,
)
.unwrap();
assert_eq!(
result.blocks,
Some(previous.clone()),
"the previous capture must be served, not stripped"
);
let warning = result.warning.expect("a failed answer must warn");
assert!(
warning.contains("exited with status 1")
&& warning.contains("Error: brew is mid-upgrade")
&& warning.contains("Keeping"),
"warning was: {warning}"
);
assert_eq!(
load_cache(env.fs.as_ref(), &cache),
Some(previous),
"the cache must survive a failed answer untouched"
);
}
#[test]
fn one_failing_dialect_warns_and_keeps_instead_of_stripping_both() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let cache = env.paths.homebrew_cache_path();
let previous = BrewBlocks {
prefix: prefix.clone(),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
};
persist_cache(env.fs.as_ref(), &cache, Some(&previous)).unwrap();
let runner = FakeBrew::failing_dialect("zsh");
let result = capture_and_persist_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
&cache,
)
.unwrap();
assert_eq!(result.blocks, Some(previous.clone()));
assert!(
result
.warning
.as_deref()
.is_some_and(|w| w.contains("`brew shellenv zsh`")),
"warning must name the failed dialect: {:?}",
result.warning
);
assert_eq!(load_cache(env.fs.as_ref(), &cache), Some(previous));
}
#[test]
fn failed_answer_with_no_cache_warns_and_emits_nothing() {
let env = TempEnvironment::builder().build();
let prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &prefix);
let cache = env.paths.homebrew_cache_path();
let runner = FakeBrew::failing(1);
let result = capture_and_persist_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&prefix]),
&cache,
)
.unwrap();
assert!(result.blocks.is_none());
assert!(
result
.warning
.as_deref()
.is_some_and(|w| w.contains("no Homebrew block")),
"warning was: {:?}",
result.warning
);
assert!(!env.fs.exists(&cache));
}
#[test]
fn failed_answer_never_serves_a_mismatched_prefix_cache() {
let env = TempEnvironment::builder().build();
let old_prefix = env.dotfiles_root.join("usr/local");
let new_prefix = env.dotfiles_root.join("opt/homebrew");
install_brew(&env, &new_prefix);
let cache = env.paths.homebrew_cache_path();
let stale = BrewBlocks {
prefix: old_prefix.clone(),
sh: "export STALE=1;\n".to_string(),
zsh: "export STALE=1;\n".to_string(),
};
persist_cache(env.fs.as_ref(), &cache, Some(&stale)).unwrap();
let runner = FakeBrew::failing(1);
let result = capture_and_persist_at(
env.fs.as_ref(),
&runner,
BrewBootstrapMode::Auto,
&host_with(&[&old_prefix, &new_prefix]),
&cache,
)
.unwrap();
assert!(
result.blocks.is_none(),
"a mismatched-prefix cache must never be served"
);
assert!(result.warning.is_some());
assert_eq!(load_cache(env.fs.as_ref(), &cache), Some(stale));
}
#[test]
fn zsh_only_lines_stay_under_the_zsh_guard() {
let mut script = String::new();
emit_homebrew_block(
&mut script,
&BrewBlocks {
prefix: PathBuf::from("/opt/homebrew"),
sh: SH_BLOCK.to_string(),
zsh: ZSH_BLOCK.to_string(),
},
);
let guard = script.find("if [ -n \"${ZSH_VERSION:-}\" ]; then").unwrap();
let else_at = script.find("\nelse\n").unwrap();
let fi_at = script.find("\nfi\n").unwrap();
let zsh_branch = &script[guard..else_at];
let sh_branch = &script[else_at..fi_at];
assert!(zsh_branch.contains("fpath[1,0]="), "script:\n{script}");
assert!(zsh_branch.contains("export FPATH;"), "script:\n{script}");
assert!(!sh_branch.contains("fpath["), "script:\n{script}");
assert!(!sh_branch.contains("FPATH"), "script:\n{script}");
assert!(zsh_branch.contains("PATH_HELPER_ROOT"));
assert!(sh_branch.contains("PATH_HELPER_ROOT"));
}
#[test]
fn captured_output_is_emitted_byte_for_byte() {
let mut script = String::new();
let sh = "export ONE=1;\nexport TWO=2;";
emit_homebrew_block(
&mut script,
&BrewBlocks {
prefix: PathBuf::from("/opt/homebrew"),
sh: sh.to_string(),
zsh: "export ZED=1;\n".to_string(),
},
);
assert!(
script.contains("export ONE=1;\nexport TWO=2;\nfi\n"),
"script:\n{script}"
);
assert!(
script.contains("export ZED=1;\nelse\n"),
"script:\n{script}"
);
}
}