use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use console::style;
use crate::config::Config;
use crate::discover::Platform;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExistingHomebrew {
pub prefix: PathBuf,
pub repository: PathBuf,
pub brew_binary: Option<PathBuf>,
pub auto_migrate_configured: bool,
pub managed_by_nix_homebrew: bool,
}
impl ExistingHomebrew {
pub fn is_conflict(&self) -> bool {
(self.repository.exists() || self.brew_binary.is_some())
&& !self.auto_migrate_configured
&& !self.managed_by_nix_homebrew
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HomebrewBootstrapChoice {
Migrate,
Reset,
Abort,
}
pub(crate) fn detect_existing(config: &Config) -> Result<Option<ExistingHomebrew>> {
if config.platform != Platform::Darwin {
return Ok(None);
}
let prefixes = homebrew_prefixes_for_host();
for prefix in prefixes {
if let Some(existing) = detect_existing_at(config, &prefix)? {
return Ok(Some(existing));
}
}
Ok(None)
}
fn detect_existing_at(config: &Config, prefix: &Path) -> Result<Option<ExistingHomebrew>> {
let repository = prefix.join("Homebrew/Library/Homebrew");
let brew_binary = [prefix.join("bin/brew"), prefix.join("Homebrew/bin/brew")]
.into_iter()
.find(|path| path.exists());
if !repository.exists() && brew_binary.is_none() {
return Ok(None);
}
let managed_by_nix_homebrew =
managed_by_nix_homebrew(prefix, &repository, brew_binary.as_deref());
Ok(Some(ExistingHomebrew {
prefix: prefix.to_path_buf(),
repository,
brew_binary,
auto_migrate_configured: homebrew_auto_migrate_configured(&config.homebrew_file)?,
managed_by_nix_homebrew,
}))
}
fn managed_by_nix_homebrew(prefix: &Path, repository: &Path, brew_binary: Option<&Path>) -> bool {
let marker = repository.join(".homebrew-is-managed-by-nix");
if marker.exists() {
return true;
}
if path_resolves_into_nix_store(repository) {
return true;
}
if brew_binary.is_some_and(path_resolves_into_nix_store) {
return true;
}
let taps = prefix.join("Homebrew/Library/Taps");
if path_resolves_into_nix_store(&taps) {
return true;
}
false
}
fn path_resolves_into_nix_store(path: &Path) -> bool {
path.starts_with("/nix/store")
|| path
.canonicalize()
.map(|resolved| resolved.starts_with("/nix/store"))
.unwrap_or(false)
}
pub(crate) fn preflight(config: &Config, dry_run: bool) -> Result<()> {
let Some(existing) = detect_existing(config)? else {
return Ok(());
};
if !existing.is_conflict() {
return Ok(());
}
print_existing_homebrew_warning(&existing);
if dry_run {
return Ok(());
}
match prompt_choice()? {
HomebrewBootstrapChoice::Migrate => {
enable_auto_migrate(config)?;
eprintln!(
" {} enabled nix-homebrew.autoMigrate; rerun switch/activation",
style("✓").green().bold()
);
bail!("Homebrew migration configured; rerun the activation command");
}
HomebrewBootstrapChoice::Reset => {
let inventory = inventory_existing(&existing)?;
quarantine_existing(&existing)?;
eprintln!(
" {} inventory kept at {}",
style("i").cyan(),
inventory.display()
);
}
HomebrewBootstrapChoice::Abort => bail!("aborted to leave existing Homebrew unchanged"),
}
Ok(())
}
pub(crate) fn print_existing_homebrew_warning(existing: &ExistingHomebrew) {
eprintln!();
eprintln!(
" {} existing unmanaged Homebrew detected at {}",
style("!").yellow().bold(),
existing.prefix.display()
);
eprintln!(
" {}",
style("nix-homebrew will reject activation until this is migrated or reset.").dim()
);
eprintln!(
" {}",
style("Run `nex doctor --fix homebrew-bootstrap` to repair before switching.").dim()
);
}
pub(crate) fn doctor(config: &Config, fix: bool) -> Result<()> {
let Some(existing) = detect_existing(config)? else {
eprintln!(" {} homebrew bootstrap ready", style("✓").green().bold());
return Ok(());
};
if !existing.is_conflict() {
eprintln!(" {} homebrew bootstrap ready", style("✓").green().bold());
return Ok(());
}
print_existing_homebrew_warning(&existing);
if fix {
match prompt_choice()? {
HomebrewBootstrapChoice::Migrate => {
enable_auto_migrate(config)?;
}
HomebrewBootstrapChoice::Reset => {
let inventory = inventory_existing(&existing)?;
quarantine_existing(&existing)?;
eprintln!(
" {} inventory kept at {}",
style("i").cyan(),
inventory.display()
);
}
HomebrewBootstrapChoice::Abort => bail!("aborted to leave existing Homebrew unchanged"),
}
}
Ok(())
}
fn prompt_choice() -> Result<HomebrewBootstrapChoice> {
let items = vec![
"migrate: set nix-homebrew.autoMigrate = true and preserve installed packages".to_string(),
"reset: inventory packages, move Homebrew aside, and let nix-homebrew install fresh"
.to_string(),
"abort: leave Homebrew unchanged".to_string(),
];
match crate::input::input().select("Existing Homebrew detected", &items, 0)? {
0 => Ok(HomebrewBootstrapChoice::Migrate),
1 => confirm_reset_choice(),
_ => Ok(HomebrewBootstrapChoice::Abort),
}
}
fn confirm_reset_choice() -> Result<HomebrewBootstrapChoice> {
eprintln!();
eprintln!(
" {} reset moves the Homebrew repository and brew shim aside; it does not delete package payloads.",
style("!").yellow().bold()
);
eprintln!(" Type {} to continue.", style("RESET HOMEBREW").bold());
let typed = crate::input::input().input_text("Confirmation", None)?;
Ok(reset_choice_from_confirmation(&typed))
}
fn reset_choice_from_confirmation(typed: &str) -> HomebrewBootstrapChoice {
if typed == "RESET HOMEBREW" {
HomebrewBootstrapChoice::Reset
} else {
HomebrewBootstrapChoice::Abort
}
}
pub(crate) fn enable_auto_migrate(config: &Config) -> Result<bool> {
let path = &config.homebrew_file;
let content =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let patched = add_auto_migrate_to_homebrew_module(&content)
.context("could not find nix-homebrew block to patch autoMigrate")?;
if patched == content {
return Ok(false);
}
crate::edit::atomic_write_bytes(path, patched.as_bytes())?;
crate::exec::git_commit(&config.repo, "nex doctor: enable nix-homebrew autoMigrate");
Ok(true)
}
fn add_auto_migrate_to_homebrew_module(content: &str) -> Option<String> {
if content.contains("autoMigrate = true;") {
return Some(content.to_string());
}
let marker = " enable = true;\n";
let idx = content.find(marker)? + marker.len();
let mut patched = String::with_capacity(content.len() + 32);
patched.push_str(&content[..idx]);
patched.push_str(" autoMigrate = true;\n");
patched.push_str(&content[idx..]);
Some(patched)
}
fn inventory_existing(existing: &ExistingHomebrew) -> Result<PathBuf> {
let state_dir = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".local/state/nex");
std::fs::create_dir_all(&state_dir)?;
let stamp = current_timestamp();
let prefix = state_dir.join(format!("homebrew-before-reset-{stamp}"));
std::fs::create_dir_all(&prefix)?;
if let Some(brew) = &existing.brew_binary {
capture_brew_list(brew, &["list", "--formula"], &prefix.join("formulae.txt"))?;
capture_brew_list(brew, &["list", "--cask"], &prefix.join("casks.txt"))?;
capture_brew_list(brew, &["leaves"], &prefix.join("leaves.txt"))?;
capture_brew_list(brew, &["tap"], &prefix.join("taps.txt"))?;
capture_brew_list(brew, &["config"], &prefix.join("config.txt"))?;
let brewfile = prefix.join("Brewfile");
let _ = Command::new(brew)
.args(["bundle", "dump", "--file"])
.arg(&brewfile)
.arg("--force")
.output();
}
eprintln!(
" {} wrote Homebrew inventory to {}",
style("✓").green(),
prefix.display()
);
Ok(prefix)
}
fn capture_brew_list(brew: &Path, args: &[&str], output_path: &Path) -> Result<()> {
let output = Command::new(brew)
.args(args)
.output()
.with_context(|| format!("running {} {}", brew.display(), args.join(" ")))?;
if !output.status.success() {
bail!(
"failed to inventory Homebrew with `{} {}`: {}",
brew.display(),
args.join(" "),
crate::exec::captured_text(&output.stderr).trim()
);
}
std::fs::write(output_path, crate::exec::captured_text(&output.stdout))?;
Ok(())
}
fn quarantine_existing(existing: &ExistingHomebrew) -> Result<()> {
let prefix = existing.prefix.to_string_lossy().to_string();
if prefix != "/usr/local" && prefix != "/opt/homebrew" {
bail!("refusing to reset unexpected Homebrew prefix {prefix}");
}
if existing.repository.exists() {
let homebrew_dir = existing.prefix.join("Homebrew");
ensure_safe_quarantine_source(&homebrew_dir, &existing.prefix)?;
let quarantine = next_quarantine_path(&homebrew_dir);
run_sudo_mv(&homebrew_dir, &quarantine)?;
eprintln!(
" {} moved {} to {}",
style("✓").green(),
homebrew_dir.display(),
quarantine.display()
);
}
if let Some(brew) = &existing.brew_binary {
if brew.exists() && brew.starts_with(&existing.prefix) {
ensure_safe_quarantine_source(brew, &existing.prefix)?;
let quarantine = next_quarantine_path(brew);
run_sudo_mv(brew, &quarantine)?;
eprintln!(
" {} moved {} to {}",
style("✓").green(),
brew.display(),
quarantine.display()
);
}
}
eprintln!(
" {} moved unmanaged Homebrew aside at {}",
style("✓").green(),
existing.prefix.display()
);
Ok(())
}
fn ensure_safe_quarantine_source(path: &Path, prefix: &Path) -> Result<()> {
let metadata = std::fs::symlink_metadata(path)
.with_context(|| format!("inspecting {}", path.display()))?;
if metadata.file_type().is_symlink() {
return Ok(());
}
let canonical_path = path
.canonicalize()
.with_context(|| format!("canonicalizing {}", path.display()))?;
let canonical_prefix = prefix
.canonicalize()
.with_context(|| format!("canonicalizing {}", prefix.display()))?;
if !canonical_path.starts_with(&canonical_prefix) {
bail!(
"refusing to move {} because it resolves outside {}",
path.display(),
prefix.display()
);
}
Ok(())
}
fn run_sudo_mv(from: &Path, to: &Path) -> Result<()> {
let status = Command::new("sudo")
.arg("mv")
.arg(from)
.arg(to)
.status()
.with_context(|| format!("failed to move {} to {}", from.display(), to.display()))?;
if !status.success() {
bail!("failed to move {} to {}", from.display(), to.display());
}
Ok(())
}
fn next_quarantine_path(path: &Path) -> PathBuf {
let candidate = PathBuf::from(format!(
"{}.before-nex-reset-{}",
path.display(),
current_timestamp()
));
if !candidate.exists() {
return candidate;
}
for i in 1.. {
let candidate = PathBuf::from(format!(
"{}.before-nex-reset-{}.{}",
path.display(),
current_timestamp(),
i
));
if !candidate.exists() {
return candidate;
}
}
unreachable!()
}
fn homebrew_auto_migrate_configured(path: &Path) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let content =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
Ok(content.contains("autoMigrate = true;"))
}
pub(crate) fn expected_brew_binary_exists() -> bool {
expected_homebrew_prefix_for_host()
.join("bin/brew")
.exists()
}
fn expected_homebrew_prefix_for_host() -> PathBuf {
if cfg!(target_arch = "aarch64") {
PathBuf::from("/opt/homebrew")
} else {
PathBuf::from("/usr/local")
}
}
fn homebrew_prefixes_for_host() -> Vec<PathBuf> {
if cfg!(target_arch = "aarch64") {
vec![PathBuf::from("/opt/homebrew"), PathBuf::from("/usr/local")]
} else {
vec![PathBuf::from("/usr/local"), PathBuf::from("/opt/homebrew")]
}
}
fn current_timestamp() -> String {
match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(duration) => duration.as_secs().to_string(),
Err(_) => "0".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::{
add_auto_migrate_to_homebrew_module, managed_by_nix_homebrew, next_quarantine_path,
ExistingHomebrew,
};
use std::path::{Path, PathBuf};
#[test]
fn inserts_auto_migrate_after_enable() {
let input = "nix-homebrew = {\n enable = true;\n user = username;\n};\n";
let output = add_auto_migrate_to_homebrew_module(input).expect("patchable");
assert!(output.contains(" enable = true;\n autoMigrate = true;\n"));
}
#[test]
fn auto_migrate_patch_is_idempotent() {
let input = "nix-homebrew = {\n enable = true;\n autoMigrate = true;\n};\n";
assert_eq!(
add_auto_migrate_to_homebrew_module(input).as_deref(),
Some(input)
);
}
#[test]
fn quarantine_path_moves_aside_instead_of_deleting() {
let path = next_quarantine_path(Path::new("/usr/local/Homebrew"));
assert!(path
.to_string_lossy()
.starts_with("/usr/local/Homebrew.before-nex-reset-"));
}
#[test]
fn marker_file_classifies_prefix_as_managed() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("Homebrew/Library/Homebrew");
std::fs::create_dir_all(&repo).expect("repo dir");
std::fs::write(repo.join(".homebrew-is-managed-by-nix"), "").expect("marker");
assert!(managed_by_nix_homebrew(dir.path(), &repo, None));
}
#[test]
fn nix_store_brew_symlink_classifies_prefix_as_managed() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("Homebrew/Library/Homebrew");
std::fs::create_dir_all(&repo).expect("repo dir");
let brew = PathBuf::from("/nix/store/nex-test-brew");
assert!(managed_by_nix_homebrew(dir.path(), &repo, Some(&brew)));
}
#[test]
fn managed_homebrew_is_not_a_conflict() {
let existing = ExistingHomebrew {
prefix: PathBuf::from("/usr/local"),
repository: PathBuf::from("/usr/local/Homebrew/Library/Homebrew"),
brew_binary: Some(PathBuf::from("/usr/local/bin/brew")),
auto_migrate_configured: false,
managed_by_nix_homebrew: true,
};
assert!(!existing.is_conflict());
}
#[test]
fn unmanaged_homebrew_is_a_conflict() {
let dir = tempfile::tempdir().expect("temp dir");
let repo = dir.path().join("Homebrew/Library/Homebrew");
std::fs::create_dir_all(&repo).expect("repo dir");
let existing = ExistingHomebrew {
prefix: dir.path().to_path_buf(),
repository: repo,
brew_binary: None,
auto_migrate_configured: false,
managed_by_nix_homebrew: false,
};
assert!(existing.is_conflict());
}
#[test]
fn reset_confirmation_accepts_only_exact_phrase() {
assert_eq!(
super::reset_choice_from_confirmation("no"),
super::HomebrewBootstrapChoice::Abort
);
assert_eq!(
super::reset_choice_from_confirmation("RESET HOMEBREW"),
super::HomebrewBootstrapChoice::Reset
);
}
}