use std::collections::HashSet;
use std::path::PathBuf;
use anyhow::Result;
use console::style;
use crate::config::Config;
use crate::edit::{self, EditSession};
use crate::exec;
use crate::nixfile;
use crate::output;
fn confirm_or_default(prompt: &str, default: bool) -> Result<bool> {
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return Ok(default);
}
Ok(dialoguer::Confirm::new()
.with_prompt(prompt)
.default(default)
.interact()?)
}
pub fn run(config: &Config, dry_run: bool) -> Result<()> {
if !exec::brew_available() {
output::error("brew not found — nothing to adopt");
return Ok(());
}
println!();
println!(
" {} — capturing installed packages",
style("nex adopt").bold()
);
println!();
let managed_brews: HashSet<String> =
edit::list_packages(&config.homebrew_file, &nixfile::HOMEBREW_BREWS)?
.into_iter()
.collect();
let managed_casks: HashSet<String> =
edit::list_packages(&config.homebrew_file, &nixfile::HOMEBREW_CASKS)?
.into_iter()
.collect();
let installed_formulae = exec::brew_leaves()?;
let installed_casks = exec::brew_list_casks()?;
let new_formulae: Vec<&String> = installed_formulae
.iter()
.filter(|f| !managed_brews.contains(*f))
.collect();
let new_casks: Vec<&String> = installed_casks
.iter()
.filter(|c| !managed_casks.contains(*c))
.collect();
if new_formulae.is_empty() && new_casks.is_empty() {
println!(
" {} all installed brew packages are already in the nex config",
style("✓").green().bold()
);
println!();
return Ok(());
}
if !new_formulae.is_empty() {
println!(
" {} brew formulae to add:",
style(new_formulae.len()).bold()
);
for f in &new_formulae {
println!(" {} {}", style("+").green(), f);
}
println!();
}
if !new_casks.is_empty() {
println!(" {} brew casks to add:", style(new_casks.len()).bold());
for c in &new_casks {
println!(" {} {}", style("+").green(), c);
}
println!();
}
if dry_run {
output::dry_run(&format!(
"would add {} formulae and {} casks to {}",
new_formulae.len(),
new_casks.len(),
config.homebrew_file.display()
));
println!();
return Ok(());
}
let total = new_formulae.len() + new_casks.len();
let confirm = confirm_or_default(
&format!(
" Add {total} packages to {}?",
config.homebrew_file.display()
),
true,
)?;
if !confirm {
println!(" cancelled");
return Ok(());
}
let mut session = EditSession::new();
session.backup(&config.homebrew_file)?;
let mut added_formulae = 0;
for formula in &new_formulae {
if edit::insert(&config.homebrew_file, &nixfile::HOMEBREW_BREWS, formula)? {
added_formulae += 1;
}
}
let mut added_casks = 0;
for cask in &new_casks {
if edit::insert(&config.homebrew_file, &nixfile::HOMEBREW_CASKS, cask)? {
added_casks += 1;
}
}
session.commit_all()?;
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&config.repo)
.output();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "nex adopt: capture existing brew packages"])
.current_dir(&config.repo)
.output();
println!();
println!(
" {} added {} formulae and {} casks",
style("✓").green().bold(),
added_formulae,
added_casks
);
let nix_packages = edit::list_packages(&config.nix_packages_file, &nixfile::NIX_PACKAGES)?;
let collisions = find_path_collisions(&nix_packages);
if !collisions.is_empty() {
println!();
println!("{}", style("PATH collisions detected").yellow().bold());
println!();
println!(" The following binaries exist outside nix/brew. After switch,");
println!(" nix versions will take priority. You can pin each one to");
println!(" keep your existing version.");
println!();
let mut pinned = 0;
for (binary, existing_path, existing_ver) in &collisions {
let nix_ver = exec::nix_eval_version(binary)
.ok()
.flatten()
.unwrap_or_else(|| "?".into());
let ver_info = if let Some(v) = existing_ver {
format!("{} -> nix {}", v, nix_ver)
} else {
format!("-> nix {}", nix_ver)
};
println!(
" {} {} {} ({})",
style("!").yellow(),
style(binary).bold(),
style(&ver_info).dim(),
style(existing_path.display()).dim()
);
let pin = confirm_or_default(
&format!(" Keep existing {binary}? (removes from nix config)"),
false,
)?;
if pin && edit::remove(&config.nix_packages_file, &nixfile::NIX_PACKAGES, binary)? {
println!(
" {} pinned — {} removed from nix config",
style("✓").green(),
binary
);
pinned += 1;
}
}
if pinned > 0 {
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&config.repo)
.output();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "nex adopt: pin existing binaries"])
.current_dir(&config.repo)
.output();
}
}
println!();
println!(
" It's now safe to run {}. Your existing packages",
style("nex switch").bold()
);
println!(" won't be removed or shadowed unexpectedly.");
println!();
println!(
" Later, run {} to see which formulae can move to nix.",
style("nex migrate").cyan()
);
println!();
Ok(())
}
fn find_path_collisions(nix_packages: &[String]) -> Vec<(String, PathBuf, Option<String>)> {
let path_var = std::env::var("PATH").unwrap_or_default();
let skip_prefixes = ["/nix/", "/opt/homebrew/"];
let mut collisions = Vec::new();
for pkg in nix_packages {
for dir in path_var.split(':') {
if skip_prefixes.iter().any(|p| dir.starts_with(p)) {
continue;
}
let bin = PathBuf::from(dir).join(pkg);
if bin.exists() {
let version = std::process::Command::new(&bin)
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.and_then(|o| {
let out = String::from_utf8_lossy(&o.stdout).to_string();
out.lines().next().map(|l| l.trim().to_string())
})
.filter(|v| !v.is_empty());
collisions.push((pkg.clone(), bin, version));
break;
}
}
}
collisions
}