use std::fs;
use std::path::Path;
use std::process::Command;
use crate::cli::InstallArgs;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::flake::is_flake_file;
use crate::flake::parser::parse_local_package_attr;
use crate::flake::template::regenerate_flake;
use crate::nix::Nix;
use crate::nixhub::{parse_package_spec, NixhubClient};
use crate::profile::get_flake_dir;
use crate::rollback::{self, RollbackContext};
use crate::state::{get_state_path, CustomPackage, PackageState, ResolvedNixpkgPackage};
use super::{info, success, warn};
pub fn run(config: &Config, args: InstallArgs) -> Result<()> {
if let Some(file) = args.file {
return install_from_file(config, &file);
}
if let Some(from) = args.from {
let pkg = args
.package
.ok_or_else(|| Error::Usage("Package name is required with --from".to_string()))?;
return install_from_registry(config, &from, &pkg);
}
let pkg_spec_str = args.package.ok_or_else(|| {
Error::Usage(
"Usage: nixy install <package>[@version] or nixy install --file <path> or nixy install --from <registry> <package>".to_string(),
)
})?;
let pkg_spec = parse_package_spec(&pkg_spec_str);
let flake_dir = get_flake_dir(config)?;
let state_path = get_state_path(&flake_dir);
let mut state = PackageState::load(&state_path)?;
if state.has_package(&pkg_spec.name) {
success(&format!("Package '{}' is already installed", pkg_spec.name));
return Ok(());
}
let version_display = pkg_spec.version.as_deref().unwrap_or("latest");
info(&format!(
"Resolving {}@{} via Nixhub...",
pkg_spec.name, version_display
));
let client = NixhubClient::new();
let resolved = client.resolve_for_current_system(
&pkg_spec.name,
pkg_spec.version.as_deref().unwrap_or("latest"),
)?;
info(&format!(
"Found {} version {} (commit {})",
resolved.name,
resolved.version,
&resolved.commit_hash[..8.min(resolved.commit_hash.len())]
));
let original_state = state.clone();
state.add_resolved_package(ResolvedNixpkgPackage {
name: resolved.name.clone(),
version_spec: pkg_spec.version.clone(),
resolved_version: resolved.version.clone(),
attribute_path: resolved.attribute_path.clone(),
commit_hash: resolved.commit_hash.clone(),
});
state.save(&state_path)?;
if let Err(e) = regenerate_flake(&flake_dir, &state) {
original_state.save(&state_path)?;
warn("Failed to regenerate flake.nix. Reverted changes.");
return Err(e);
}
rollback::set_context(RollbackContext {
flake_dir: flake_dir.clone(),
state_path: state_path.clone(),
original_state: original_state.clone(),
copied_file: None,
created_dir: None,
});
info(&format!(
"Installing {}@{}...",
resolved.name, resolved.version
));
if let Err(e) = super::sync::run(config) {
rollback::clear_context();
original_state.save(&state_path)?;
let _ = regenerate_flake(&flake_dir, &original_state);
warn("Sync failed. Reverted changes.");
return Err(e);
}
rollback::clear_context();
Ok(())
}
fn install_from_registry(config: &Config, from_arg: &str, pkg: &str) -> Result<()> {
let flake_dir = get_flake_dir(config)?;
let state_path = get_state_path(&flake_dir);
let mut state = PackageState::load(&state_path)?;
if state.has_package(pkg) {
success(&format!("Package '{}' is already installed", pkg));
return Ok(());
}
let flake_url = if from_arg.contains(':') {
info(&format!("Using flake URL: {}", from_arg));
from_arg.to_string()
} else {
info(&format!("Looking up '{}' in nix registry...", from_arg));
let url = Nix::registry_lookup(from_arg)?
.ok_or_else(|| Error::RegistryNotFound(from_arg.to_string()))?;
info(&format!("Found: {}", url));
url
};
let input_name = if !from_arg.contains(':') {
sanitize_input_name(from_arg)
} else {
derive_input_name_from_url(&flake_url)
};
info(&format!(
"Validating package '{}' in {}...",
pkg, input_name
));
let pkg_output = Nix::validate_flake_package(&flake_url, pkg)?.ok_or_else(|| {
let available = Nix::list_flake_packages(&flake_url, None)
.unwrap_or_default()
.into_iter()
.take(10)
.collect::<Vec<_>>()
.join(" ");
if available.is_empty() {
Error::FlakePackageNotFound(pkg.to_string(), input_name.clone())
} else {
Error::Usage(format!(
"Package '{}' not found in '{}'. Available packages: {}...",
pkg, input_name, available
))
}
})?;
let original_state = state.clone();
let (final_input_name, final_url) = (input_name.clone(), flake_url.clone());
state.add_custom_package(CustomPackage {
name: pkg.to_string(),
input_name: final_input_name.clone(),
input_url: final_url,
package_output: pkg_output,
source_name: None,
});
state.save(&state_path)?;
if let Err(e) = regenerate_flake(&flake_dir, &state) {
original_state.save(&state_path)?;
warn("Failed to regenerate flake.nix. Reverted changes.");
return Err(e);
}
rollback::set_context(RollbackContext {
flake_dir: flake_dir.clone(),
state_path: state_path.clone(),
original_state: original_state.clone(),
copied_file: None,
created_dir: None,
});
info(&format!("Installing {} from {}...", pkg, final_input_name));
if let Err(e) = super::sync::run(config) {
rollback::clear_context();
original_state.save(&state_path)?;
let _ = regenerate_flake(&flake_dir, &original_state);
warn("Sync failed. Reverted changes.");
return Err(e);
}
rollback::clear_context();
Ok(())
}
fn install_from_file(config: &Config, file: &Path) -> Result<()> {
if !file.exists() {
return Err(Error::FileNotFound(file.display().to_string()));
}
if is_flake_file(file) {
return install_from_flake_file(config, file);
}
let content = fs::read_to_string(file)?;
let pkg_name = parse_local_package_attr(&content, "pname")
.or_else(|| parse_local_package_attr(&content, "name"))
.ok_or_else(|| Error::NoPackageName(file.display().to_string()))?;
let flake_dir = get_flake_dir(config)?;
let state_path = get_state_path(&flake_dir);
let state = PackageState::load(&state_path)?;
if state.has_package(&pkg_name) {
success(&format!("Package '{}' is already installed", pkg_name));
return Ok(());
}
info(&format!(
"Installing local package: {} from {}",
pkg_name,
file.display()
));
let original_state = state.clone();
let pkg_dir = flake_dir.join("packages");
fs::create_dir_all(&pkg_dir)?;
let dest = pkg_dir.join(format!("{}.nix", pkg_name));
fs::copy(file, &dest)?;
success(&format!("Copied package definition to {}", dest.display()));
git_add(&flake_dir, &format!("packages/{}.nix", pkg_name));
if let Err(e) = regenerate_flake(&flake_dir, &state) {
let _ = fs::remove_file(&dest);
return Err(e);
}
rollback::set_context(RollbackContext {
flake_dir: flake_dir.clone(),
state_path: state_path.clone(),
original_state: original_state.clone(),
copied_file: Some(dest.clone()),
created_dir: None,
});
info(&format!("Installing {}...", pkg_name));
if let Err(e) = super::sync::run(config) {
rollback::clear_context();
original_state.save(&state_path)?;
let _ = fs::remove_file(&dest);
regenerate_flake(&flake_dir, &original_state)?;
warn("Sync failed. Reverted changes.");
return Err(e);
}
rollback::clear_context();
Ok(())
}
fn install_from_flake_file(config: &Config, file: &Path) -> Result<()> {
let pkg_name = file
.file_stem()
.and_then(|s| s.to_str())
.map(sanitize_input_name)
.ok_or_else(|| Error::InvalidFilename(file.display().to_string()))?;
if pkg_name.is_empty() {
return Err(Error::InvalidFilename(file.display().to_string()));
}
let flake_dir = get_flake_dir(config)?;
let state_path = get_state_path(&flake_dir);
let state = PackageState::load(&state_path)?;
if state.has_package(&pkg_name) {
success(&format!("Package '{}' is already installed", pkg_name));
return Ok(());
}
info(&format!(
"Installing local flake: {} from {}",
pkg_name,
file.display()
));
let original_state = state.clone();
let pkg_dir = flake_dir.join("packages").join(&pkg_name);
fs::create_dir_all(&pkg_dir)?;
let dest = pkg_dir.join("flake.nix");
fs::copy(file, &dest)?;
success(&format!("Copied flake to {}", dest.display()));
git_add(&flake_dir, &format!("packages/{}/flake.nix", pkg_name));
if let Err(e) = regenerate_flake(&flake_dir, &state) {
let _ = fs::remove_dir_all(&pkg_dir);
return Err(e);
}
rollback::set_context(RollbackContext {
flake_dir: flake_dir.clone(),
state_path: state_path.clone(),
original_state: original_state.clone(),
copied_file: None,
created_dir: Some(pkg_dir.clone()),
});
info(&format!("Installing {}...", pkg_name));
if let Err(e) = super::sync::run(config) {
rollback::clear_context();
original_state.save(&state_path)?;
let _ = fs::remove_dir_all(&pkg_dir);
regenerate_flake(&flake_dir, &original_state)?;
warn("Sync failed. Reverted changes.");
return Err(e);
}
rollback::clear_context();
Ok(())
}
fn sanitize_input_name(s: &str) -> String {
let sanitized: String = s
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect();
sanitized.trim_matches('-').to_string()
}
fn derive_input_name_from_url(url: &str) -> String {
let parts: Vec<&str> = url.split('/').collect();
if parts.len() >= 2 {
let owner = parts[parts.len() - 2];
let repo = parts[parts.len() - 1].trim_end_matches(".git");
sanitize_input_name(&format!("{}-{}", owner, repo))
} else {
"custom-flake".to_string()
}
}
fn git_add(dir: &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(), "add", file])
.output();
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_sanitize_input_name() {
assert_eq!(sanitize_input_name("nixpkgs"), "nixpkgs");
assert_eq!(sanitize_input_name("foo-bar"), "foo-bar");
assert_eq!(sanitize_input_name("foo_bar"), "foo-bar");
assert_eq!(sanitize_input_name("foo/bar"), "foo-bar");
assert_eq!(sanitize_input_name("--foo--"), "foo");
}
#[test]
fn test_derive_input_name_from_url() {
assert_eq!(
derive_input_name_from_url("github:NixOS/nixpkgs"),
"github-NixOS-nixpkgs"
);
assert_eq!(
derive_input_name_from_url("github:user/repo.git"),
"github-user-repo"
);
}
#[test]
fn test_regenerate_flake() {
let temp = TempDir::new().unwrap();
let flake_dir = temp.path();
let mut state = PackageState::default();
state.add_package("hello");
regenerate_flake(flake_dir, &state).unwrap();
let flake_path = flake_dir.join("flake.nix");
assert!(flake_path.exists());
let content = fs::read_to_string(&flake_path).unwrap();
assert!(content.contains("hello = pkgs.hello;"));
}
}