use std::fs;
use std::process::Command;
use regex::Regex;
use crate::cli::UninstallArgs;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::flake::editor::remove_from_section;
use crate::flake::is_nixy_managed;
use crate::profile::get_flake_dir;
use super::info;
pub fn run(config: &Config, args: UninstallArgs) -> Result<()> {
let package = &args.package;
let flake_dir = get_flake_dir(config)?;
let flake_path = flake_dir.join("flake.nix");
if !flake_path.exists() {
return Err(Error::NoFlakeFound(flake_path.display().to_string()));
}
if !is_nixy_managed(&flake_path) {
return Err(Error::NotNixyManaged);
}
info(&format!("Uninstalling {}...", package));
let pkg_dir = flake_dir.join("packages");
let local_pkg_file = pkg_dir.join(format!("{}.nix", package));
let local_flake_dir = pkg_dir.join(package);
if local_pkg_file.exists() {
info(&format!(
"Removing local package definition: {}",
local_pkg_file.display()
));
fs::remove_file(&local_pkg_file)?;
git_rm(&flake_dir, &format!("packages/{}.nix", package));
} else if local_flake_dir.exists() && local_flake_dir.join("flake.nix").exists() {
info(&format!(
"Removing local flake: {}",
local_flake_dir.display()
));
fs::remove_dir_all(&local_flake_dir)?;
git_rm_recursive(&flake_dir, &format!("packages/{}", package));
}
remove_package_from_flake(config, package)?;
info("Rebuilding environment...");
super::sync::run(config)?;
Ok(())
}
fn remove_package_from_flake(config: &Config, pkg: &str) -> Result<()> {
let flake_dir = get_flake_dir(config)?;
let flake_path = flake_dir.join("flake.nix");
if !flake_path.exists() {
return Err(Error::NoFlakeFound(flake_path.display().to_string()));
}
if !is_nixy_managed(&flake_path) {
return Err(Error::NotNixyManaged);
}
let content = fs::read_to_string(&flake_path)?;
let input_used = find_input_for_package(&content, pkg);
let pkg_pattern = Regex::new(&format!(
r"^\s*{} = pkgs\.{};",
regex::escape(pkg),
regex::escape(pkg)
))?;
let content = remove_from_section(
&content,
"# [nixy:packages]",
"# [/nixy:packages]",
&pkg_pattern,
);
let path_pattern = Regex::new(&format!(r"^\s*{}$", regex::escape(pkg)))?;
let content = remove_from_section(
&content,
"# [nixy:env-paths]",
"# [/nixy:env-paths]",
&path_pattern,
);
let custom_pkg_pattern = Regex::new(&format!(r"^\s*{} = inputs\.", regex::escape(pkg)))?;
let content = remove_from_section(
&content,
"# [nixy:custom-packages]",
"# [/nixy:custom-packages]",
&custom_pkg_pattern,
);
let content = remove_from_section(
&content,
"# [nixy:custom-paths]",
"# [/nixy:custom-paths]",
&path_pattern,
);
let local_pkg_pattern = Regex::new(&format!(r"^\s*{} = ", regex::escape(pkg)))?;
let content = remove_from_section(
&content,
"# [nixy:local-packages]",
"# [/nixy:local-packages]",
&local_pkg_pattern,
);
let content = if let Some(input_name) = input_used {
remove_unused_input(&content, &input_name)
} else {
content
};
let overlay_input_name = format!("{}-overlay", pkg);
let content = remove_unused_overlay(&content, &overlay_input_name);
fs::write(&flake_path, content)?;
super::success(&format!("Removed {} from flake.nix", pkg));
Ok(())
}
fn find_input_for_package(content: &str, pkg: &str) -> Option<String> {
let pattern = Regex::new(&format!(
r"^\s*{} = inputs\.([a-zA-Z0-9_-]+)\.",
regex::escape(pkg)
))
.ok()?;
for line in content.lines() {
if let Some(caps) = pattern.captures(line) {
return Some(caps[1].to_string());
}
}
None
}
fn remove_unused_input(content: &str, input_name: &str) -> String {
let usage_pattern = format!(r"inputs\.{}\.", regex::escape(input_name));
if Regex::new(&usage_pattern)
.map(|r| r.is_match(content))
.unwrap_or(false)
{
return content.to_string();
}
let input_pattern = Regex::new(&format!(r"^\s*{}\.url = ", regex::escape(input_name))).unwrap();
remove_from_section(
content,
"# [nixy:custom-inputs]",
"# [/nixy:custom-inputs]",
&input_pattern,
)
}
fn remove_unused_overlay(content: &str, overlay_name: &str) -> String {
let input_pattern = format!(r"{}\.url\s*=", regex::escape(overlay_name));
if !Regex::new(&input_pattern)
.map(|r| r.is_match(content))
.unwrap_or(false)
{
return content.to_string();
}
let overlay_pattern = Regex::new(&format!(
r"^\s*{}\.overlays\.[a-zA-Z0-9_-]+",
regex::escape(overlay_name)
))
.unwrap();
let content = remove_from_section(
content,
"# [nixy:local-overlays]",
"# [/nixy:local-overlays]",
&overlay_pattern,
);
let overlay_usage = format!(r"{}\.overlays", regex::escape(overlay_name));
if Regex::new(&overlay_usage)
.map(|r| r.is_match(&content))
.unwrap_or(false)
{
return content;
}
let input_pattern =
Regex::new(&format!(r"^\s*{}\.url = ", regex::escape(overlay_name))).unwrap();
let content = remove_from_section(
&content,
"# [nixy:local-inputs]",
"# [/nixy:local-inputs]",
&input_pattern,
);
remove_from_outputs_signature(&content, overlay_name)
}
fn remove_from_outputs_signature(content: &str, input_name: &str) -> String {
let sig_re = match Regex::new(r"(?s)(outputs\s*=\s*\{)([^}]*)\}(@inputs\s*:)") {
Ok(r) => r,
Err(_) => return content.to_string(),
};
let caps = match sig_re.captures(content) {
Some(c) => c,
None => return content.to_string(),
};
let full_match = caps.get(0).unwrap();
let before_brace = caps.get(1).unwrap().as_str();
let params = caps.get(2).unwrap().as_str();
let after_sig = caps.get(3).unwrap().as_str();
let mut kept_params: Vec<String> = Vec::new();
for raw_part in params.split(',') {
let trimmed = raw_part.trim();
if trimmed.is_empty() || trimmed == input_name {
continue;
}
kept_params.push(trimmed.to_string());
}
let new_params = kept_params.join(", ");
let mut result = String::with_capacity(content.len());
result.push_str(&content[..full_match.start()]);
result.push_str(before_brace);
result.push_str(&new_params);
result.push('}');
result.push_str(after_sig);
result.push_str(&content[full_match.end()..]);
result
}
fn git_rm(dir: &std::path::Path, file: &str) {
let is_git_repo = dir.join(".git").exists()
|| Command::new("git")
.args(["-C", &dir.to_string_lossy(), "rev-parse", "--git-dir"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if is_git_repo {
let _ = Command::new("git")
.args(["-C", &dir.to_string_lossy(), "rm", "--cached", file])
.output();
}
}
fn git_rm_recursive(dir: &std::path::Path, path: &str) {
let is_git_repo = dir.join(".git").exists()
|| Command::new("git")
.args(["-C", &dir.to_string_lossy(), "rev-parse", "--git-dir"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if is_git_repo {
let _ = Command::new("git")
.args(["-C", &dir.to_string_lossy(), "rm", "-r", "--cached", path])
.output();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_find_input_for_package() {
let content = r#"
# [nixy:custom-packages]
neovim = inputs.github-nix-community-neovim-nightly-overlay.packages.${system}.neovim;
# [/nixy:custom-packages]
"#;
let result = find_input_for_package(content, "neovim");
assert_eq!(
result,
Some("github-nix-community-neovim-nightly-overlay".to_string())
);
}
#[test]
fn test_find_input_for_package_not_found() {
let content = r#"
# [nixy:packages]
hello = pkgs.hello;
# [/nixy:packages]
"#;
let result = find_input_for_package(content, "hello");
assert_eq!(result, None);
}
#[test]
fn test_find_input_for_package_different_package() {
let content = r#"
# [nixy:custom-packages]
neovim = inputs.neovim-overlay.packages.${system}.neovim;
# [/nixy:custom-packages]
"#;
let result = find_input_for_package(content, "vim");
assert_eq!(result, None);
}
#[test]
fn test_remove_unused_input_removes_when_not_used() {
let content = r#"# [nixy:custom-inputs]
neovim-overlay.url = "github:nix-community/neovim-nightly-overlay";
# [/nixy:custom-inputs]
# [nixy:custom-packages]
# [/nixy:custom-packages]
"#;
let result = remove_unused_input(content, "neovim-overlay");
assert!(!result.contains("neovim-overlay.url"));
}
#[test]
fn test_remove_unused_input_keeps_when_still_used() {
let content = r#"# [nixy:custom-inputs]
neovim-overlay.url = "github:nix-community/neovim-nightly-overlay";
# [/nixy:custom-inputs]
# [nixy:custom-packages]
neovim = inputs.neovim-overlay.packages.${system}.neovim;
# [/nixy:custom-packages]
"#;
let result = remove_unused_input(content, "neovim-overlay");
assert!(result.contains("neovim-overlay.url"));
}
#[test]
fn test_remove_unused_input_keeps_other_inputs() {
let content = r#"# [nixy:custom-inputs]
neovim-overlay.url = "github:nix-community/neovim-nightly-overlay";
other-input.url = "github:foo/bar";
# [/nixy:custom-inputs]
# [nixy:custom-packages]
foo = inputs.other-input.packages.${system}.foo;
# [/nixy:custom-packages]
"#;
let result = remove_unused_input(content, "neovim-overlay");
assert!(!result.contains("neovim-overlay.url"));
assert!(result.contains("other-input.url"));
}
#[test]
fn test_remove_unused_overlay() {
let content = r#"outputs = { self, nixpkgs, neovim-nightly-overlay, ... }@inputs:
# [nixy:local-inputs]
neovim-nightly-overlay.url = "github:nix-community/neovim-nightly-overlay";
# [/nixy:local-inputs]
overlays = [
# [nixy:local-overlays]
neovim-nightly-overlay.overlays.default
# [/nixy:local-overlays]
];
"#;
let result = remove_unused_overlay(content, "neovim-nightly-overlay");
assert!(!result.contains("neovim-nightly-overlay.overlays"));
assert!(!result.contains("neovim-nightly-overlay.url"));
assert!(!result.contains("neovim-nightly-overlay"));
assert!(result.contains("self, nixpkgs, ..."));
assert!(result.contains("}@inputs:"));
}
#[test]
fn test_remove_unused_overlay_keeps_input_if_still_used() {
let content = r#"# [nixy:local-inputs]
neovim-nightly-overlay.url = "github:nix-community/neovim-nightly-overlay";
# [/nixy:local-inputs]
overlays = [
# [nixy:local-overlays]
neovim-nightly-overlay.overlays.default
# [/nixy:local-overlays]
];
# Another reference outside local-overlays
other-pkg = neovim-nightly-overlay.overlays.something;
"#;
let result = remove_unused_overlay(content, "neovim-nightly-overlay");
assert!(!result.contains(" neovim-nightly-overlay.overlays.default"));
assert!(result.contains("neovim-nightly-overlay.url"));
}
#[test]
fn test_remove_unused_overlay_no_overlay() {
let content = r#"# [nixy:local-inputs]
gke-plugin.url = "path:./packages/gke";
# [/nixy:local-inputs]
"#;
let result = remove_unused_overlay(content, "neovim-nightly-overlay");
assert_eq!(result, content);
}
#[test]
fn test_remove_from_outputs_signature() {
let content = r#"outputs = { self, nixpkgs, gke-plugin, neovim-nightly-overlay }@inputs:"#;
let result = remove_from_outputs_signature(content, "neovim-nightly-overlay");
assert!(!result.contains("neovim-nightly-overlay"));
assert!(result.contains("gke-plugin"));
assert!(result.contains("nixpkgs"));
}
#[test]
fn test_remove_from_outputs_signature_middle() {
let content = r#"outputs = { self, nixpkgs, neovim-nightly-overlay, gke-plugin }@inputs:"#;
let result = remove_from_outputs_signature(content, "neovim-nightly-overlay");
assert!(!result.contains("neovim-nightly-overlay"));
assert!(result.contains("gke-plugin"));
}
#[test]
fn test_remove_from_outputs_signature_only_custom_input() {
let content = r#"outputs = { self, nixpkgs, my-input }@inputs:"#;
let result = remove_from_outputs_signature(content, "my-input");
assert!(!result.contains("my-input"));
assert!(result.contains("self"));
assert!(result.contains("nixpkgs"));
assert!(!result.contains("nixpkgs,}") && !result.contains("nixpkgs, }"));
assert!(result.contains("}@inputs:"));
}
#[test]
fn test_remove_from_outputs_signature_first_custom() {
let content = r#"outputs = { self, nixpkgs, first-input, second-input }@inputs:"#;
let result = remove_from_outputs_signature(content, "first-input");
assert!(!result.contains("first-input"));
assert!(result.contains("second-input"));
assert!(result.contains("self, nixpkgs, second-input"));
}
#[test]
fn test_remove_from_outputs_signature_with_ellipsis() {
let content = r#"outputs = { self, nixpkgs, my-input, ... }@inputs:"#;
let result = remove_from_outputs_signature(content, "my-input");
assert!(!result.contains("my-input"));
assert!(result.contains("..."));
assert!(result.contains("self, nixpkgs, ..."));
}
}