use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct MaterializationPayload {
#[serde(default)]
pub system: Option<String>,
#[serde(default)]
pub flake_inputs: BTreeMap<String, String>,
#[serde(default)]
pub nixos_module: NixosModulePayload,
}
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
pub struct NixosModulePayload {
#[serde(default)]
pub extra_config: Vec<String>,
}
impl MaterializationPayload {
pub fn from_source(path: &Path) -> Result<Self> {
let loaded = crate::document::load_document::<Self>(path, "materialization payload")?;
loaded.value.validate()?;
Ok(loaded.value)
}
pub fn from_toml_str(content: &str) -> Result<Self> {
let payload: Self = toml::from_str(content).context("invalid compatibility materialization TOML")?;
payload.validate()?;
Ok(payload)
}
pub fn to_compat_toml(&self) -> String {
if self.flake_inputs.is_empty() {
return String::new();
}
let mut lines = Vec::new();
if let Some(system) = &self.system {
lines.push(format!("system = {system:?}"));
if !self.flake_inputs.is_empty() || !self.nixos_module.extra_config.is_empty() {
lines.push(String::new());
}
}
if !self.flake_inputs.is_empty() {
lines.push("[flake_inputs]".to_string());
for (name, reference) in &self.flake_inputs {
lines.push(format!("{name} = {reference:?}"));
}
}
if !self.nixos_module.extra_config.is_empty() {
if !lines.is_empty() {
lines.push(String::new());
}
lines.push("[nixos_module]".to_string());
lines.push("extra_config = [".to_string());
for fragment in &self.nixos_module.extra_config {
lines.push(format!(" {fragment:?},"));
}
lines.push("]".to_string());
}
lines.join("\n")
}
pub fn validate(&self) -> Result<()> {
if let Some(system) = &self.system {
validate_nix_system(system)?;
}
for (name, reference) in &self.flake_inputs {
validate_flake_input_name(name)?;
validate_flake_input_ref(reference)?;
}
for fragment in &self.nixos_module.extra_config {
validate_extra_config(fragment)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaterializationTarget {
Toplevel,
SdImage,
}
impl MaterializationTarget {
pub fn parse(value: &str) -> Result<Self> {
match value {
"toplevel" => Ok(Self::Toplevel),
"sd-image" => Ok(Self::SdImage),
other => bail!("unsupported materialization target '{other}'; supported: toplevel, sd-image"),
}
}
pub fn attr(self, hostname: &str) -> String {
match self {
Self::Toplevel => nixos_toplevel_attr(hostname),
Self::SdImage => nixos_sd_image_attr(hostname),
}
}
}
#[derive(Debug, Clone)]
pub struct MaterializationCheck {
pub workspace: PathBuf,
pub hostname: String,
pub target: MaterializationTarget,
}
impl MaterializationCheck {
pub fn eval_attr(&self) -> String {
self.target.attr(&self.hostname)
}
pub fn command(&self) -> Result<Command> {
validate_hostname(&self.hostname)?;
validate_workspace(&self.workspace)?;
let mut command = Command::new(find_nix());
command
.env("NIX_CONFIG", "experimental-features = nix-command flakes")
.env("NIX_SHOW_STATS", "0")
.args([
"--extra-experimental-features",
"nix-command flakes",
"eval",
"--no-update-lock-file",
"--no-write-lock-file",
"--offline",
])
.arg(self.eval_attr())
.current_dir(&self.workspace);
Ok(command)
}
pub fn run(&self) -> Result<()> {
let output = self
.command()?
.output()
.with_context(|| format!("running nix eval in {}", self.workspace.display()))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!(
"materialization check failed for {}\n{}{}",
self.eval_attr(),
stdout,
stderr
);
}
}
#[derive(Debug, Clone)]
pub struct MaterializationBuild {
pub workspace: PathBuf,
pub hostname: String,
pub target: MaterializationTarget,
pub out_link: PathBuf,
}
impl MaterializationBuild {
pub fn eval_attr(&self) -> String {
self.target.attr(&self.hostname)
}
pub fn command(&self) -> Result<Command> {
validate_hostname(&self.hostname)?;
validate_workspace(&self.workspace)?;
if let Some(parent) = self.out_link.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
let mut command = Command::new(find_nix());
command
.env("NIX_CONFIG", "experimental-features = nix-command flakes")
.env("NIX_SHOW_STATS", "0")
.args([
"--extra-experimental-features",
"nix-command flakes",
"build",
"--no-update-lock-file",
"--no-write-lock-file",
"--offline",
"--out-link",
])
.arg(&self.out_link)
.arg(self.eval_attr())
.current_dir(&self.workspace);
Ok(command)
}
pub fn run(&self) -> Result<()> {
let check = MaterializationCheck {
workspace: self.workspace.clone(),
hostname: self.hostname.clone(),
target: self.target,
};
check.run()?;
let output = self
.command()?
.output()
.with_context(|| format!("running nix build in {}", self.workspace.display()))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!(
"materialization build failed for {}\n{}{}",
self.eval_attr(),
stdout,
stderr
);
}
}
#[derive(Debug, Clone)]
pub struct NixosModuleExport {
pub workspace: PathBuf,
pub name: String,
}
impl NixosModuleExport {
pub fn write(&self, payload: &MaterializationPayload) -> Result<()> {
validate_module_name(&self.name)?;
std::fs::create_dir_all(&self.workspace)?;
std::fs::write(self.workspace.join("module.nix"), render_nixos_module(payload))?;
std::fs::write(
self.workspace.join("flake.nix"),
format!(
r#"{{
description = "Nex materialization module export";
outputs = {{ self, ... }}:
{{
nixosModules.{name} = import ./module.nix;
}};
}}
"#,
name = self.name
),
)?;
Ok(())
}
}
pub fn validate_module_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("module name cannot be empty");
}
let mut chars = name.chars();
let first = chars.next().expect("checked non-empty");
if !(first.is_ascii_alphabetic() || first == '_') {
bail!("module name '{name}' must start with a letter or underscore");
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
bail!("module name '{name}' may only contain [A-Za-z0-9_-]");
}
Ok(())
}
pub fn nixos_toplevel_attr(hostname: &str) -> String {
format!(".#nixosConfigurations.{hostname}.config.system.build.toplevel")
}
pub fn nixos_sd_image_attr(hostname: &str) -> String {
format!(".#nixosConfigurations.{hostname}.config.system.build.sdImage")
}
pub fn validate_hostname(hostname: &str) -> Result<()> {
if hostname.is_empty() || hostname.len() > 63 {
bail!("hostname must be 1-63 characters");
}
if hostname.starts_with('-') || hostname.ends_with('-') {
bail!("hostname cannot start or end with a hyphen");
}
if !hostname
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
bail!("hostname must contain only ASCII letters, digits, and hyphens");
}
Ok(())
}
pub fn validate_workspace(workspace: &Path) -> Result<()> {
if !workspace.is_dir() {
bail!("materialization workspace does not exist: {}", workspace.display());
}
let flake = workspace.join("flake.nix");
if !flake.is_file() {
bail!(
"materialization workspace {} does not contain flake.nix",
workspace.display()
);
}
Ok(())
}
pub fn validate_nix_system(system: &str) -> Result<()> {
match system {
"x86_64-linux" | "aarch64-linux" | "x86_64-darwin" | "aarch64-darwin" => Ok(()),
other => bail!(
"unsupported nix system '{other}'; supported: x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin"
),
}
}
pub fn validate_flake_input_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("flake input name cannot be empty");
}
let mut chars = name.chars();
let first = chars.next().expect("checked non-empty");
if !(first.is_ascii_alphabetic() || first == '_') {
bail!("flake input name '{name}' must start with a letter or underscore");
}
if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
bail!("flake input name '{name}' may only contain [A-Za-z0-9_-]");
}
Ok(())
}
pub fn validate_flake_input_ref(reference: &str) -> Result<()> {
if reference.trim().is_empty() {
bail!("flake input ref cannot be empty");
}
if reference.chars().any(|c| c.is_control() || c.is_whitespace()) {
bail!("flake input ref '{reference}' cannot contain whitespace or control characters");
}
if reference.contains('"')
|| reference.contains('\'')
|| reference.contains('`')
|| reference.contains('$')
|| reference.contains(';')
|| reference.contains('|')
|| reference.contains('&')
|| reference.contains('>')
|| reference.contains('<')
{
bail!("flake input ref '{reference}' contains unsupported shell/template characters");
}
Ok(())
}
pub fn validate_extra_config(fragment: &str) -> Result<()> {
if fragment.contains("builtins.getFlake") || fragment.contains("builtins.fetchGit") {
bail!("extra_config must not use impure flake/git fetches; declare flake_inputs instead");
}
Ok(())
}
pub fn render_nixos_module(payload: &MaterializationPayload) -> String {
let mut lines = Vec::new();
lines.push("{ config, lib, pkgs, inputs, ... }:".to_string());
lines.push(String::new());
lines.push("{".to_string());
if payload.nixos_module.extra_config.is_empty() {
lines.push(" # Generated by nex materialization export.".to_string());
} else {
for fragment in &payload.nixos_module.extra_config {
lines.push(" # Extra NixOS config from materialization payload".to_string());
for line in fragment.trim().lines() {
if line.trim().is_empty() {
lines.push(String::new());
} else {
lines.push(format!(" {line}"));
}
}
lines.push(String::new());
}
}
lines.push("}".to_string());
lines.push(String::new());
lines.join("\n")
}
pub fn render_flake_inputs(inputs: &BTreeMap<String, String>) -> String {
let mut lines = String::new();
for (name, reference) in inputs {
lines.push_str(&format!(" {name}.url = \"{reference}\";\n"));
}
lines
}
pub fn find_nix() -> String {
if std::env::var_os("NEX_TESTING").is_some() {
return "nix".to_string();
}
let candidates = [
"/nix/var/nix/profiles/default/bin/nix",
"/run/current-system/sw/bin/nix",
"/etc/profiles/per-user/default/bin/nix",
];
for path in candidates {
if Path::new(path).exists() {
return path.to_string();
}
}
"nix".to_string()
}
pub fn scaffold_nixos_config_from_source(config_dir: &Path, hostname: &str, source: &Path) -> Result<()> {
match source.extension().and_then(|ext| ext.to_str()) {
Some("toml") => {
let content = std::fs::read_to_string(source)
.with_context(|| format!("reading compatibility materialization TOML {}", source.display()))?;
scaffold_nixos_config(config_dir, hostname, &content)
}
Some("pkl") => {
let payload = MaterializationPayload::from_source(source)?;
scaffold_nixos_config(config_dir, hostname, &payload.to_compat_toml())
}
Some(ext) => bail!("unsupported materialization source extension .{ext}; canonical Nex sources use .pkl (.toml is compatibility/interchange)"),
None => bail!("materialization source path must have an extension; canonical Nex sources use .pkl"),
}
}
#[allow(dead_code)]
pub fn scaffold_nixos_config(config_dir: &Path, hostname: &str, profile_toml: &str) -> Result<()> {
std::fs::create_dir_all(config_dir)?;
let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
let detected_system = crate::discover::detect_system();
let profile: toml::Value = toml::from_str(profile_toml).context("invalid materialization TOML")?;
let payload = MaterializationPayload::from_toml_str(profile_toml)?;
let extra_inputs = render_flake_inputs(&payload.flake_inputs);
let system = payload.system.as_deref().unwrap_or(detected_system);
std::fs::write(
config_dir.join("flake.nix"),
format!(
r#"{{
description = "NixOS configuration — generated by nex forge";
inputs = {{
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
home-manager = {{
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
}};
{extra_inputs} }};
outputs = {{ self, nixpkgs, home-manager, ... }}@inputs:
{{
nixosConfigurations."{hostname}" = nixpkgs.lib.nixosSystem {{
system = "{system}";
specialArgs = {{ inherit inputs; username = "{user}"; hostname = "{hostname}"; }};
modules = [
./configuration.nix
./materialization-module.nix
./hardware-configuration.nix
home-manager.nixosModules.home-manager
{{
home-manager = {{
useGlobalPkgs = true;
useUserPackages = true;
backupFileExtension = "backup";
extraSpecialArgs = {{ username = "{user}"; hostname = "{hostname}"; }};
users."{user}" = import ./home.nix;
}};
}}
];
}};
}};
}}
"#
),
)?;
let mut config_lines = Vec::new();
config_lines.push("{ pkgs, lib, username, hostname, ... }:".to_string());
config_lines.push(String::new());
config_lines.push("{".to_string());
config_lines.push(format!(" networking.hostName = \"{hostname}\";"));
config_lines.push(String::new());
config_lines
.push(" nix.settings.experimental-features = [ \"nix-command\" \"flakes\" ];".to_string());
config_lines.push(" nixpkgs.config.allowUnfree = true;".to_string());
config_lines.push(String::new());
config_lines.push(" boot.loader.systemd-boot.enable = true;".to_string());
config_lines.push(" boot.loader.efi.canTouchEfiVariables = true;".to_string());
config_lines.push(String::new());
config_lines.push(format!(" users.users.\"{user}\" = {{"));
config_lines.push(" isNormalUser = true;".to_string());
config_lines.push(
" extraGroups = [ \"wheel\" \"networkmanager\" \"video\" \"audio\" ];".to_string(),
);
config_lines.push(" shell = pkgs.bash;".to_string());
config_lines.push(" };".to_string());
config_lines.push(String::new());
config_lines.push(" networking.networkmanager.enable = true;".to_string());
config_lines.push(String::new());
config_lines.push(" # time.timeZone is set at install time by polymerize".to_string());
config_lines.push(" i18n.defaultLocale = \"en_US.UTF-8\";".to_string());
config_lines.push(String::new());
if let Some(linux) = profile.get("linux") {
generate_linux_config(&mut config_lines, linux);
}
config_lines.push(" system.stateVersion = \"25.05\";".to_string());
config_lines.push("}".to_string());
config_lines.push(String::new());
std::fs::write(
config_dir.join("configuration.nix"),
config_lines.join("\n"),
)?;
std::fs::write(
config_dir.join("materialization-module.nix"),
render_nixos_module(&payload),
)?;
std::fs::write(
config_dir.join("hardware-configuration.nix"),
r#"# Placeholder — polymerize.sh generates the real one via nixos-generate-config
{ config, lib, pkgs, modulesPath, ... }:
{
imports = [
(modulesPath + "/installer/scan/not-detected.nix")
];
}
"#,
)?;
let mut home_lines = vec![
"{ pkgs, username, ... }:".to_string(),
String::new(),
"{".to_string(),
" home = {".to_string(),
" username = username;".to_string(),
" homeDirectory = \"/home/${username}\";".to_string(),
" stateVersion = \"25.05\";".to_string(),
];
home_lines.push(" };".to_string());
home_lines.push(String::new());
home_lines.push(" home.sessionPath = [".to_string());
home_lines.push(" \"$HOME/.local/bin\"".to_string());
if let Some(paths) = profile
.get("shell")
.and_then(|s| s.get("paths"))
.and_then(|p| p.as_array())
{
for path in paths {
if let Some(path_str) = path.as_str() {
if path_str != "$HOME/.local/bin" && path_str != "~/.local/bin" {
home_lines.push(format!(" \"{path_str}\""));
}
}
}
}
home_lines.push(" ];".to_string());
home_lines.push(String::new());
home_lines.push(" home.packages = with pkgs; [".to_string());
if let Some(pkgs) = profile
.get("packages")
.and_then(|p| p.get("nix"))
.and_then(|n| n.as_array())
{
for pkg in pkgs {
if let Some(name) = pkg.as_str() {
home_lines.push(format!(" {name}"));
}
}
}
home_lines.push(" ];".to_string());
home_lines.push(String::new());
home_lines.push(" programs.home-manager.enable = true;".to_string());
home_lines.push("}".to_string());
home_lines.push(String::new());
std::fs::write(config_dir.join("home.nix"), home_lines.join("\n"))?;
Ok(())
}
pub
fn generate_linux_config(lines: &mut Vec<String>, linux: &toml::Value) {
if let Some(de) = linux.get("desktop").and_then(|v| v.as_str()) {
match de {
"gnome" => {
lines.push(" # Desktop: GNOME".to_string());
lines.push(" services.xserver.enable = true;".to_string());
lines.push(" services.xserver.displayManager.gdm.enable = true;".to_string());
lines.push(" services.xserver.desktopManager.gnome.enable = true;".to_string());
}
"kde" | "plasma" => {
lines.push(" # Desktop: KDE Plasma".to_string());
lines.push(" services.desktopManager.plasma6.enable = true;".to_string());
lines.push(" services.displayManager.sddm.enable = true;".to_string());
lines.push(" services.displayManager.sddm.wayland.enable = true;".to_string());
}
"cosmic" => {
lines.push(" # Desktop: COSMIC".to_string());
lines.push(" services.desktopManager.cosmic.enable = true;".to_string());
lines.push(" services.displayManager.cosmic-greeter.enable = true;".to_string());
}
_ => {}
}
lines.push(String::new());
}
if let Some(gpu) = linux.get("gpu") {
let driver = gpu.get("driver").and_then(|v| v.as_str()).unwrap_or("");
let lib32 = gpu.get("32bit").and_then(|v| v.as_bool()).unwrap_or(false);
let _vulkan = gpu.get("vulkan").and_then(|v| v.as_bool()).unwrap_or(true);
let vaapi = gpu.get("vaapi").and_then(|v| v.as_bool()).unwrap_or(false);
let opencl = gpu.get("opencl").and_then(|v| v.as_bool()).unwrap_or(false);
let drivers: Vec<&str> = driver.split(',').map(|d| d.trim()).collect();
lines.push(" hardware.graphics.enable = true;".to_string());
if lib32 {
lines.push(" hardware.graphics.enable32Bit = true;".to_string());
}
let mut video_drivers: Vec<&str> = Vec::new();
let mut extra_packages: Vec<&str> = Vec::new();
for drv in &drivers {
match *drv {
"amdgpu" => {
lines.push(" # GPU: AMD".to_string());
lines.push(" hardware.amdgpu.initrd.enable = true;".to_string());
if opencl {
lines.push(" hardware.amdgpu.opencl.enable = true;".to_string());
}
if vaapi {
extra_packages.push("libva-vdpau-driver");
}
}
"nvidia" => {
let nvidia_open = gpu
.get("nvidia_open")
.and_then(|v| v.as_bool())
.unwrap_or(true);
lines.push(" # GPU: NVIDIA".to_string());
video_drivers.push("nvidia");
lines.push(" hardware.nvidia.modesetting.enable = true;".to_string());
lines.push(format!(
" hardware.nvidia.open = {};",
if nvidia_open { "true" } else { "false" }
));
}
"nouveau" => {
lines.push(" # GPU: NVIDIA (open-source nouveau)".to_string());
video_drivers.push("nouveau");
}
"intel" => {
lines.push(" # GPU: Intel".to_string());
if vaapi {
extra_packages.push("intel-media-driver");
}
}
"" => {} other => {
lines.push(format!(" # GPU: {other}"));
}
}
}
if !video_drivers.is_empty() {
let drivers_str = video_drivers
.iter()
.map(|d| format!("\"{d}\""))
.collect::<Vec<_>>()
.join(" ");
lines.push(format!(
" services.xserver.videoDrivers = [ {drivers_str} ];"
));
}
if !extra_packages.is_empty() {
lines.push(" hardware.graphics.extraPackages = with pkgs; [".to_string());
for pkg in &extra_packages {
lines.push(format!(" {pkg}"));
}
lines.push(" ];".to_string());
}
lines.push(String::new());
}
if let Some(audio) = linux.get("audio") {
let backend = audio
.get("backend")
.and_then(|v| v.as_str())
.unwrap_or("pipewire");
let low_latency = audio
.get("low_latency")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let bluetooth = audio
.get("bluetooth")
.and_then(|v| v.as_bool())
.unwrap_or(false);
lines.push(" # Audio".to_string());
if backend == "pipewire" {
lines.push(" services.pipewire = {".to_string());
lines.push(" enable = true;".to_string());
lines.push(" alsa.enable = true;".to_string());
lines.push(" alsa.support32Bit = true;".to_string());
lines.push(" pulse.enable = true;".to_string());
if low_latency {
lines.push(" extraConfig.pipewire.\"92-low-latency\" = {".to_string());
lines.push(" \"context.properties\" = { \"default.clock.rate\" = 48000; \"default.clock.quantum\" = 64; };".to_string());
lines.push(" };".to_string());
}
lines.push(" };".to_string());
}
if bluetooth {
lines.push(" hardware.bluetooth.enable = true;".to_string());
lines.push(" hardware.bluetooth.powerOnBoot = true;".to_string());
}
lines.push(String::new());
}
if let Some(gaming) = linux.get("gaming") {
let steam = gaming
.get("steam")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let gamemode = gaming
.get("gamemode")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let gamescope = gaming
.get("gamescope")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let controllers = gaming
.get("controllers")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mangohud = gaming
.get("mangohud")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let _proton_ge = gaming
.get("proton_ge")
.and_then(|v| v.as_bool())
.unwrap_or(false);
lines.push(" # Gaming".to_string());
if steam {
lines.push(" programs.steam = {".to_string());
lines.push(" enable = true;".to_string());
lines.push(format!(
" gamescopeSession.enable = {};",
if gamescope { "true" } else { "false" }
));
lines.push(" };".to_string());
}
if gamemode {
lines.push(" programs.gamemode.enable = true;".to_string());
}
if controllers {
lines.push(" hardware.steam-hardware.enable = true;".to_string());
}
let mut pkgs = Vec::new();
if mangohud {
pkgs.push("mangohud");
}
if !pkgs.is_empty() {
lines.push(" environment.systemPackages = with pkgs; [".to_string());
for p in &pkgs {
lines.push(format!(" {p}"));
}
lines.push(" ];".to_string());
}
lines.push(String::new());
}
if let Some(gnome) = linux.get("gnome") {
lines.push(" # GNOME settings (applied via dconf in home-manager)".to_string());
let dark = gnome
.get("dark_mode")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if dark {
lines.push(" environment.sessionVariables.GTK_THEME = \"Adwaita:dark\";".to_string());
}
if let Some(favs) = gnome.get("favorite_apps").and_then(|v| v.as_array()) {
let apps: Vec<String> = favs
.iter()
.filter_map(|v| v.as_str())
.map(|s| format!("'{s}'"))
.collect();
if !apps.is_empty() {
lines.push(" # GNOME favorite apps — written as dconf db override".to_string());
let apps_str = apps.join(", ");
lines.push(
" environment.etc.\"dconf/db/local.d/01-nex-favorites\".text = ''".to_string(),
);
lines.push(" [org/gnome/shell]".to_string());
lines.push(format!(" favorite-apps=[{apps_str}]"));
if dark {
lines.push(String::new());
lines.push(" [org/gnome/desktop/interface]".to_string());
lines.push(" color-scheme='prefer-dark'".to_string());
lines.push(" gtk-theme='Adwaita-dark'".to_string());
}
lines.push(" '';".to_string());
lines.push(" system.activationScripts.dconf-update = \"dconf update 2>/dev/null || true\";".to_string());
}
}
if let Some(exts) = gnome.get("extensions").and_then(|v| v.as_array()) {
let ext_pkgs: Vec<&str> = exts.iter().filter_map(|v| v.as_str()).collect();
if !ext_pkgs.is_empty() {
lines.push(
" environment.systemPackages = with pkgs.gnomeExtensions; [".to_string(),
);
for ext in &ext_pkgs {
lines.push(format!(" {ext}"));
}
lines.push(" ];".to_string());
}
}
lines.push(String::new());
}
if let Some(cosmic) = linux.get("cosmic") {
lines.push(" # COSMIC desktop settings".to_string());
let dark = cosmic
.get("dark_mode")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let autohide = cosmic
.get("dock_autohide")
.and_then(|v| v.as_bool())
.unwrap_or(false);
lines.push(format!(
" environment.etc.\"skel/.config/cosmic/com.system76.CosmicTheme.Mode/v1/is-dark\".text = \"{}\";",
dark
));
if autohide {
lines.push(
" environment.etc.\"skel/.config/cosmic/com.system76.CosmicPanel.Dock/v1/autohide\".text = \"true\";".to_string()
);
}
if let Some(favs) = cosmic.get("dock_favorites").and_then(|v| v.as_array()) {
let fav_list: Vec<String> = favs
.iter()
.filter_map(|v| v.as_str())
.map(|s| {
if s.ends_with(".desktop") {
s.to_string()
} else {
format!("{s}.desktop")
}
})
.collect();
if !fav_list.is_empty() {
let ron = fav_list
.iter()
.map(|f| format!("\\\"{f}\\\""))
.collect::<Vec<_>>()
.join(", ");
lines.push(format!(
" environment.etc.\"skel/.config/cosmic/com.system76.CosmicAppList/v1/favorites\".text = \"[{ron}]\";",
));
}
}
lines.push(String::new());
}
if let Some(services) = linux.get("services").and_then(|v| v.as_array()) {
let services: Vec<&str> = services.iter().filter_map(|v| v.as_str()).collect();
if !services.is_empty() {
lines.push(" # Extra services".to_string());
for service in services {
lines.push(format!(" services.{service}.enable = true;"));
}
lines.push(String::new());
}
}
if let Some(params) = linux.get("kernel_params").and_then(|v| v.as_array()) {
let params: Vec<String> = params
.iter()
.filter_map(|v| v.as_str())
.map(nix_string)
.collect();
if !params.is_empty() {
lines.push(format!(" boot.kernelParams = [ {} ];", params.join(" ")));
lines.push(String::new());
}
}
if let Some(firewall) = linux.get("firewall") {
if let Some(ports) = firewall.get("allowed_tcp_ports").and_then(|v| v.as_array()) {
let ports: Vec<String> = ports
.iter()
.filter_map(|v| v.as_integer())
.map(|p| p.to_string())
.collect();
if !ports.is_empty() {
lines.push(format!(
" networking.firewall.allowedTCPPorts = [ {} ];",
ports.join(" ")
));
}
}
if let Some(ports) = firewall.get("allowed_udp_ports").and_then(|v| v.as_array()) {
let ports: Vec<String> = ports
.iter()
.filter_map(|v| v.as_integer())
.map(|p| p.to_string())
.collect();
if !ports.is_empty() {
lines.push(format!(
" networking.firewall.allowedUDPPorts = [ {} ];",
ports.join(" ")
));
}
}
lines.push(String::new());
}
if let Some(k3s) = linux.get("k3s") {
let enabled = k3s.get("enable").and_then(|v| v.as_bool()).unwrap_or(true);
if enabled {
lines.push(" # K3s".to_string());
lines.push(" services.k3s = {".to_string());
lines.push(" enable = true;".to_string());
let role = k3s.get("role").and_then(|v| v.as_str()).unwrap_or("server");
lines.push(format!(" role = {};", nix_string(role)));
if let Some(cluster_init) = k3s.get("cluster_init").and_then(|v| v.as_bool()) {
lines.push(format!(
" clusterInit = {};",
if cluster_init { "true" } else { "false" }
));
}
if let Some(disable_agent) = k3s.get("disable_agent").and_then(|v| v.as_bool()) {
lines.push(format!(
" disableAgent = {};",
if disable_agent { "true" } else { "false" }
));
}
if let Some(server_addr) = k3s.get("server_addr").and_then(|v| v.as_str()) {
lines.push(format!(" serverAddr = {};", nix_string(server_addr)));
}
if let Some(token_file) = k3s.get("token_file").and_then(|v| v.as_str()) {
lines.push(format!(" tokenFile = {};", nix_string(token_file)));
}
let mut flags: Vec<String> = Vec::new();
if let Some(disabled) = k3s.get("disable").and_then(|v| v.as_array()) {
for component in disabled.iter().filter_map(|v| v.as_str()) {
flags.push(format!("--disable={component}"));
}
}
if let Some(extra_flags) = k3s.get("extra_flags").and_then(|v| v.as_array()) {
for flag in extra_flags.iter().filter_map(|v| v.as_str()) {
flags.push(flag.to_string());
}
}
if !flags.is_empty() {
lines.push(" extraFlags = [".to_string());
for flag in flags {
lines.push(format!(" {}", nix_string(&flag)));
}
lines.push(" ];".to_string());
}
lines.push(" };".to_string());
lines.push(String::new());
}
}
let mut extra_configs = Vec::new();
if let Some(extra_config) = linux.get("extra_config").and_then(|v| v.as_str()) {
extra_configs.push(extra_config);
}
if let Some(fragments) = linux
.get("extra_config_fragments")
.and_then(|v| v.as_array())
{
for fragment in fragments.iter().filter_map(|v| v.as_str()) {
extra_configs.push(fragment);
}
}
for extra_config in extra_configs {
append_extra_nixos_config(lines, extra_config);
}
}
fn nix_string(value: &str) -> String {
format!("{value:?}")
}
fn append_extra_nixos_config(lines: &mut Vec<String>, extra_config: &str) {
let extra_config = extra_config.trim();
if extra_config.is_empty() {
return;
}
lines.push(" # Extra NixOS config from profile".to_string());
for line in extra_config.lines() {
if line.trim().is_empty() {
lines.push(String::new());
} else {
lines.push(format!(" {}", line));
}
}
lines.push(String::new());
}
#[allow(dead_code)]
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scaffold_nixos_config_includes_materialization_flake_inputs() {
let dir = tempfile::tempdir().unwrap();
let profile = r#"
[flake_inputs]
dns-dhcp = "github:styrene-lab/dhcp-dns-work"
nixos-hardware = "github:NixOS/nixos-hardware"
[linux]
extra_config = ""
"#;
scaffold_nixos_config(dir.path(), "test-host", profile).unwrap();
let flake = std::fs::read_to_string(dir.path().join("flake.nix")).unwrap();
assert!(flake.contains("dns-dhcp.url = \"github:styrene-lab/dhcp-dns-work\";"));
assert!(flake.contains("nixos-hardware.url = \"github:NixOS/nixos-hardware\";"));
assert!(flake.contains("outputs = { self, nixpkgs, home-manager, ... }@inputs:"));
assert!(flake.contains("specialArgs = { inherit inputs; username ="));
assert!(flake.contains("./materialization-module.nix"));
assert!(dir.path().join("materialization-module.nix").is_file());
assert!(!dir.path().join("module.nix").exists());
}
#[test]
fn scaffold_nixos_config_rejects_invalid_flake_inputs() {
let dir = tempfile::tempdir().unwrap();
let profile = r#"
[flake_inputs]
bad = "github:owner/repo;rm-rf"
"#;
let error = scaffold_nixos_config(dir.path(), "test-host", profile)
.expect_err("invalid flake input rejected");
assert!(format!("{error:#}").contains("unsupported shell/template characters"));
}
#[test]
fn builds_nixos_toplevel_attr() {
assert_eq!(
nixos_toplevel_attr("test-host"),
".#nixosConfigurations.test-host.config.system.build.toplevel"
);
}
#[test]
fn command_uses_eval_attr_and_workspace() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("flake.nix"), "{}").expect("write flake");
let check = MaterializationCheck {
workspace: dir.path().to_path_buf(),
hostname: "test-host".to_string(),
target: MaterializationTarget::Toplevel,
};
let command = check.command().expect("valid command");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert_eq!(
args,
vec![
"--extra-experimental-features".to_string(),
"nix-command flakes".to_string(),
"eval".to_string(),
"--no-update-lock-file".to_string(),
"--no-write-lock-file".to_string(),
"--offline".to_string(),
nixos_toplevel_attr("test-host"),
]
);
assert_eq!(command.get_current_dir(), Some(dir.path()));
}
#[test]
fn rejects_invalid_hostname() {
let error = validate_hostname("bad/host").expect_err("invalid hostname rejected");
assert!(format!("{error:#}").contains("hostname must contain only"));
}
#[test]
fn rejects_workspace_without_flake() {
let dir = tempfile::tempdir().expect("tempdir");
let error = validate_workspace(dir.path()).expect_err("missing flake rejected");
assert!(format!("{error:#}").contains("does not contain flake.nix"));
}
#[test]
fn parses_and_renders_flake_inputs() {
let payload = MaterializationPayload::from_toml_str(
r#"
[flake_inputs]
dns-dhcp = "github:styrene-lab/dhcp-dns-work"
nixos-hardware = "github:NixOS/nixos-hardware"
"#,
)
.expect("valid payload");
let rendered = render_flake_inputs(&payload.flake_inputs);
assert!(rendered.contains(" dns-dhcp.url = \"github:styrene-lab/dhcp-dns-work\";"));
assert!(rendered.contains(" nixos-hardware.url = \"github:NixOS/nixos-hardware\";"));
}
#[test]
fn rejects_invalid_flake_input_names() {
let error = MaterializationPayload::from_toml_str(
r#"
[flake_inputs]
"9bad" = "github:owner/repo"
"#,
)
.expect_err("invalid input name rejected");
assert!(format!("{error:#}").contains("must start with a letter or underscore"));
}
#[test]
fn rejects_suspicious_flake_input_refs() {
let error = MaterializationPayload::from_toml_str(
r#"
[flake_inputs]
bad = "github:owner/repo;rm-rf"
"#,
)
.expect_err("invalid input ref rejected");
assert!(format!("{error:#}").contains("unsupported shell/template characters"));
}
#[test]
fn parses_explicit_materialization_system() {
let payload = MaterializationPayload::from_toml_str(
r#"
system = "aarch64-linux"
"#,
)
.expect("valid payload");
assert_eq!(payload.system.as_deref(), Some("aarch64-linux"));
}
#[test]
fn rejects_invalid_materialization_system() {
let error = MaterializationPayload::from_toml_str(
r#"
system = "mips-linux"
"#,
)
.expect_err("invalid system rejected");
assert!(format!("{error:#}").contains("unsupported nix system"));
}
#[test]
fn materialization_target_builds_sd_image_attr() {
let target = MaterializationTarget::parse("sd-image").unwrap();
assert_eq!(
target.attr("test-host"),
".#nixosConfigurations.test-host.config.system.build.sdImage"
);
}
#[test]
fn renders_nixos_module_extra_config() {
let payload = MaterializationPayload {
system: None,
flake_inputs: BTreeMap::new(),
nixos_module: NixosModulePayload {
extra_config: vec!["services.openssh.enable = true;".to_string()],
},
};
let module = render_nixos_module(&payload);
assert!(module.contains("services.openssh.enable = true;"));
}
#[test]
fn rejects_impure_extra_config_fetches() {
let error = MaterializationPayload::from_toml_str(
r#"
[nixos_module]
extra_config = ["let x = builtins.getFlake \"github:owner/repo\"; in {}"]
"#,
)
.expect_err("impure fetch rejected");
assert!(format!("{error:#}").contains("declare flake_inputs instead"));
}
#[test]
fn build_command_uses_deterministic_flags_and_out_link() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("flake.nix"), "{}").expect("write flake");
let build = MaterializationBuild {
workspace: dir.path().to_path_buf(),
hostname: "test-host".to_string(),
target: MaterializationTarget::SdImage,
out_link: dir.path().join("result-sd-image"),
};
let command = build.command().expect("valid command");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>();
assert!(args.contains(&"build".to_string()));
assert!(args.contains(&"--offline".to_string()));
assert!(args.contains(&"--no-update-lock-file".to_string()));
assert!(args.contains(&"--no-write-lock-file".to_string()));
assert!(args.contains(&"--out-link".to_string()));
assert!(args.contains(&nixos_sd_image_attr("test-host")));
}
}