use anyhow::{Context, Result};
use console::style;
use crate::config::Config;
use crate::output;
pub fn run(config: &Config) -> Result<()> {
tracing::info!("running doctor checks");
println!();
println!(" {} — checking configuration", style("nex doctor").bold());
println!();
let mut fixed = 0;
if check_mac_app_util(config, &mut fixed)? {
}
check_allow_unfree(config, &mut fixed)?;
check_session_path(config, &mut fixed)?;
if fixed > 0 {
crate::exec::git_commit(&config.repo, "nex doctor: apply fixes");
println!();
println!(
" {} {fixed} issue(s) fixed. Run {} to activate.",
style("✓").green().bold(),
style("nex switch").bold()
);
} else {
println!(" {} no issues found", style("✓").green().bold());
}
println!();
Ok(())
}
fn check_mac_app_util(config: &Config, fixed: &mut usize) -> Result<bool> {
let flake_path = config.repo.join("flake.nix");
let mkhost_path = config.repo.join("nix/lib/mkHost.nix");
let flake = std::fs::read_to_string(&flake_path)
.with_context(|| format!("reading {}", flake_path.display()))?;
if flake.contains("mac-app-util") {
ok("mac-app-util", "Spotlight app aliases enabled");
return Ok(false);
}
warn(
"mac-app-util",
"not configured — nix apps won't appear in Spotlight",
);
let lines: Vec<&str> = flake.lines().collect();
let mut result_lines: Vec<String> = Vec::new();
let mut added_input = false;
let mut patched_outputs = false;
let mut patched_inherit = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if !added_input && trimmed == "};" {
let is_inputs_close = lines[i + 1..]
.iter()
.take(3)
.any(|l| l.trim().starts_with("outputs"));
if is_inputs_close {
result_lines
.push(" mac-app-util.url = \"github:hraban/mac-app-util\";".to_string());
added_input = true;
}
}
if !patched_outputs
&& trimmed.starts_with("outputs")
&& trimmed.contains("home-manager")
&& !trimmed.contains("mac-app-util")
{
let patched = line.replace("home-manager }", "home-manager, mac-app-util }");
let patched = patched.replace("home-manager }:", "home-manager, mac-app-util }:");
result_lines.push(patched);
patched_outputs = true;
continue;
}
if !patched_inherit
&& trimmed.starts_with("inherit")
&& trimmed.contains("home-manager")
&& !trimmed.contains("mac-app-util")
{
let patched = line.replace("home-manager;", "home-manager mac-app-util;");
result_lines.push(patched);
patched_inherit = true;
continue;
}
result_lines.push(line.to_string());
}
let mut patched_flake = result_lines.join("\n");
if flake.ends_with('\n') && !patched_flake.ends_with('\n') {
patched_flake.push('\n');
}
if !patched_flake.contains("mac-app-util") {
output::warn("could not auto-patch flake.nix — manual edit required");
return Ok(false);
}
let has_input = patched_flake.contains("mac-app-util.url");
let has_output = patched_flake.contains("mac-app-util }");
let has_inherit = patched_flake.contains("mac-app-util;");
if !has_input || !has_output || !has_inherit {
output::warn(
"could not fully patch flake.nix — partial changes would break the flake.\n\
Add mac-app-util manually: https://github.com/hraban/mac-app-util",
);
return Ok(false);
}
crate::edit::atomic_write_bytes(&flake_path, patched_flake.as_bytes())
.with_context(|| format!("writing {}", flake_path.display()))?;
info("patched", &flake_path.display().to_string());
if mkhost_path.exists() {
let mkhost = std::fs::read_to_string(&mkhost_path)
.with_context(|| format!("reading {}", mkhost_path.display()))?;
if !mkhost.contains("mac-app-util") {
let patched = mkhost
.replace(
"{ nixpkgs, nix-darwin, home-manager }:",
"{ nixpkgs, nix-darwin, home-manager, mac-app-util }:",
)
.replace(
" hostModule\n home-manager.darwinModules.home-manager",
" hostModule\n mac-app-util.darwinModules.default\n home-manager.darwinModules.home-manager",
)
.replace(
" extraSpecialArgs = { inherit hostname username; };\n };",
" extraSpecialArgs = { inherit hostname username; };\n sharedModules = [\n mac-app-util.homeManagerModules.default\n ];\n };",
);
crate::edit::atomic_write_bytes(&mkhost_path, patched.as_bytes())
.with_context(|| format!("writing {}", mkhost_path.display()))?;
info("patched", &mkhost_path.display().to_string());
}
}
tracing::info!(fix = "mac-app-util", "applied fix");
*fixed += 1;
Ok(true)
}
fn check_allow_unfree(config: &Config, fixed: &mut usize) -> Result<bool> {
let base_path = config.repo.join("nix/modules/darwin/base.nix");
if !base_path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(&base_path)
.with_context(|| format!("reading {}", base_path.display()))?;
if content.contains("allowUnfree") {
ok("unfree packages", "nixpkgs.config.allowUnfree is set");
return Ok(false);
}
warn(
"unfree packages",
"not allowed — vscode, slack, spotify, etc. will fail to install",
);
let patched = if content.contains("nix.enable = false;") {
content.replace(
"nix.enable = false;",
"nix.enable = false;\n\n nixpkgs.config.allowUnfree = true;",
)
} else if content.contains("nix.settings.experimental-features") {
content.replace(
"nix.settings.experimental-features",
"nixpkgs.config.allowUnfree = true;\n\n nix.settings.experimental-features",
)
} else {
output::warn(
"could not auto-patch base.nix — add `nixpkgs.config.allowUnfree = true;` manually",
);
return Ok(false);
};
crate::edit::atomic_write_bytes(&base_path, patched.as_bytes())
.with_context(|| format!("writing {}", base_path.display()))?;
info("patched", &base_path.display().to_string());
tracing::info!(fix = "allow-unfree", "applied fix");
*fixed += 1;
Ok(true)
}
fn check_session_path(config: &Config, fixed: &mut usize) -> Result<bool> {
let base_path = &config.nix_packages_file;
if !base_path.exists() {
return Ok(false);
}
let content = std::fs::read_to_string(base_path)
.with_context(|| format!("reading {}", base_path.display()))?;
let in_nix_config = content.contains("sessionPath");
let on_path = is_local_bin_on_path();
if in_nix_config && on_path {
ok("sessionPath", "~/.local/bin is on PATH");
return Ok(false);
}
if in_nix_config && !on_path {
warn(
"sessionPath",
"configured in nix but ~/.local/bin is not on PATH — run `nex switch` then open a new shell",
);
return Ok(false);
}
warn(
"sessionPath",
"~/.local/bin not in PATH — nex may not be found after install",
);
let patched = if content.contains("stateVersion =") {
content.replace(
"stateVersion =",
"sessionPath = [ \"$HOME/.local/bin\" ];\n stateVersion =",
)
} else {
output::warn(
"could not auto-patch — add `home.sessionPath = [ \"$HOME/.local/bin\" ];` manually",
);
return Ok(false);
};
crate::edit::atomic_write_bytes(base_path, patched.as_bytes())
.with_context(|| format!("writing {}", base_path.display()))?;
info("patched", &base_path.display().to_string());
tracing::info!(fix = "session-path", "applied fix");
*fixed += 1;
Ok(true)
}
fn is_local_bin_on_path() -> bool {
let path_var = std::env::var("PATH").unwrap_or_default();
let home = std::env::var("HOME").unwrap_or_default();
let expanded = format!("{home}/.local/bin");
path_var
.split(':')
.any(|entry| entry == "~/.local/bin" || entry == "$HOME/.local/bin" || entry == expanded)
}
fn ok(label: &str, detail: &str) {
eprintln!(
" {} {}: {}",
style("✓").green().bold(),
label,
style(detail).dim()
);
}
fn warn(label: &str, detail: &str) {
eprintln!(
" {} {}: {}",
style("!").yellow().bold(),
label,
style(detail).dim()
);
}
fn info(label: &str, detail: &str) {
eprintln!(" {} {}: {}", style("→").cyan(), label, style(detail).dim());
}