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, regenerate_flake_from_profile};
use crate::nix::Nix;
use crate::nixhub::{parse_package_spec, NixhubClient};
use crate::nixy_config::{nixy_json_exists, NixyConfig};
use crate::profile::get_flake_dir;
use crate::rollback::{self, RollbackContext};
use crate::state::{
get_state_path, normalize_platforms, CustomPackage, PackageState, ResolvedNixpkgPackage,
};
use super::{info, success, warn};
pub fn run(config: &Config, args: InstallArgs) -> Result<()> {
let platforms = if args.platform.is_empty() {
None
} else {
Some(normalize_platforms(&args.platform).map_err(Error::Usage)?)
};
if let Some(file) = args.file {
if platforms.is_some() {
return Err(Error::Usage(
"--platform is not supported with --file".to_string(),
));
}
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, platforms);
}
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);
if nixy_json_exists(config) {
return install_with_nixy_config(
config,
&pkg_spec.name,
pkg_spec.version.as_deref(),
platforms,
);
}
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(),
platforms: platforms.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::legacy(
flake_dir.clone(),
state_path.clone(),
original_state.clone(),
None,
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_with_nixy_config(
config: &Config,
name: &str,
version: Option<&str>,
platforms: Option<Vec<String>>,
) -> Result<()> {
let mut nixy_config = NixyConfig::load(config)?;
let active_profile = nixy_config.active_profile.clone();
{
let profile = nixy_config
.get_active_profile()
.ok_or_else(|| Error::ProfileNotFound(active_profile.clone()))?;
if profile.has_package(name) {
success(&format!("Package '{}' is already installed", name));
return Ok(());
}
}
let version_display = version.unwrap_or("latest");
info(&format!(
"Resolving {}@{} via Nixhub...",
name, version_display
));
let client = NixhubClient::new();
let resolved = client.resolve_for_current_system(name, version.unwrap_or("latest"))?;
info(&format!(
"Found {} version {} (commit {})",
resolved.name,
resolved.version,
&resolved.commit_hash[..8.min(resolved.commit_hash.len())]
));
let original_config = nixy_config.clone();
{
let profile = nixy_config
.get_active_profile_mut()
.ok_or_else(|| Error::ProfileNotFound(active_profile.clone()))?;
profile.add_resolved_package(ResolvedNixpkgPackage {
name: resolved.name.clone(),
version_spec: version.map(String::from),
resolved_version: resolved.version.clone(),
attribute_path: resolved.attribute_path.clone(),
commit_hash: resolved.commit_hash.clone(),
platforms: platforms.clone(),
});
}
nixy_config.save(config)?;
let flake_dir = get_flake_dir(config)?;
let global_packages_dir = if config.global_packages_dir.exists() {
Some(config.global_packages_dir.as_path())
} else {
None
};
let profile_for_flake = nixy_config.get_active_profile().unwrap();
if let Err(e) =
regenerate_flake_from_profile(&flake_dir, profile_for_flake, global_packages_dir)
{
original_config.save(config)?;
warn("Failed to regenerate flake.nix. Reverted changes.");
return Err(e);
}
rollback::set_context(RollbackContext::nixy_config(
flake_dir.clone(),
config.nixy_json.clone(),
original_config.clone(),
global_packages_dir,
));
info(&format!(
"Installing {}@{}...",
resolved.name, resolved.version
));
if let Err(e) = super::sync::run(config) {
rollback::clear_context();
original_config.save(config)?;
let original_profile = original_config.get_active_profile().unwrap();
let _ = regenerate_flake_from_profile(&flake_dir, original_profile, global_packages_dir);
warn("Sync failed. Reverted changes.");
return Err(e);
}
rollback::clear_context();
Ok(())
}
fn install_from_registry(
config: &Config,
from_arg: &str,
pkg: &str,
platforms: Option<Vec<String>>,
) -> Result<()> {
if nixy_json_exists(config) {
return install_from_registry_with_nixy_config(config, from_arg, pkg, platforms);
}
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,
platforms,
});
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::legacy(
flake_dir.clone(),
state_path.clone(),
original_state.clone(),
None,
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_registry_with_nixy_config(
config: &Config,
from_arg: &str,
pkg: &str,
platforms: Option<Vec<String>>,
) -> Result<()> {
let mut nixy_config = NixyConfig::load(config)?;
let active_profile = nixy_config.active_profile.clone();
{
let profile = nixy_config
.get_active_profile()
.ok_or_else(|| Error::ProfileNotFound(active_profile.clone()))?;
if profile.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_config = nixy_config.clone();
{
let profile = nixy_config
.get_active_profile_mut()
.ok_or_else(|| Error::ProfileNotFound(active_profile.clone()))?;
profile.add_custom_package(CustomPackage {
name: pkg.to_string(),
input_name: input_name.clone(),
input_url: flake_url,
package_output: pkg_output,
source_name: None,
platforms,
});
}
nixy_config.save(config)?;
let flake_dir = get_flake_dir(config)?;
let global_packages_dir = if config.global_packages_dir.exists() {
Some(config.global_packages_dir.as_path())
} else {
None
};
let profile_for_flake = nixy_config.get_active_profile().unwrap();
if let Err(e) =
regenerate_flake_from_profile(&flake_dir, profile_for_flake, global_packages_dir)
{
original_config.save(config)?;
warn("Failed to regenerate flake.nix. Reverted changes.");
return Err(e);
}
rollback::set_context(RollbackContext::nixy_config(
flake_dir.clone(),
config.nixy_json.clone(),
original_config.clone(),
global_packages_dir,
));
info(&format!("Installing {} from {}...", pkg, input_name));
if let Err(e) = super::sync::run(config) {
rollback::clear_context();
original_config.save(config)?;
let original_profile = original_config.get_active_profile().unwrap();
let _ = regenerate_flake_from_profile(&flake_dir, original_profile, global_packages_dir);
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::legacy(
flake_dir.clone(),
state_path.clone(),
original_state.clone(),
Some(dest.clone()),
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::legacy(
flake_dir.clone(),
state_path.clone(),
original_state.clone(),
None,
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;"));
}
}