use std::fs;
use std::path::Path;
use regex::Regex;
use crate::error::Result;
use crate::state::{get_state_path, CustomPackage, PackageState};
pub fn needs_migration(profile_dir: &Path) -> bool {
let flake_path = profile_dir.join("flake.nix");
let state_path = get_state_path(profile_dir);
if state_path.exists() {
return false;
}
if !flake_path.exists() {
return false;
}
let content = match fs::read_to_string(&flake_path) {
Ok(c) => c,
Err(_) => return false,
};
content.contains("[nixy:packages]")
}
pub fn migrate(profile_dir: &Path) -> Result<()> {
let flake_path = profile_dir.join("flake.nix");
let state_path = get_state_path(profile_dir);
let content = fs::read_to_string(&flake_path)?;
let mut state = PackageState::default();
let standard_packages = extract_standard_packages(&content);
for pkg in standard_packages {
state.add_package(&pkg);
}
let custom_packages = extract_custom_packages(&content);
for pkg in custom_packages {
state.add_custom_package(pkg);
}
state.save(&state_path)?;
let content = crate::flake::template::generate_flake(&state, Some(profile_dir));
fs::write(&flake_path, content)?;
Ok(())
}
fn extract_standard_packages(content: &str) -> Vec<String> {
let mut packages = Vec::new();
let section = extract_marker_content(content, "nixy:packages");
for line in section.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.starts_with("//") {
continue;
}
if let Some(eq_pos) = trimmed.find('=') {
let name = trimmed[..eq_pos].trim();
let rhs = trimmed[eq_pos + 1..].trim();
let expected_rhs = format!("pkgs.{};", name);
if rhs == expected_rhs && is_valid_nix_identifier(name) {
packages.push(name.to_string());
}
}
}
packages
}
fn extract_custom_packages(content: &str) -> Vec<CustomPackage> {
let mut packages = Vec::new();
let section = extract_marker_content(content, "nixy:custom-packages");
let re = Regex::new(
r"^\s*([a-zA-Z0-9_-]+)\s*=\s*inputs\.([a-zA-Z0-9_-]+)\.(packages|legacyPackages)\.\$\{system\}\.([a-zA-Z0-9_-]+);",
);
if let Ok(re) = re {
for line in section.lines() {
if let Some(caps) = re.captures(line) {
let name = caps[1].to_string();
let input_name = caps[2].to_string();
let package_output = caps[3].to_string();
let source_pkg_name = caps[4].to_string();
let source_name = if name != source_pkg_name {
eprintln!(
"Warning: Migration: custom package '{}' is an alias for '{}'; preserving alias during migration.",
name, source_pkg_name
);
Some(source_pkg_name)
} else {
None
};
let input_url = match find_input_url(content, &input_name) {
Some(url) => url,
None => {
eprintln!(
"Warning: Migration: could not find URL for input '{}' used by custom package '{}'; skipping this package.",
input_name, name
);
continue;
}
};
packages.push(CustomPackage {
name,
input_name,
input_url,
package_output,
source_name,
});
}
}
}
packages
}
fn find_input_url(content: &str, input_name: &str) -> Option<String> {
let custom_inputs = extract_marker_content(content, "nixy:custom-inputs");
let local_inputs = extract_marker_content(content, "nixy:local-inputs");
let pattern = format!(r#"{}\.url\s*=\s*"([^"]+)""#, regex::escape(input_name));
let re = Regex::new(&pattern).ok()?;
for line in custom_inputs.lines() {
if let Some(caps) = re.captures(line) {
return Some(caps[1].to_string());
}
}
for line in local_inputs.lines() {
if let Some(caps) = re.captures(line) {
return Some(caps[1].to_string());
}
}
None
}
fn extract_marker_content(content: &str, marker: &str) -> String {
let start_marker = format!("# [{}]", marker);
let end_marker = format!("# [/{}]", marker);
let mut result = String::new();
let mut in_section = false;
for line in content.lines() {
if line.contains(&end_marker) {
in_section = false;
continue;
}
if in_section {
result.push_str(line);
result.push('\n');
}
if line.contains(&start_marker) {
in_section = true;
}
}
result
}
fn is_valid_nix_identifier(s: &str) -> bool {
let mut chars = s.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_needs_migration_no_flake() {
let temp = TempDir::new().unwrap();
assert!(!needs_migration(temp.path()));
}
#[test]
fn test_needs_migration_state_exists() {
let temp = TempDir::new().unwrap();
let flake_content = r#"{ # [nixy:packages] # [/nixy:packages] }"#;
fs::write(temp.path().join("flake.nix"), flake_content).unwrap();
fs::write(temp.path().join("packages.json"), "{}").unwrap();
assert!(!needs_migration(temp.path()));
}
#[test]
fn test_needs_migration_marker_flake() {
let temp = TempDir::new().unwrap();
let flake_content = r#"{
# [nixy:packages]
hello = pkgs.hello;
# [/nixy:packages]
}"#;
fs::write(temp.path().join("flake.nix"), flake_content).unwrap();
assert!(needs_migration(temp.path()));
}
#[test]
fn test_needs_migration_no_markers() {
let temp = TempDir::new().unwrap();
let flake_content = r#"{ outputs = {}; }"#;
fs::write(temp.path().join("flake.nix"), flake_content).unwrap();
assert!(!needs_migration(temp.path()));
}
#[test]
fn test_extract_standard_packages() {
let content = r#"
# [nixy:packages]
ripgrep = pkgs.ripgrep;
fzf = pkgs.fzf;
bat = pkgs.bat;
# [/nixy:packages]
"#;
let packages = extract_standard_packages(content);
assert_eq!(packages.len(), 3);
assert!(packages.contains(&"ripgrep".to_string()));
assert!(packages.contains(&"fzf".to_string()));
assert!(packages.contains(&"bat".to_string()));
}
#[test]
fn test_extract_standard_packages_ignores_custom() {
let content = r#"
# [nixy:packages]
hello = pkgs.hello;
custom = pkgs.other;
# [/nixy:packages]
"#;
let packages = extract_standard_packages(content);
assert_eq!(packages.len(), 1);
assert!(packages.contains(&"hello".to_string()));
}
#[test]
fn test_extract_custom_packages() {
let content = r#"
# [nixy:custom-inputs]
neovim-nightly.url = "github:nix-community/neovim-nightly-overlay";
# [/nixy:custom-inputs]
# [nixy:custom-packages]
neovim = inputs.neovim-nightly.packages.${system}.neovim;
# [/nixy:custom-packages]
"#;
let packages = extract_custom_packages(content);
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name, "neovim");
assert_eq!(packages[0].input_name, "neovim-nightly");
assert_eq!(
packages[0].input_url,
"github:nix-community/neovim-nightly-overlay"
);
assert_eq!(packages[0].package_output, "packages");
}
#[test]
fn test_extract_custom_packages_legacy_packages() {
let content = r#"
# [nixy:custom-inputs]
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixos-unstable";
# [/nixy:custom-inputs]
# [nixy:custom-packages]
hello = inputs.nixpkgs-unstable.legacyPackages.${system}.hello;
# [/nixy:custom-packages]
"#;
let packages = extract_custom_packages(content);
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].name, "hello");
assert_eq!(packages[0].package_output, "legacyPackages");
}
#[test]
fn test_migrate() {
let temp = TempDir::new().unwrap();
let flake_content = r#"{
# [nixy:custom-inputs]
neovim-nightly.url = "github:nix-community/neovim-nightly-overlay";
# [/nixy:custom-inputs]
# [nixy:packages]
ripgrep = pkgs.ripgrep;
fzf = pkgs.fzf;
# [/nixy:packages]
# [nixy:custom-packages]
neovim = inputs.neovim-nightly.packages.${system}.neovim;
# [/nixy:custom-packages]
}"#;
fs::write(temp.path().join("flake.nix"), flake_content).unwrap();
migrate(temp.path()).unwrap();
let state_path = get_state_path(temp.path());
let state = PackageState::load(&state_path).unwrap();
assert_eq!(state.packages.len(), 2);
assert!(state.packages.contains(&"ripgrep".to_string()));
assert!(state.packages.contains(&"fzf".to_string()));
assert_eq!(state.custom_packages.len(), 1);
assert_eq!(state.custom_packages[0].name, "neovim");
}
}