use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use super::parser::collect_local_packages;
use super::{LocalFlake, LocalPackage};
use crate::error::Result;
use crate::nixy_config::ProfileConfig;
use crate::state::{CustomPackage, PackageState, ResolvedNixpkgPackage};
struct PathEntry {
name: String,
platforms: Option<Vec<String>>,
}
struct FlakeBuilder {
inputs: String,
seen_inputs: HashSet<String>,
overlays: String,
standard_entries: String,
resolved_entries: String,
local_entries: String,
custom_entries: String,
buildenv_paths: Vec<PathEntry>,
}
impl FlakeBuilder {
fn new() -> Self {
Self {
inputs: String::new(),
seen_inputs: HashSet::new(),
overlays: String::new(),
standard_entries: String::new(),
resolved_entries: String::new(),
local_entries: String::new(),
custom_entries: String::new(),
buildenv_paths: Vec::new(),
}
}
fn add_standard_packages(&mut self, packages: &[&String]) {
let entries: Vec<String> = packages
.iter()
.map(|pkg| format!(" {} = pkgs.{};", pkg, pkg))
.collect();
if !entries.is_empty() {
self.standard_entries = format!("{}\n", entries.join("\n"));
}
self.buildenv_paths
.extend(packages.iter().map(|p| PathEntry {
name: p.to_string(),
platforms: None,
}));
}
fn add_resolved_packages(&mut self, packages: &[ResolvedNixpkgPackage]) {
if packages.is_empty() {
return;
}
let mut by_commit: HashMap<&str, Vec<&ResolvedNixpkgPackage>> = HashMap::new();
for pkg in packages {
by_commit.entry(&pkg.commit_hash).or_default().push(pkg);
}
for (commit, pkgs) in &by_commit {
let input_name = format!("nixpkgs-{}", &commit[..8.min(commit.len())]);
if self.seen_inputs.insert(input_name.clone()) {
self.inputs.push_str(&format!(
" {}.url = \"github:NixOS/nixpkgs/{}\";\n",
input_name, commit
));
}
for pkg in pkgs {
self.resolved_entries.push_str(&format!(
" {} = inputs.{}.legacyPackages.${{system}}.{};\n",
pkg.name, input_name, pkg.attribute_path
));
self.buildenv_paths.push(PathEntry {
name: pkg.name.clone(),
platforms: pkg.platforms.clone(),
});
}
}
}
fn add_local_flakes(&mut self, flakes: &[LocalFlake]) {
for flake in flakes {
self.inputs.push_str(&format!(
" {}.url = \"path:./packages/{}\";\n",
flake.name, flake.name
));
self.seen_inputs.insert(flake.name.clone());
self.local_entries.push_str(&format!(
" {} = inputs.{}.packages.${{system}}.default;\n",
flake.name, flake.name
));
self.buildenv_paths.push(PathEntry {
name: flake.name.clone(),
platforms: None,
});
}
}
fn add_local_flakes_with_absolute_paths(
&mut self,
flakes: &[LocalFlake],
packages_dir: Option<&Path>,
) {
for flake in flakes {
let path = if let Some(dir) = packages_dir {
let abs_path = dir.join(&flake.name);
let flake_nix_path = abs_path.join("flake.nix");
let resolved_path = if flake_nix_path.is_symlink() {
flake_nix_path
.canonicalize()
.ok()
.and_then(|p| p.parent().map(|parent| parent.to_path_buf()))
.unwrap_or_else(|| abs_path.canonicalize().unwrap_or(abs_path))
} else {
abs_path.canonicalize().unwrap_or(abs_path)
};
let path_str = resolved_path.to_string_lossy();
let escaped_path = path_str.replace(' ', "%20");
format!("path:{}", escaped_path)
} else {
format!("path:./packages/{}", flake.name)
};
self.inputs
.push_str(&format!(" {}.url = \"{}\";\n", flake.name, path));
self.seen_inputs.insert(flake.name.clone());
self.local_entries.push_str(&format!(
" {} = inputs.{}.packages.${{system}}.default;\n",
flake.name, flake.name
));
self.buildenv_paths.push(PathEntry {
name: flake.name.clone(),
platforms: None,
});
}
}
fn add_local_packages(&mut self, packages: &[LocalPackage]) {
for pkg in packages {
if let (Some(input_name), Some(input_url)) = (&pkg.input_name, &pkg.input_url) {
if self.seen_inputs.insert(input_name.clone()) {
self.inputs
.push_str(&format!(" {}.url = \"{}\";\n", input_name, input_url));
}
}
if let Some(overlay) = &pkg.overlay {
self.overlays.push_str(&format!(" {}\n", overlay));
}
self.local_entries
.push_str(&format!(" {} = {};\n", pkg.name, pkg.package_expr));
self.buildenv_paths.push(PathEntry {
name: pkg.name.clone(),
platforms: None,
});
}
}
fn add_local_packages_with_absolute_paths(
&mut self,
packages: &[LocalPackage],
packages_dir: Option<&Path>,
) {
for pkg in packages {
if let (Some(input_name), Some(input_url)) = (&pkg.input_name, &pkg.input_url) {
if self.seen_inputs.insert(input_name.clone()) {
self.inputs
.push_str(&format!(" {}.url = \"{}\";\n", input_name, input_url));
}
}
if let Some(overlay) = &pkg.overlay {
self.overlays.push_str(&format!(" {}\n", overlay));
}
let package_expr = if let Some(dir) = packages_dir {
let abs_path = dir.join(format!("{}.nix", pkg.name));
let resolved_path = abs_path.canonicalize().unwrap_or(abs_path);
let path_str = resolved_path.to_string_lossy();
if pkg.package_expr == format!("pkgs.callPackage ./packages/{}.nix {{}}", pkg.name)
{
if path_str.contains(' ') {
format!("pkgs.callPackage \"{}\" {{}}", path_str)
} else {
format!("pkgs.callPackage {} {{}}", path_str)
}
} else {
pkg.package_expr.clone()
}
} else {
pkg.package_expr.clone()
};
self.local_entries
.push_str(&format!(" {} = {};\n", pkg.name, package_expr));
self.buildenv_paths.push(PathEntry {
name: pkg.name.clone(),
platforms: None,
});
}
}
fn add_custom_packages(&mut self, packages: &[CustomPackage]) {
for pkg in packages {
if self.seen_inputs.insert(pkg.input_name.clone()) {
self.inputs.push_str(&format!(
" {}.url = \"{}\";\n",
pkg.input_name,
pkg.locked_input_url()
));
}
self.custom_entries.push_str(&format!(
" {} = inputs.{}.{}.${{system}}.{};\n",
pkg.name,
pkg.input_name,
pkg.package_output,
pkg.source_package_name()
));
self.buildenv_paths.push(PathEntry {
name: pkg.name.clone(),
platforms: pkg.platforms.clone(),
});
}
}
fn build_output_params(&self) -> String {
if self.seen_inputs.is_empty() {
"self, nixpkgs".to_string()
} else {
let mut inputs_list: Vec<_> = self.seen_inputs.iter().cloned().collect();
inputs_list.sort();
format!("self, nixpkgs, {}", inputs_list.join(", "))
}
}
fn build_pkgs_definition(&self) -> (String, &'static str) {
if self.overlays.is_empty() {
(
String::new(),
"let pkgs = nixpkgs.legacyPackages.${system};",
)
} else {
let overlays_content = format!("overlays = [\n{} ];", self.overlays);
let pkgs_def = format!(
"pkgsFor = system: import nixpkgs {{
inherit system;
{}
}};
",
overlays_content
);
(pkgs_def, "let pkgs = pkgsFor system;")
}
}
fn build(self) -> String {
let output_params = self.build_output_params();
let (pkgs_def, pkgs_binding) = self.build_pkgs_definition();
let (paths_content, _has_platform_conditionals) = self.build_paths_section_with_info();
let paths_section = format!("paths = [\n{} ];", paths_content);
format!(
r#"{{
description = "nixy managed packages";
inputs = {{
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
{all_inputs} }};
outputs = {{ {output_params} }}@inputs:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
{pkgs_def}
in {{
packages = forAllSystems (system:
{pkgs_binding}
in rec {{
{pkg_entries}{resolved_entries}{local_entries}{custom_entries}
default = pkgs.buildEnv {{
name = "nixy-env";
{paths_section}
extraOutputsToInstall = [ "man" "doc" "info" "dev" ];
}};
}});
}};
}}
"#,
all_inputs = self.inputs,
output_params = output_params,
pkgs_def = pkgs_def,
pkgs_binding = pkgs_binding,
pkg_entries = self.standard_entries,
resolved_entries = self.resolved_entries,
local_entries = self.local_entries,
custom_entries = self.custom_entries,
paths_section = paths_section,
)
}
fn build_paths_section_with_info(&self) -> (String, bool) {
if self.buildenv_paths.is_empty() {
return (String::new(), false);
}
let mut universal: Vec<&str> = Vec::new();
let mut by_platforms: HashMap<Vec<String>, Vec<&str>> = HashMap::new();
for entry in &self.buildenv_paths {
match &entry.platforms {
None => universal.push(&entry.name),
Some(platforms) => {
let mut sorted_platforms = platforms.clone();
sorted_platforms.sort();
by_platforms
.entry(sorted_platforms)
.or_default()
.push(&entry.name);
}
}
}
let has_conditionals = !by_platforms.is_empty();
let mut result = String::new();
for pkg in &universal {
result.push_str(&format!(" {}\n", pkg));
}
let mut platform_groups: Vec<_> = by_platforms.into_iter().collect();
platform_groups.sort_by(|a, b| a.0.cmp(&b.0));
for (platforms, packages) in platform_groups {
let platforms_str = platforms
.iter()
.map(|p| format!("\"{}\"", p))
.collect::<Vec<_>>()
.join(" ");
let packages_str = packages
.iter()
.map(|p| format!("\n {}", p))
.collect::<Vec<_>>()
.join("");
result.push_str(&format!(
" ] ++ pkgs.lib.optionals (builtins.elem system [ {} ]) [{}\n",
platforms_str, packages_str
));
}
(result, has_conditionals)
}
}
pub fn generate_flake(state: &PackageState, flake_dir: Option<&Path>) -> String {
let (local_packages, local_flakes) = if let Some(dir) = flake_dir {
let packages_dir = dir.join("packages");
if packages_dir.exists() {
collect_local_packages(&packages_dir)
} else {
(Vec::new(), Vec::new())
}
} else {
(Vec::new(), Vec::new())
};
let filtered_legacy_packages: Vec<&String> = state
.packages
.iter()
.filter(|pkg| {
!local_packages.iter().any(|lp| &lp.name == *pkg)
&& !local_flakes.iter().any(|lf| &lf.name == *pkg)
})
.collect();
let filtered_resolved_packages: Vec<ResolvedNixpkgPackage> = state
.resolved_packages
.iter()
.filter(|pkg| {
!local_packages.iter().any(|lp| lp.name == pkg.name)
&& !local_flakes.iter().any(|lf| lf.name == pkg.name)
})
.cloned()
.collect();
let mut builder = FlakeBuilder::new();
builder.add_standard_packages(&filtered_legacy_packages);
builder.add_resolved_packages(&filtered_resolved_packages);
builder.add_local_flakes(&local_flakes);
builder.add_local_packages(&local_packages);
builder.add_custom_packages(&state.custom_packages);
builder.build()
}
pub fn generate_flake_from_profile(
profile: &ProfileConfig,
global_packages_dir: Option<&Path>,
_flake_dir: &Path,
) -> String {
let (local_packages, local_flakes) = if let Some(dir) = global_packages_dir {
if dir.exists() {
collect_local_packages_with_paths(dir)
} else {
(Vec::new(), Vec::new())
}
} else {
(Vec::new(), Vec::new())
};
let filtered_legacy_packages: Vec<&String> = profile
.packages
.iter()
.filter(|pkg| {
!local_packages.iter().any(|lp| &lp.name == *pkg)
&& !local_flakes.iter().any(|lf| &lf.name == *pkg)
})
.collect();
let filtered_resolved_packages: Vec<ResolvedNixpkgPackage> = profile
.resolved_packages
.iter()
.filter(|pkg| {
!local_packages.iter().any(|lp| lp.name == pkg.name)
&& !local_flakes.iter().any(|lf| lf.name == pkg.name)
})
.cloned()
.collect();
let mut builder = FlakeBuilder::new();
builder.add_standard_packages(&filtered_legacy_packages);
builder.add_resolved_packages(&filtered_resolved_packages);
builder.add_local_flakes_with_absolute_paths(&local_flakes, global_packages_dir);
builder.add_local_packages_with_absolute_paths(&local_packages, global_packages_dir);
builder.add_custom_packages(&profile.custom_packages);
builder.build()
}
fn collect_local_packages_with_paths(packages_dir: &Path) -> (Vec<LocalPackage>, Vec<LocalFlake>) {
collect_local_packages(packages_dir)
}
pub fn local_path_input_names(packages_dir: &Path) -> Vec<String> {
let (local_packages, local_flakes) = collect_local_packages(packages_dir);
let mut names: Vec<String> = local_flakes.into_iter().map(|f| f.name).collect();
for pkg in &local_packages {
if let (Some(input_name), Some(input_url)) = (&pkg.input_name, &pkg.input_url) {
if input_url.starts_with("path:") && !names.contains(input_name) {
names.push(input_name.clone());
}
}
}
names
}
pub fn regenerate_flake(flake_dir: &Path, state: &PackageState) -> Result<()> {
let flake_path = flake_dir.join("flake.nix");
fs::create_dir_all(flake_dir)?;
let content = generate_flake(state, Some(flake_dir));
fs::write(&flake_path, content)?;
Ok(())
}
pub fn regenerate_flake_from_profile(
flake_dir: &Path,
profile: &ProfileConfig,
global_packages_dir: Option<&Path>,
) -> Result<()> {
let flake_path = flake_dir.join("flake.nix");
fs::create_dir_all(flake_dir)?;
let content = generate_flake_from_profile(profile, global_packages_dir, flake_dir);
fs::write(&flake_path, content)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::{CustomPackage, ResolvedNixpkgPackage};
#[test]
fn test_generate_empty_flake() {
let state = PackageState::default();
let flake = generate_flake(&state, None);
assert!(flake.contains("default = pkgs.buildEnv"));
assert!(flake.contains("name = \"nixy-env\""));
assert!(flake.contains("extraOutputsToInstall"));
assert!(!flake.contains("# [nixy:"));
assert!(!flake.contains("# [/nixy:"));
assert!(!flake.contains("devShells"));
}
#[test]
fn test_generate_flake_with_packages() {
let mut state = PackageState::default();
state.add_package("ripgrep");
state.add_package("fzf");
state.add_package("bat");
let flake = generate_flake(&state, None);
assert!(flake.contains("ripgrep = pkgs.ripgrep;"));
assert!(flake.contains("fzf = pkgs.fzf;"));
assert!(flake.contains("bat = pkgs.bat;"));
assert!(flake.contains("ripgrep"));
assert!(flake.contains("fzf"));
assert!(flake.contains("bat"));
}
#[test]
fn test_generate_flake_with_custom_packages() {
let mut state = PackageState::default();
state.add_custom_package(CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
});
let flake = generate_flake(&state, None);
assert!(
flake.contains("neovim-nightly.url = \"github:nix-community/neovim-nightly-overlay\"")
);
assert!(flake.contains("neovim = inputs.neovim-nightly.packages.${system}.neovim;"));
assert!(flake.contains("neovim"));
}
#[test]
fn test_custom_package_with_rev_pins_input_url() {
let mut state = PackageState::default();
state.add_custom_package(CustomPackage {
name: "box".to_string(),
input_name: "github-yusukeshib-box".to_string(),
input_url: "github:yusukeshib/box".to_string(),
package_output: "packages".to_string(),
rev: Some("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string()),
source_name: None,
platforms: None,
});
let flake = generate_flake(&state, None);
assert!(flake.contains(
"github-yusukeshib-box.url = \"github:yusukeshib/box?rev=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef\""
));
}
#[test]
fn test_flake_has_correct_nixpkgs_url() {
let state = PackageState::default();
let flake = generate_flake(&state, None);
assert!(flake.contains("nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\""));
}
#[test]
fn test_flake_has_all_systems() {
let state = PackageState::default();
let flake = generate_flake(&state, None);
assert!(flake.contains("x86_64-linux"));
assert!(flake.contains("aarch64-linux"));
assert!(flake.contains("x86_64-darwin"));
assert!(flake.contains("aarch64-darwin"));
}
#[test]
fn test_flake_uses_legacy_packages() {
let state = PackageState::default();
let flake = generate_flake(&state, None);
assert!(flake.contains("nixpkgs.legacyPackages.${system}"));
}
#[test]
fn test_buildenv_has_extra_outputs() {
let state = PackageState::default();
let flake = generate_flake(&state, None);
assert!(flake.contains("extraOutputsToInstall = [ \"man\" \"doc\" \"info\" \"dev\" ]"));
}
#[test]
fn test_flake_has_no_devshells() {
let mut state = PackageState::default();
state.add_package("ripgrep");
let flake = generate_flake(&state, None);
assert!(!flake.contains("devShells"));
assert!(flake.contains("packages = forAllSystems"));
}
#[test]
fn test_flake_has_no_markers() {
let mut state = PackageState::default();
state.add_package("hello");
state.add_custom_package(CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
});
let flake = generate_flake(&state, None);
assert!(!flake.contains("# [nixy:"));
assert!(!flake.contains("# [/nixy:"));
}
#[test]
fn test_multiple_custom_packages_share_input() {
let mut state = PackageState::default();
state.add_custom_package(CustomPackage {
name: "hello".to_string(),
input_name: "nixpkgs-unstable".to_string(),
input_url: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
package_output: "legacyPackages".to_string(),
rev: None,
source_name: None,
platforms: None,
});
state.add_custom_package(CustomPackage {
name: "world".to_string(),
input_name: "nixpkgs-unstable".to_string(),
input_url: "github:NixOS/nixpkgs/nixos-unstable".to_string(),
package_output: "legacyPackages".to_string(),
rev: None,
source_name: None,
platforms: None,
});
let flake = generate_flake(&state, None);
let count = flake.matches("nixpkgs-unstable.url").count();
assert_eq!(count, 1, "Input should only appear once");
}
#[test]
fn test_buildenv_contains_all_packages() {
let mut state = PackageState::default();
state.add_package("ripgrep");
state.add_package("fzf");
state.add_custom_package(CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
});
let flake = generate_flake(&state, None);
let paths_start = flake.find("paths = [").unwrap();
let paths_end = flake[paths_start..].find("];").unwrap();
let paths_section = &flake[paths_start..paths_start + paths_end];
assert!(paths_section.contains("ripgrep"));
assert!(paths_section.contains("fzf"));
assert!(paths_section.contains("neovim"));
}
#[test]
fn test_empty_flake_has_empty_buildenv() {
let state = PackageState::default();
let flake = generate_flake(&state, None);
assert!(flake.contains("default = pkgs.buildEnv"));
assert!(flake.contains("paths = ["));
assert!(flake.contains("extraOutputsToInstall = [ \"man\" \"doc\" \"info\" \"dev\" ]"));
}
#[test]
fn test_generate_flake_with_resolved_packages() {
let mut state = PackageState::default();
state.add_resolved_package(ResolvedNixpkgPackage {
name: "nodejs".to_string(),
version_spec: Some("20".to_string()),
resolved_version: "20.11.0".to_string(),
attribute_path: "nodejs_20".to_string(),
commit_hash: "abc123def456".to_string(),
platforms: None,
});
let flake = generate_flake(&state, None);
assert!(flake.contains("nixpkgs-abc123de.url = \"github:NixOS/nixpkgs/abc123def456\""));
assert!(
flake.contains("nodejs = inputs.nixpkgs-abc123de.legacyPackages.${system}.nodejs_20;")
);
let paths_start = flake.find("paths = [").unwrap();
let paths_end = flake[paths_start..].find("];").unwrap();
let paths_section = &flake[paths_start..paths_start + paths_end];
assert!(paths_section.contains("nodejs"));
}
#[test]
fn test_resolved_packages_share_commit_input() {
let mut state = PackageState::default();
state.add_resolved_package(ResolvedNixpkgPackage {
name: "nodejs".to_string(),
version_spec: Some("20".to_string()),
resolved_version: "20.11.0".to_string(),
attribute_path: "nodejs_20".to_string(),
commit_hash: "abc123def456".to_string(),
platforms: None,
});
state.add_resolved_package(ResolvedNixpkgPackage {
name: "python".to_string(),
version_spec: Some("3.11".to_string()),
resolved_version: "3.11.5".to_string(),
attribute_path: "python311".to_string(),
commit_hash: "abc123def456".to_string(), platforms: None,
});
let flake = generate_flake(&state, None);
let count = flake.matches("nixpkgs-abc123de.url").count();
assert_eq!(count, 1, "Same commit input should only appear once");
assert!(
flake.contains("nodejs = inputs.nixpkgs-abc123de.legacyPackages.${system}.nodejs_20;")
);
assert!(
flake.contains("python = inputs.nixpkgs-abc123de.legacyPackages.${system}.python311;")
);
}
#[test]
fn test_resolved_packages_different_commits() {
let mut state = PackageState::default();
state.add_resolved_package(ResolvedNixpkgPackage {
name: "nodejs".to_string(),
version_spec: Some("20".to_string()),
resolved_version: "20.11.0".to_string(),
attribute_path: "nodejs_20".to_string(),
commit_hash: "abc123def456".to_string(),
platforms: None,
});
state.add_resolved_package(ResolvedNixpkgPackage {
name: "python".to_string(),
version_spec: Some("3.11".to_string()),
resolved_version: "3.11.5".to_string(),
attribute_path: "python311".to_string(),
commit_hash: "xyz789ghi012".to_string(), platforms: None,
});
let flake = generate_flake(&state, None);
assert!(flake.contains("nixpkgs-abc123de.url = \"github:NixOS/nixpkgs/abc123def456\""));
assert!(flake.contains("nixpkgs-xyz789gh.url = \"github:NixOS/nixpkgs/xyz789ghi012\""));
assert!(
flake.contains("nodejs = inputs.nixpkgs-abc123de.legacyPackages.${system}.nodejs_20;")
);
assert!(
flake.contains("python = inputs.nixpkgs-xyz789gh.legacyPackages.${system}.python311;")
);
}
#[test]
fn test_mixed_legacy_and_resolved_packages() {
let mut state = PackageState::default();
state.add_package("ripgrep");
state.add_resolved_package(ResolvedNixpkgPackage {
name: "nodejs".to_string(),
version_spec: Some("20".to_string()),
resolved_version: "20.11.0".to_string(),
attribute_path: "nodejs_20".to_string(),
commit_hash: "abc123def456".to_string(),
platforms: None,
});
let flake = generate_flake(&state, None);
assert!(flake.contains("nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\""));
assert!(flake.contains("ripgrep = pkgs.ripgrep;"));
assert!(flake.contains("nixpkgs-abc123de.url = \"github:NixOS/nixpkgs/abc123def456\""));
assert!(
flake.contains("nodejs = inputs.nixpkgs-abc123de.legacyPackages.${system}.nodejs_20;")
);
let paths_start = flake.find("paths = [").unwrap();
let paths_end = flake[paths_start..].find("];").unwrap();
let paths_section = &flake[paths_start..paths_start + paths_end];
assert!(paths_section.contains("ripgrep"));
assert!(paths_section.contains("nodejs"));
}
#[test]
fn test_platform_specific_resolved_package() {
let mut state = PackageState::default();
state.add_resolved_package(ResolvedNixpkgPackage {
name: "terminal-notifier".to_string(),
version_spec: None,
resolved_version: "2.0.0".to_string(),
attribute_path: "terminal-notifier".to_string(),
commit_hash: "abc123def456".to_string(),
platforms: Some(vec![
"aarch64-darwin".to_string(),
"x86_64-darwin".to_string(),
]),
});
let flake = generate_flake(&state, None);
assert!(
flake.contains("lib.optionals"),
"Should use lib.optionals for platform-specific packages"
);
assert!(
flake.contains("aarch64-darwin") && flake.contains("x86_64-darwin"),
"Should include darwin platforms"
);
assert!(
flake.contains("terminal-notifier"),
"Should include the package name"
);
}
#[test]
fn test_mixed_universal_and_platform_specific() {
let mut state = PackageState::default();
state.add_package("hello");
state.add_resolved_package(ResolvedNixpkgPackage {
name: "terminal-notifier".to_string(),
version_spec: None,
resolved_version: "2.0.0".to_string(),
attribute_path: "terminal-notifier".to_string(),
commit_hash: "abc123def456".to_string(),
platforms: Some(vec![
"aarch64-darwin".to_string(),
"x86_64-darwin".to_string(),
]),
});
let flake = generate_flake(&state, None);
assert!(flake.contains("hello"));
assert!(flake.contains("lib.optionals"));
assert!(flake.contains("terminal-notifier"));
}
#[test]
fn test_platform_specific_custom_package() {
let mut state = PackageState::default();
state.add_custom_package(CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: Some(vec![
"x86_64-linux".to_string(),
"aarch64-linux".to_string(),
]),
});
let flake = generate_flake(&state, None);
assert!(flake.contains("lib.optionals"));
assert!(flake.contains("x86_64-linux") && flake.contains("aarch64-linux"));
assert!(flake.contains("neovim"));
}
#[test]
fn test_generated_flake_has_balanced_brackets() {
fn validate_brackets(s: &str) -> std::result::Result<(), String> {
let mut curly = 0i32;
let mut square = 0i32;
let mut paren = 0i32;
for (i, c) in s.chars().enumerate() {
match c {
'{' => curly += 1,
'}' => {
curly -= 1;
if curly < 0 {
return Err(format!("Unmatched '}}' at position {}", i));
}
}
'[' => square += 1,
']' => {
square -= 1;
if square < 0 {
return Err(format!("Unmatched ']' at position {}", i));
}
}
'(' => paren += 1,
')' => {
paren -= 1;
if paren < 0 {
return Err(format!("Unmatched ')' at position {}", i));
}
}
_ => {}
}
}
if curly != 0 {
return Err(format!("Unbalanced curly braces: {} unclosed", curly));
}
if square != 0 {
return Err(format!("Unbalanced square brackets: {} unclosed", square));
}
if paren != 0 {
return Err(format!("Unbalanced parentheses: {} unclosed", paren));
}
Ok(())
}
let state = PackageState::default();
let flake = generate_flake(&state, None);
validate_brackets(&flake).expect("Empty state should produce balanced brackets");
let mut state = PackageState::default();
state.add_package("hello");
state.add_package("ripgrep");
let flake = generate_flake(&state, None);
validate_brackets(&flake).expect("Standard packages should produce balanced brackets");
let mut state = PackageState::default();
state.add_resolved_package(ResolvedNixpkgPackage {
name: "hello".to_string(),
version_spec: Some("2.10".to_string()),
resolved_version: "2.10".to_string(),
attribute_path: "hello".to_string(),
commit_hash: "abc123".to_string(),
platforms: None,
});
let flake = generate_flake(&state, None);
validate_brackets(&flake).expect("Resolved packages should produce balanced brackets");
let mut state = PackageState::default();
state.add_resolved_package(ResolvedNixpkgPackage {
name: "terminal-notifier".to_string(),
version_spec: None,
resolved_version: "2.0.0".to_string(),
attribute_path: "terminal-notifier".to_string(),
commit_hash: "abc123def456".to_string(),
platforms: Some(vec![
"aarch64-darwin".to_string(),
"x86_64-darwin".to_string(),
]),
});
let flake = generate_flake(&state, None);
validate_brackets(&flake)
.expect("Platform-specific resolved package should produce balanced brackets");
let mut state = PackageState::default();
state.add_package("hello");
state.add_resolved_package(ResolvedNixpkgPackage {
name: "terminal-notifier".to_string(),
version_spec: None,
resolved_version: "2.0.0".to_string(),
attribute_path: "terminal-notifier".to_string(),
commit_hash: "abc123def456".to_string(),
platforms: Some(vec![
"aarch64-darwin".to_string(),
"x86_64-darwin".to_string(),
]),
});
let flake = generate_flake(&state, None);
validate_brackets(&flake)
.expect("Mixed universal and platform-specific should produce balanced brackets");
let mut state = PackageState::default();
state.add_custom_package(CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: Some(vec![
"x86_64-linux".to_string(),
"aarch64-linux".to_string(),
]),
});
let flake = generate_flake(&state, None);
validate_brackets(&flake)
.expect("Custom package with platforms should produce balanced brackets");
let mut state = PackageState::default();
state.add_custom_package(CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: None,
});
let flake = generate_flake(&state, None);
validate_brackets(&flake)
.expect("Universal custom package should produce balanced brackets");
let mut state = PackageState::default();
state.add_package("hello");
state.add_package("ripgrep");
state.add_resolved_package(ResolvedNixpkgPackage {
name: "jq".to_string(),
version_spec: Some("1.6".to_string()),
resolved_version: "1.6".to_string(),
attribute_path: "jq".to_string(),
commit_hash: "abc123".to_string(),
platforms: None,
});
state.add_resolved_package(ResolvedNixpkgPackage {
name: "terminal-notifier".to_string(),
version_spec: None,
resolved_version: "2.0.0".to_string(),
attribute_path: "terminal-notifier".to_string(),
commit_hash: "def456".to_string(),
platforms: Some(vec!["aarch64-darwin".to_string()]),
});
state.add_custom_package(CustomPackage {
name: "neovim".to_string(),
input_name: "neovim-nightly".to_string(),
input_url: "github:nix-community/neovim-nightly-overlay".to_string(),
package_output: "packages".to_string(),
rev: None,
source_name: None,
platforms: Some(vec!["x86_64-linux".to_string()]),
});
let flake = generate_flake(&state, None);
validate_brackets(&flake).expect("Complex mixed scenario should produce balanced brackets");
}
#[test]
#[cfg(unix)]
fn test_local_flake_symlink_resolution() {
use std::os::unix::fs::symlink;
use tempfile::tempdir;
let temp = tempdir().unwrap();
let target_dir = temp.path().join("real-package");
let packages_dir = temp.path().join("packages");
fs::create_dir_all(&target_dir).unwrap();
fs::create_dir_all(&packages_dir).unwrap();
fs::write(target_dir.join("flake.nix"), "{ }").unwrap();
symlink(&target_dir, packages_dir.join("my-package")).unwrap();
let local_flakes = vec![LocalFlake {
name: "my-package".to_string(),
}];
let mut builder = FlakeBuilder::new();
builder.add_local_flakes_with_absolute_paths(&local_flakes, Some(&packages_dir));
let resolved_target = target_dir.canonicalize().unwrap();
let expected_path = format!("path:{}", resolved_target.to_string_lossy());
assert!(
builder.inputs.contains(&expected_path),
"Expected path '{}' in inputs, got: {}",
expected_path,
builder.inputs
);
}
#[test]
#[cfg(unix)]
fn test_local_flake_with_symlinked_flake_nix() {
use std::os::unix::fs::symlink;
use tempfile::tempdir;
let temp = tempdir().unwrap();
let target_flake_dir = temp.path().join("real-flake");
let packages_dir = temp.path().join("packages");
let symlink_dir = packages_dir.join("my-flake");
fs::create_dir_all(&target_flake_dir).unwrap();
fs::create_dir_all(&symlink_dir).unwrap();
fs::write(target_flake_dir.join("flake.nix"), "{ }").unwrap();
symlink(
target_flake_dir.join("flake.nix"),
symlink_dir.join("flake.nix"),
)
.unwrap();
let local_flakes = vec![LocalFlake {
name: "my-flake".to_string(),
}];
let mut builder = FlakeBuilder::new();
builder.add_local_flakes_with_absolute_paths(&local_flakes, Some(&packages_dir));
let resolved_target = target_flake_dir.canonicalize().unwrap();
let expected_path = format!("path:{}", resolved_target.to_string_lossy());
assert!(
builder.inputs.contains(&expected_path),
"Expected path '{}' in inputs, got: {}",
expected_path,
builder.inputs
);
}
#[test]
fn test_local_path_input_names() {
use tempfile::tempdir;
let temp = tempdir().unwrap();
let packages_dir = temp.path().join("packages");
assert!(local_path_input_names(&packages_dir).is_empty());
fs::create_dir_all(packages_dir.join("my-flake")).unwrap();
fs::write(packages_dir.join("my-flake").join("flake.nix"), "{ }").unwrap();
fs::write(
packages_dir.join("gh-pkg.nix"),
r#"
{
pname = "gh-pkg";
inputs = {
gh-input.url = "github:user/repo";
};
}
"#,
)
.unwrap();
fs::write(
packages_dir.join("path-pkg.nix"),
r#"
{
pname = "path-pkg";
inputs = {
path-input.url = "path:/some/where";
};
}
"#,
)
.unwrap();
let names = local_path_input_names(&packages_dir);
assert!(names.contains(&"my-flake".to_string()));
assert!(names.contains(&"path-input".to_string()));
assert!(!names.contains(&"gh-input".to_string()));
assert_eq!(names.len(), 2);
}
#[test]
#[cfg(unix)]
fn test_local_package_nix_file_symlink_resolution() {
use std::os::unix::fs::symlink;
use tempfile::tempdir;
let temp = tempdir().unwrap();
let target_file = temp.path().join("real-package.nix");
let packages_dir = temp.path().join("packages");
fs::create_dir_all(&packages_dir).unwrap();
fs::write(&target_file, "{ pkgs }: pkgs.hello").unwrap();
symlink(&target_file, packages_dir.join("my-package.nix")).unwrap();
let local_packages = vec![LocalPackage {
name: "my-package".to_string(),
package_expr: "pkgs.callPackage ./packages/my-package.nix {}".to_string(),
input_name: None,
input_url: None,
overlay: None,
}];
let mut builder = FlakeBuilder::new();
builder.add_local_packages_with_absolute_paths(&local_packages, Some(&packages_dir));
let resolved_target = target_file.canonicalize().unwrap();
let expected_expr = format!(
"pkgs.callPackage {} {{}}",
resolved_target.to_string_lossy()
);
assert!(
builder.local_entries.contains(&expected_expr),
"Expected expression '{}' in local_entries, got: {}",
expected_expr,
builder.local_entries
);
}
#[test]
#[cfg(unix)]
fn test_symlink_fallback_on_broken_link() {
use std::os::unix::fs::symlink;
use tempfile::tempdir;
let temp = tempdir().unwrap();
let packages_dir = temp.path().join("packages");
fs::create_dir_all(&packages_dir).unwrap();
let nonexistent = temp.path().join("nonexistent");
symlink(&nonexistent, packages_dir.join("broken-package")).unwrap();
let local_flakes = vec![LocalFlake {
name: "broken-package".to_string(),
}];
let mut builder = FlakeBuilder::new();
builder.add_local_flakes_with_absolute_paths(&local_flakes, Some(&packages_dir));
let expected_path = format!(
"path:{}",
packages_dir.join("broken-package").to_string_lossy()
);
assert!(
builder.inputs.contains(&expected_path),
"Expected fallback path '{}' in inputs, got: {}",
expected_path,
builder.inputs
);
}
}