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::state::{CustomPackage, PackageState, ResolvedNixpkgPackage};
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<String>,
}
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| p.to_string()));
}
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(pkg.name.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(flake.name.clone());
}
}
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(pkg.name.clone());
}
}
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.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(pkg.name.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_paths_section(&self) -> String {
if self.buildenv_paths.is_empty() {
String::new()
} else {
let paths: Vec<String> = self
.buildenv_paths
.iter()
.map(|p| format!(" {}", p))
.collect();
format!("{}\n", paths.join("\n"))
}
}
fn build(self) -> String {
let output_params = self.build_output_params();
let (pkgs_def, pkgs_binding) = self.build_pkgs_definition();
let buildenv_paths_str = self.build_paths_section();
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 = [
{buildenv_paths_str} ];
extraOutputsToInstall = [ "man" "doc" "info" ];
}};
}});
}};
}}
"#,
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,
buildenv_paths_str = buildenv_paths_str,
)
}
}
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 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(())
}
#[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(),
source_name: 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_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\" ]"));
}
#[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(),
source_name: 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(),
source_name: 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(),
source_name: 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(),
source_name: 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\" ]"));
}
#[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(),
});
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(),
});
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(), });
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(),
});
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(), });
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(),
});
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"));
}
}