use anyhow::{bail, Context, Result};
use colored::Colorize;
use flk::flake::parsers::overlays::add_pinned_package;
use std::{fs, path};
use crate::nix::run_nix_command;
use flk::flake::parsers::{packages::parse_packages_section, utils::resolve_profile};
use flk::utils::visual::with_spinner;
pub fn run_add(
package: &str,
version: Option<String>,
target_profile: Option<String>,
) -> Result<()> {
let profile = resolve_profile(target_profile)?;
let flake_path = path::Path::new(".flk/profiles/").join(format!("{}.nix", profile));
let flake_content = fs::read_to_string(&flake_path).with_context(|| {
format!(
"Failed to read profile file at '{}'. Have you run 'flk init'?",
flake_path.display()
)
})?;
let section = parse_packages_section(&flake_content).with_context(|| {
format!(
"Failed to parse packages section in profile file '{}'",
flake_path.display()
)
})?;
if package.trim().is_empty() {
bail!("Package name cannot be empty");
}
with_spinner("Validating package...", || validate_package_exists(package))?;
let (package_to_add, package_pin) = if let Some(ver) = &version {
println!(
"{} Adding package: {} (pinned to version {})",
"→".blue().bold(),
package.green(),
ver.yellow()
);
let full_pin = with_spinner("Fetching nixpkgs pin for the package...", || {
get_nix_package_pin_full(package, ver)
})?;
(format!("pkgs.\"{}@{}\"", package, ver), Some(full_pin))
} else {
println!("{} Adding package: {}", "→".blue().bold(), package.green());
(format!("pkgs.{}", package), None)
};
if section.package_exists(&package_to_add) {
bail!(
"Package '{}' is already in the packages declaration",
package_to_add
);
}
if let Some(pin) = &package_pin {
println!(
"{} Package will be pinned to nixpkgs: {}",
"→".blue().bold(),
pin.full_ref.yellow()
);
let pins_path = ".flk/pins.nix";
let pins_content = fs::read_to_string(pins_path).context("Failed to read pins.nix file")?;
let updated_pins = add_pinned_package(
&pins_content,
&pin.hash,
&pin.full_ref,
package,
version
.as_deref()
.expect("version must be Some when package_pin is Some"),
)
.context("Failed to add pinned package to pins.nix")?;
fs::write(pins_path, updated_pins).context("Failed to write updated pins.nix")?;
}
let updated_content = section.add_package(&flake_content, &package_to_add, None);
fs::write(flake_path, updated_content).context("Failed to write flake.nix")?;
println!(
"{} Package '{}' added successfully!",
"✓".green().bold(),
package_to_add
);
Ok(())
}
struct PinInfo {
hash: String,
full_ref: String,
}
fn get_nix_package_pin_full(package: &str, version: &str) -> Result<PinInfo> {
let package_with_version = format!("{}@{}", package, version);
let (stdout, stderr, success) = run_nix_command(&[
"run",
"github:vic/nix-versions",
&package_with_version,
"--",
"--one",
])
.context("Failed to execute nix eval")?;
if !success || stderr.contains("no packages found") {
bail!(
"Package {} does not exist or is marked as insecure. Aborting",
package
);
}
let stdout = stdout
.lines()
.find(|line| line.contains("nixpkgs"))
.context("Failed to find nixpkgs pin in nix search output")?;
let pin = stdout
.split_whitespace()
.nth(2)
.context("Failed to extract nixpkgs pin value")
.map(|s| s.trim().to_string())?;
let pin_hash = pin
.split('/')
.nth(1)
.and_then(|s| s.split('#').next())
.context("Failed to extract nixpkgs pin from output")?
.trim()
.to_string();
let pin_ref = format!("github:NixOS/nixpkgs/{}", pin_hash);
Ok(PinInfo {
hash: pin_hash,
full_ref: pin_ref,
})
}
fn validate_package_exists(package: &str) -> Result<()> {
let (_, stderr, success) = run_nix_command(&["run", "github:vic/nix-versions", package])
.context("Failed to execute nix eval")?;
if !success || stderr.contains("no packages found") {
bail!(
"Package {} does not exist or is marked as insecure. Aborting",
package
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::cwd_test_guard;
use crate::nix::with_nix_runner;
use tempfile::TempDir;
const PINS_NIX: &str = r#"{
sources = {
stable = "github:NixOS/nixpkgs/nixos-25.05";
};
pinnedPackages = {
};
}
"#;
fn setup_project(rust_packages: &str, with_pins: bool) -> TempDir {
let tmp = TempDir::new().unwrap();
let flk = tmp.path().join(".flk");
std::fs::create_dir_all(flk.join("profiles")).unwrap();
std::fs::write(flk.join("default.nix"), "{ defaultShell = \"rust\"; }\n").unwrap();
std::fs::write(
flk.join("profiles").join("rust.nix"),
format!(
"{{pkgs, ...}}: {{\n packages = [\n{} ];\n}}\n",
rust_packages
),
)
.unwrap();
if with_pins {
std::fs::write(flk.join("pins.nix"), PINS_NIX).unwrap();
}
std::env::set_current_dir(tmp.path()).unwrap();
tmp
}
fn validate_ok(args: &[&str]) -> Result<(String, String, bool)> {
let _ = args;
Ok((String::new(), String::new(), true))
}
#[test]
fn rejects_empty_package_name() {
let _guard = cwd_test_guard();
let _tmp = setup_project(" pkgs.fd\n", false);
let err = with_nix_runner(validate_ok, || {
run_add(" ", None, Some("rust".into())).unwrap_err()
});
assert!(err.to_string().contains("empty"), "got: {err}");
}
#[test]
fn rejects_package_not_in_nixpkgs() {
let _guard = cwd_test_guard();
let _tmp = setup_project(" pkgs.fd\n", false);
let err = with_nix_runner(
|_| Ok((String::new(), "no packages found".into(), false)),
|| run_add("does-not-exist", None, Some("rust".into())).unwrap_err(),
);
assert!(err.to_string().contains("does not exist"), "got: {err}");
}
#[test]
fn adds_unversioned_package_to_profile() {
let _guard = cwd_test_guard();
let tmp = setup_project(" pkgs.fd\n", false);
with_nix_runner(validate_ok, || {
run_add("ripgrep", None, Some("rust".into())).unwrap()
});
let after = std::fs::read_to_string(tmp.path().join(".flk/profiles/rust.nix")).unwrap();
assert!(after.contains("pkgs.ripgrep"));
assert!(after.contains("pkgs.fd"));
}
#[test]
fn adds_versioned_package_and_updates_pins() {
let _guard = cwd_test_guard();
let tmp = setup_project(" pkgs.fd\n", true);
with_nix_runner(
|args| match args {
["run", "github:vic/nix-versions", "ripgrep"] => {
Ok((String::new(), String::new(), true))
}
["run", "github:vic/nix-versions", "ripgrep@14.1.0", "--", "--one"] => Ok((
"ripgrep 14.1.0 flake:nixpkgs/abc123def456#ripgrep description\n".into(),
String::new(),
true,
)),
other => panic!("unexpected nix args: {other:?}"),
},
|| run_add("ripgrep", Some("14.1.0".into()), Some("rust".into())).unwrap(),
);
let profile = std::fs::read_to_string(tmp.path().join(".flk/profiles/rust.nix")).unwrap();
assert!(
profile.contains("pkgs.\"ripgrep@14.1.0\""),
"profile missing versioned entry:\n{profile}"
);
let pins = std::fs::read_to_string(tmp.path().join(".flk/pins.nix")).unwrap();
assert!(
pins.contains("abc123def456"),
"pins.nix missing pin hash:\n{pins}"
);
assert!(
pins.contains("ripgrep@14.1.0"),
"pins.nix missing package alias:\n{pins}"
);
}
#[test]
fn versioned_add_errors_when_pin_missing_from_output() {
let _guard = cwd_test_guard();
let _tmp = setup_project(" pkgs.fd\n", true);
let err = with_nix_runner(
|args| match args {
["run", "github:vic/nix-versions", "ripgrep"] => {
Ok((String::new(), String::new(), true))
}
_ => Ok((
"ripgrep 14.1.0 something-else description\n".into(),
String::new(),
true,
)),
},
|| run_add("ripgrep", Some("14.1.0".into()), Some("rust".into())).unwrap_err(),
);
assert!(
err.to_string().contains("nixpkgs pin"),
"expected pin-extraction error, got: {err}"
);
}
}