flk 0.6.3

A CLI tool for managing flake.nix devShell environments
Documentation
//! # Add Command Handler
//!
//! Add packages to the development environment with optional version pinning.

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;

/// Add a package to the development environment.
///
/// Validates the package exists in nixpkgs, then writes it to the active
/// profile. When `--version` is specified, also pins the package in `.flk/pins.nix`.
///
/// # Arguments
///
/// * `package` - Package name to add (e.g., "ripgrep", "nodejs")
/// * `version` - Optional version to pin (e.g., "15.1.0")
/// * `target_profile` - Optional profile override
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)
    };

    // Check if package already exists
    if section.package_exists(&package_to_add) {
        bail!(
            "Package '{}' is already in the packages declaration",
            package_to_add
        );
    }

    // Handle pinning (sources + pinnedPackages in pins.nix)

    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")?;
    }

    // Add the package to buildInputs
    let updated_content = section.add_package(&flake_content, &package_to_add, None);

    // Write back to file
    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
        );
    }

    // extract the nixpkgs pin from the stdout
    let stdout = stdout
        .lines()
        .find(|line| line.contains("nixpkgs"))
        .context("Failed to find nixpkgs pin in nix search output")?;

    // get the pin value
    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;

    /// Minimal pins.nix matching the layout the `overlays` parser expects.
    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
    }

    /// `nix-versions` for `validate_package_exists` only cares about success+stderr.
    fn validate_ok(args: &[&str]) -> Result<(String, String, bool)> {
        // The validate call is exactly: nix run github:vic/nix-versions <pkg>
        // The pin lookup adds `--` `--one`. Both should succeed by default;
        // tests that need a richer pin response override beforehand.
        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}");
    }

    // NOTE: a duplicate-detection test belongs here, but `add.rs` calls
    // `section.package_exists(&package_to_add)` with the `pkgs.`-prefixed name,
    // while `PackagesSection::package_exists` expects the bare name. The
    // duplicate is silently re-added. Fixing that is a behavior change out of
    // scope for this commit — leaving the test out rather than asserting buggy
    // behavior.

    #[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);

        // First call (validate): bare `nix run … ripgrep`.
        // Second call (pin lookup): `nix run … ripgrep@14.1.0 -- --one`.
        // The pin-lookup output's third whitespace-separated token must look
        // like `<anything>/<hash>#<anything>` for the extractor to succeed.
        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))
                }
                // Pin-lookup output that contains no line mentioning "nixpkgs".
                _ => 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}"
        );
    }
}