Skip to main content

nex_pkg/
materialization.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{bail, Context, Result};
6
7#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
8pub struct MaterializationPayload {
9    #[serde(default)]
10    pub system: Option<String>,
11    #[serde(default)]
12    pub flake_inputs: BTreeMap<String, String>,
13    #[serde(default)]
14    pub nixos_module: NixosModulePayload,
15}
16
17#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
18pub struct NixosModulePayload {
19    #[serde(default)]
20    pub extra_config: Vec<String>,
21}
22
23impl MaterializationPayload {
24    pub fn from_source(path: &Path) -> Result<Self> {
25        let loaded = crate::document::load_document::<Self>(path, "materialization payload")?;
26        loaded.value.validate()?;
27        Ok(loaded.value)
28    }
29
30    pub fn from_toml_str(content: &str) -> Result<Self> {
31        let payload: Self =
32            toml::from_str(content).context("invalid compatibility materialization TOML")?;
33        payload.validate()?;
34        Ok(payload)
35    }
36
37    pub fn to_compat_toml(&self) -> String {
38        if self.flake_inputs.is_empty() {
39            return String::new();
40        }
41        let mut lines = Vec::new();
42        if let Some(system) = &self.system {
43            lines.push(format!("system = {system:?}"));
44            if !self.flake_inputs.is_empty() || !self.nixos_module.extra_config.is_empty() {
45                lines.push(String::new());
46            }
47        }
48        if !self.flake_inputs.is_empty() {
49            lines.push("[flake_inputs]".to_string());
50            for (name, reference) in &self.flake_inputs {
51                lines.push(format!("{name} = {reference:?}"));
52            }
53        }
54        if !self.nixos_module.extra_config.is_empty() {
55            if !lines.is_empty() {
56                lines.push(String::new());
57            }
58            lines.push("[nixos_module]".to_string());
59            lines.push("extra_config = [".to_string());
60            for fragment in &self.nixos_module.extra_config {
61                lines.push(format!("  {fragment:?},"));
62            }
63            lines.push("]".to_string());
64        }
65        lines.join("\n")
66    }
67
68    pub fn validate(&self) -> Result<()> {
69        if let Some(system) = &self.system {
70            validate_nix_system(system)?;
71        }
72        for (name, reference) in &self.flake_inputs {
73            validate_flake_input_name(name)?;
74            validate_flake_input_ref(reference)?;
75        }
76        for fragment in &self.nixos_module.extra_config {
77            validate_extra_config(fragment)?;
78        }
79        Ok(())
80    }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum MaterializationTarget {
85    Toplevel,
86    SdImage,
87}
88
89impl MaterializationTarget {
90    pub fn parse(value: &str) -> Result<Self> {
91        match value {
92            "toplevel" => Ok(Self::Toplevel),
93            "sd-image" => Ok(Self::SdImage),
94            other => {
95                bail!("unsupported materialization target '{other}'; supported: toplevel, sd-image")
96            }
97        }
98    }
99
100    pub fn attr(self, hostname: &str) -> String {
101        match self {
102            Self::Toplevel => nixos_toplevel_attr(hostname),
103            Self::SdImage => nixos_sd_image_attr(hostname),
104        }
105    }
106}
107
108#[derive(Debug, Clone)]
109pub struct MaterializationCheck {
110    pub workspace: PathBuf,
111    pub hostname: String,
112    pub target: MaterializationTarget,
113}
114
115impl MaterializationCheck {
116    pub fn eval_attr(&self) -> String {
117        self.target.attr(&self.hostname)
118    }
119
120    pub fn command(&self) -> Result<Command> {
121        validate_hostname(&self.hostname)?;
122        validate_workspace(&self.workspace)?;
123
124        let mut command = Command::new(find_nix());
125        command
126            .env("NIX_CONFIG", "experimental-features = nix-command flakes")
127            .env("NIX_SHOW_STATS", "0")
128            .args([
129                "--extra-experimental-features",
130                "nix-command flakes",
131                "eval",
132                "--no-update-lock-file",
133                "--no-write-lock-file",
134                "--offline",
135            ])
136            .arg(self.eval_attr())
137            .current_dir(&self.workspace);
138        Ok(command)
139    }
140
141    pub fn run(&self) -> Result<()> {
142        let output = self
143            .command()?
144            .output()
145            .with_context(|| format!("running nix eval in {}", self.workspace.display()))?;
146
147        if output.status.success() {
148            return Ok(());
149        }
150
151        let stderr = crate::exec::captured_text(&output.stderr);
152        let stdout = crate::exec::captured_text(&output.stdout);
153        bail!(
154            "materialization check failed for {}\n{}{}",
155            self.eval_attr(),
156            stdout,
157            stderr
158        );
159    }
160}
161
162#[derive(Debug, Clone)]
163pub struct MaterializationBuild {
164    pub workspace: PathBuf,
165    pub hostname: String,
166    pub target: MaterializationTarget,
167    pub out_link: PathBuf,
168}
169
170impl MaterializationBuild {
171    pub fn eval_attr(&self) -> String {
172        self.target.attr(&self.hostname)
173    }
174
175    pub fn command(&self) -> Result<Command> {
176        validate_hostname(&self.hostname)?;
177        validate_workspace(&self.workspace)?;
178        if let Some(parent) = self.out_link.parent() {
179            std::fs::create_dir_all(parent)
180                .with_context(|| format!("creating {}", parent.display()))?;
181        }
182        let mut command = Command::new(find_nix());
183        command
184            .env("NIX_CONFIG", "experimental-features = nix-command flakes")
185            .env("NIX_SHOW_STATS", "0")
186            .args([
187                "--extra-experimental-features",
188                "nix-command flakes",
189                "build",
190                "--no-update-lock-file",
191                "--no-write-lock-file",
192                "--offline",
193                "--out-link",
194            ])
195            .arg(&self.out_link)
196            .arg(self.eval_attr())
197            .current_dir(&self.workspace);
198        Ok(command)
199    }
200
201    pub fn run(&self) -> Result<()> {
202        let check = MaterializationCheck {
203            workspace: self.workspace.clone(),
204            hostname: self.hostname.clone(),
205            target: self.target,
206        };
207        check.run()?;
208
209        let output = self
210            .command()?
211            .output()
212            .with_context(|| format!("running nix build in {}", self.workspace.display()))?;
213        if output.status.success() {
214            return Ok(());
215        }
216        let stderr = crate::exec::captured_text(&output.stderr);
217        let stdout = crate::exec::captured_text(&output.stdout);
218        bail!(
219            "materialization build failed for {}\n{}{}",
220            self.eval_attr(),
221            stdout,
222            stderr
223        );
224    }
225}
226
227#[derive(Debug, Clone)]
228pub struct NixosModuleExport {
229    pub workspace: PathBuf,
230    pub name: String,
231}
232
233impl NixosModuleExport {
234    pub fn write(&self, payload: &MaterializationPayload) -> Result<()> {
235        validate_module_name(&self.name)?;
236        std::fs::create_dir_all(&self.workspace)?;
237        std::fs::write(
238            self.workspace.join("module.nix"),
239            render_nixos_module(payload),
240        )?;
241        std::fs::write(
242            self.workspace.join("flake.nix"),
243            format!(
244                r#"{{
245  description = "Nex materialization module export";
246
247  outputs = {{ self, ... }}:
248  {{
249    nixosModules.{name} = import ./module.nix;
250  }};
251}}
252"#,
253                name = self.name
254            ),
255        )?;
256        Ok(())
257    }
258}
259
260pub fn validate_module_name(name: &str) -> Result<()> {
261    if name.is_empty() {
262        bail!("module name cannot be empty");
263    }
264    let mut chars = name.chars();
265    let first = chars.next().expect("checked non-empty");
266    if !(first.is_ascii_alphabetic() || first == '_') {
267        bail!("module name '{name}' must start with a letter or underscore");
268    }
269    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
270        bail!("module name '{name}' may only contain [A-Za-z0-9_-]");
271    }
272    Ok(())
273}
274
275pub fn nixos_toplevel_attr(hostname: &str) -> String {
276    format!(".#nixosConfigurations.{hostname}.config.system.build.toplevel")
277}
278
279pub fn nixos_sd_image_attr(hostname: &str) -> String {
280    format!(".#nixosConfigurations.{hostname}.config.system.build.sdImage")
281}
282
283pub fn validate_hostname(hostname: &str) -> Result<()> {
284    if hostname.is_empty() || hostname.len() > 63 {
285        bail!("hostname must be 1-63 characters");
286    }
287    if hostname.starts_with('-') || hostname.ends_with('-') {
288        bail!("hostname cannot start or end with a hyphen");
289    }
290    if !hostname
291        .chars()
292        .all(|c| c.is_ascii_alphanumeric() || c == '-')
293    {
294        bail!("hostname must contain only ASCII letters, digits, and hyphens");
295    }
296    Ok(())
297}
298
299pub fn validate_workspace(workspace: &Path) -> Result<()> {
300    if !workspace.is_dir() {
301        bail!(
302            "materialization workspace does not exist: {}",
303            workspace.display()
304        );
305    }
306    let flake = workspace.join("flake.nix");
307    if !flake.is_file() {
308        bail!(
309            "materialization workspace {} does not contain flake.nix",
310            workspace.display()
311        );
312    }
313    Ok(())
314}
315
316pub fn validate_nix_system(system: &str) -> Result<()> {
317    match system {
318        "x86_64-linux" | "aarch64-linux" | "x86_64-darwin" | "aarch64-darwin" => Ok(()),
319        other => bail!(
320            "unsupported nix system '{other}'; supported: x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin"
321        ),
322    }
323}
324
325pub fn validate_flake_input_name(name: &str) -> Result<()> {
326    if name.is_empty() {
327        bail!("flake input name cannot be empty");
328    }
329    let mut chars = name.chars();
330    let first = chars.next().expect("checked non-empty");
331    if !(first.is_ascii_alphabetic() || first == '_') {
332        bail!("flake input name '{name}' must start with a letter or underscore");
333    }
334    if !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') {
335        bail!("flake input name '{name}' may only contain [A-Za-z0-9_-]");
336    }
337    Ok(())
338}
339
340pub fn validate_flake_input_ref(reference: &str) -> Result<()> {
341    if reference.trim().is_empty() {
342        bail!("flake input ref cannot be empty");
343    }
344    if reference
345        .chars()
346        .any(|c| c.is_control() || c.is_whitespace())
347    {
348        bail!("flake input ref '{reference}' cannot contain whitespace or control characters");
349    }
350    if reference.contains('"')
351        || reference.contains('\'')
352        || reference.contains('`')
353        || reference.contains('$')
354        || reference.contains(';')
355        || reference.contains('|')
356        || reference.contains('&')
357        || reference.contains('>')
358        || reference.contains('<')
359    {
360        bail!("flake input ref '{reference}' contains unsupported shell/template characters");
361    }
362    Ok(())
363}
364
365pub fn validate_extra_config(fragment: &str) -> Result<()> {
366    if fragment.contains("builtins.getFlake") || fragment.contains("builtins.fetchGit") {
367        bail!("extra_config must not use impure flake/git fetches; declare flake_inputs instead");
368    }
369    Ok(())
370}
371
372pub fn render_nixos_module(payload: &MaterializationPayload) -> String {
373    let mut lines = Vec::new();
374    lines.push("{ config, lib, pkgs, inputs, ... }:".to_string());
375    lines.push(String::new());
376    lines.push("{".to_string());
377    if payload.nixos_module.extra_config.is_empty() {
378        lines.push("  # Generated by nex materialization export.".to_string());
379    } else {
380        for fragment in &payload.nixos_module.extra_config {
381            lines.push("  # Extra NixOS config from materialization payload".to_string());
382            for line in fragment.trim().lines() {
383                if line.trim().is_empty() {
384                    lines.push(String::new());
385                } else {
386                    lines.push(format!("  {line}"));
387                }
388            }
389            lines.push(String::new());
390        }
391    }
392    lines.push("}".to_string());
393    lines.push(String::new());
394    lines.join("\n")
395}
396
397pub fn render_flake_inputs(inputs: &BTreeMap<String, String>) -> String {
398    let mut lines = String::new();
399    for (name, reference) in inputs {
400        lines.push_str(&format!("    {name}.url = \"{reference}\";\n"));
401    }
402    lines
403}
404
405pub fn find_nix() -> String {
406    if std::env::var_os("NEX_TESTING").is_some() {
407        return "nix".to_string();
408    }
409
410    let candidates = [
411        "/nix/var/nix/profiles/default/bin/nix",
412        "/run/current-system/sw/bin/nix",
413        "/etc/profiles/per-user/default/bin/nix",
414    ];
415    for path in candidates {
416        if Path::new(path).exists() {
417            return path.to_string();
418        }
419    }
420    "nix".to_string()
421}
422
423/// Scaffold a minimal NixOS configuration from a canonical Pkl source or compatibility TOML source.
424pub fn scaffold_nixos_config_from_source(
425    config_dir: &Path,
426    hostname: &str,
427    source: &Path,
428) -> Result<()> {
429    match source.extension().and_then(|ext| ext.to_str()) {
430        Some("toml") => {
431            let content = std::fs::read_to_string(source)
432                .with_context(|| format!("reading compatibility materialization TOML {}", source.display()))?;
433            scaffold_nixos_config(config_dir, hostname, &content)
434        }
435        Some("pkl") => {
436            let payload = MaterializationPayload::from_source(source)?;
437            scaffold_nixos_config(config_dir, hostname, &payload.to_compat_toml())
438        }
439        Some(ext) => bail!("unsupported materialization source extension .{ext}; canonical Nex sources use .pkl (.toml is compatibility/interchange)"),
440        None => bail!("materialization source path must have an extension; canonical Nex sources use .pkl"),
441    }
442}
443
444/// Scaffold a minimal NixOS configuration for the installer.
445/// Retained for compatibility TOML interchange and tests.
446#[allow(dead_code)]
447pub fn scaffold_nixos_config(config_dir: &Path, hostname: &str, profile_toml: &str) -> Result<()> {
448    std::fs::create_dir_all(config_dir)?;
449
450    let user = std::env::var("USER").unwrap_or_else(|_| "user".to_string());
451    let detected_system = crate::discover::detect_system();
452
453    // Parse profile to extract linux settings and materialization metadata.
454    let profile: toml::Value =
455        toml::from_str(profile_toml).context("invalid materialization TOML")?;
456    let payload = MaterializationPayload::from_toml_str(profile_toml)?;
457    let extra_inputs = render_flake_inputs(&payload.flake_inputs);
458    let system = payload.system.as_deref().unwrap_or(detected_system);
459
460    // flake.nix
461    std::fs::write(
462        config_dir.join("flake.nix"),
463        format!(
464            r#"{{
465  description = "NixOS configuration — generated by nex forge";
466
467  inputs = {{
468    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
469    home-manager = {{
470      url = "github:nix-community/home-manager";
471      inputs.nixpkgs.follows = "nixpkgs";
472    }};
473{extra_inputs}  }};
474
475  outputs = {{ self, nixpkgs, home-manager, ... }}@inputs:
476  {{
477    nixosConfigurations."{hostname}" = nixpkgs.lib.nixosSystem {{
478      system = "{system}";
479      specialArgs = {{ inherit inputs; username = "{user}"; hostname = "{hostname}"; }};
480      modules = [
481        ./configuration.nix
482        ./materialization-module.nix
483        ./hardware-configuration.nix
484        home-manager.nixosModules.home-manager
485        {{
486          home-manager = {{
487            useGlobalPkgs = true;
488            useUserPackages = true;
489            backupFileExtension = "backup";
490            extraSpecialArgs = {{ username = "{user}"; hostname = "{hostname}"; }};
491            users."{user}" = import ./home.nix;
492          }};
493        }}
494      ];
495    }};
496  }};
497}}
498"#
499        ),
500    )?;
501
502    // configuration.nix — system-level config generated from profile
503    let mut config_lines = Vec::new();
504    config_lines.push("{ pkgs, lib, username, hostname, ... }:".to_string());
505    config_lines.push(String::new());
506    config_lines.push("{".to_string());
507    config_lines.push(format!("  networking.hostName = \"{hostname}\";"));
508    config_lines.push(String::new());
509
510    // Nix settings
511    config_lines
512        .push("  nix.settings.experimental-features = [ \"nix-command\" \"flakes\" ];".to_string());
513    config_lines.push("  nixpkgs.config.allowUnfree = true;".to_string());
514    config_lines.push(String::new());
515
516    // Boot
517    config_lines.push("  boot.loader.systemd-boot.enable = true;".to_string());
518    config_lines.push("  boot.loader.efi.canTouchEfiVariables = true;".to_string());
519    config_lines.push(String::new());
520
521    // User
522    config_lines.push(format!("  users.users.\"{user}\" = {{"));
523    config_lines.push("    isNormalUser = true;".to_string());
524    config_lines.push(
525        "    extraGroups = [ \"wheel\" \"networkmanager\" \"video\" \"audio\" ];".to_string(),
526    );
527    config_lines.push("    shell = pkgs.bash;".to_string());
528    config_lines.push("  };".to_string());
529    config_lines.push(String::new());
530
531    // Networking
532    config_lines.push("  networking.networkmanager.enable = true;".to_string());
533    config_lines.push(String::new());
534
535    // Locale / timezone
536    config_lines.push("  # time.timeZone is set at install time by polymerize".to_string());
537    config_lines.push("  i18n.defaultLocale = \"en_US.UTF-8\";".to_string());
538    config_lines.push(String::new());
539
540    // Generate from [linux] section of profile
541    if let Some(linux) = profile.get("linux") {
542        generate_linux_config(&mut config_lines, linux);
543    }
544
545    config_lines.push("  system.stateVersion = \"25.05\";".to_string());
546    config_lines.push("}".to_string());
547    config_lines.push(String::new());
548
549    std::fs::write(
550        config_dir.join("configuration.nix"),
551        config_lines.join("\n"),
552    )?;
553
554    std::fs::write(
555        config_dir.join("materialization-module.nix"),
556        render_nixos_module(&payload),
557    )?;
558
559    // hardware-configuration.nix — placeholder, polymerize will generate the real one
560    std::fs::write(
561        config_dir.join("hardware-configuration.nix"),
562        r#"# Placeholder — polymerize.sh generates the real one via nixos-generate-config
563{ config, lib, pkgs, modulesPath, ... }:
564
565{
566  imports = [
567    (modulesPath + "/installer/scan/not-detected.nix")
568  ];
569}
570"#,
571    )?;
572
573    // home.nix — user-level config from profile packages
574    let mut home_lines = vec![
575        "{ pkgs, username, ... }:".to_string(),
576        String::new(),
577        "{".to_string(),
578        "  home = {".to_string(),
579        "    username = username;".to_string(),
580        "    homeDirectory = \"/home/${username}\";".to_string(),
581        "    stateVersion = \"25.05\";".to_string(),
582    ];
583    home_lines.push("  };".to_string());
584    home_lines.push(String::new());
585    home_lines.push("  home.sessionPath = [".to_string());
586    home_lines.push("    \"$HOME/.local/bin\"".to_string());
587    if let Some(paths) = profile
588        .get("shell")
589        .and_then(|s| s.get("paths"))
590        .and_then(|p| p.as_array())
591    {
592        for path in paths {
593            if let Some(path_str) = path.as_str() {
594                if path_str != "$HOME/.local/bin" && path_str != "~/.local/bin" {
595                    home_lines.push(format!("    \"{path_str}\""));
596                }
597            }
598        }
599    }
600    home_lines.push("  ];".to_string());
601    home_lines.push(String::new());
602
603    // Packages from profile
604    home_lines.push("  home.packages = with pkgs; [".to_string());
605    if let Some(pkgs) = profile
606        .get("packages")
607        .and_then(|p| p.get("nix"))
608        .and_then(|n| n.as_array())
609    {
610        for pkg in pkgs {
611            if let Some(name) = pkg.as_str() {
612                home_lines.push(format!("    {name}"));
613            }
614        }
615    }
616    home_lines.push("  ];".to_string());
617    home_lines.push(String::new());
618    home_lines.push("  programs.home-manager.enable = true;".to_string());
619    home_lines.push("}".to_string());
620    home_lines.push(String::new());
621
622    std::fs::write(config_dir.join("home.nix"), home_lines.join("\n"))?;
623
624    Ok(())
625}
626
627/// Generate NixOS config lines from the [linux] section of a profile.
628/// Public so `polymerize` can reuse the same generation logic.
629pub fn generate_linux_config(lines: &mut Vec<String>, linux: &toml::Value) {
630    // Desktop environment
631    if let Some(de) = linux.get("desktop").and_then(|v| v.as_str()) {
632        match de {
633            "gnome" => {
634                lines.push("  # Desktop: GNOME".to_string());
635                lines.push("  services.xserver.enable = true;".to_string());
636                lines.push("  services.xserver.displayManager.gdm.enable = true;".to_string());
637                lines.push("  services.xserver.desktopManager.gnome.enable = true;".to_string());
638            }
639            "kde" | "plasma" => {
640                lines.push("  # Desktop: KDE Plasma".to_string());
641                lines.push("  services.desktopManager.plasma6.enable = true;".to_string());
642                lines.push("  services.displayManager.sddm.enable = true;".to_string());
643                lines.push("  services.displayManager.sddm.wayland.enable = true;".to_string());
644            }
645            "cosmic" => {
646                lines.push("  # Desktop: COSMIC".to_string());
647                lines.push("  services.desktopManager.cosmic.enable = true;".to_string());
648                lines.push("  services.displayManager.cosmic-greeter.enable = true;".to_string());
649            }
650            _ => {}
651        }
652        lines.push(String::new());
653    }
654
655    // GPU
656    if let Some(gpu) = linux.get("gpu") {
657        let driver = gpu.get("driver").and_then(|v| v.as_str()).unwrap_or("");
658        let lib32 = gpu.get("32bit").and_then(|v| v.as_bool()).unwrap_or(false);
659        let _vulkan = gpu.get("vulkan").and_then(|v| v.as_bool()).unwrap_or(true);
660        let vaapi = gpu.get("vaapi").and_then(|v| v.as_bool()).unwrap_or(false);
661        let opencl = gpu.get("opencl").and_then(|v| v.as_bool()).unwrap_or(false);
662
663        // Multiple drivers can be specified comma-separated: "amdgpu,nvidia"
664        let drivers: Vec<&str> = driver.split(',').map(|d| d.trim()).collect();
665
666        lines.push("  hardware.graphics.enable = true;".to_string());
667        if lib32 {
668            lines.push("  hardware.graphics.enable32Bit = true;".to_string());
669        }
670
671        let mut video_drivers: Vec<&str> = Vec::new();
672        let mut extra_packages: Vec<&str> = Vec::new();
673
674        for drv in &drivers {
675            match *drv {
676                "amdgpu" => {
677                    lines.push("  # GPU: AMD".to_string());
678                    lines.push("  hardware.amdgpu.initrd.enable = true;".to_string());
679                    if opencl {
680                        lines.push("  hardware.amdgpu.opencl.enable = true;".to_string());
681                    }
682                    if vaapi {
683                        extra_packages.push("libva-vdpau-driver");
684                    }
685                }
686                "nvidia" => {
687                    let nvidia_open = gpu
688                        .get("nvidia_open")
689                        .and_then(|v| v.as_bool())
690                        .unwrap_or(true);
691                    lines.push("  # GPU: NVIDIA".to_string());
692                    video_drivers.push("nvidia");
693                    lines.push("  hardware.nvidia.modesetting.enable = true;".to_string());
694                    lines.push(format!(
695                        "  hardware.nvidia.open = {};",
696                        if nvidia_open { "true" } else { "false" }
697                    ));
698                }
699                "nouveau" => {
700                    lines.push("  # GPU: NVIDIA (open-source nouveau)".to_string());
701                    video_drivers.push("nouveau");
702                }
703                "intel" => {
704                    lines.push("  # GPU: Intel".to_string());
705                    // Intel i915 is loaded automatically by the kernel
706                    if vaapi {
707                        extra_packages.push("intel-media-driver");
708                    }
709                }
710                "" => {} // no driver specified, just enable graphics
711                other => {
712                    lines.push(format!("  # GPU: {other}"));
713                }
714            }
715        }
716
717        if !video_drivers.is_empty() {
718            let drivers_str = video_drivers
719                .iter()
720                .map(|d| format!("\"{d}\""))
721                .collect::<Vec<_>>()
722                .join(" ");
723            lines.push(format!(
724                "  services.xserver.videoDrivers = [ {drivers_str} ];"
725            ));
726        }
727
728        if !extra_packages.is_empty() {
729            lines.push("  hardware.graphics.extraPackages = with pkgs; [".to_string());
730            for pkg in &extra_packages {
731                lines.push(format!("    {pkg}"));
732            }
733            lines.push("  ];".to_string());
734        }
735        lines.push(String::new());
736    }
737
738    // Audio
739    if let Some(audio) = linux.get("audio") {
740        let backend = audio
741            .get("backend")
742            .and_then(|v| v.as_str())
743            .unwrap_or("pipewire");
744        let low_latency = audio
745            .get("low_latency")
746            .and_then(|v| v.as_bool())
747            .unwrap_or(false);
748        let bluetooth = audio
749            .get("bluetooth")
750            .and_then(|v| v.as_bool())
751            .unwrap_or(false);
752
753        lines.push("  # Audio".to_string());
754        if backend == "pipewire" {
755            lines.push("  services.pipewire = {".to_string());
756            lines.push("    enable = true;".to_string());
757            lines.push("    alsa.enable = true;".to_string());
758            lines.push("    alsa.support32Bit = true;".to_string());
759            lines.push("    pulse.enable = true;".to_string());
760            if low_latency {
761                lines.push("    extraConfig.pipewire.\"92-low-latency\" = {".to_string());
762                lines.push("      \"context.properties\" = { \"default.clock.rate\" = 48000; \"default.clock.quantum\" = 64; };".to_string());
763                lines.push("    };".to_string());
764            }
765            lines.push("  };".to_string());
766        }
767        if bluetooth {
768            lines.push("  hardware.bluetooth.enable = true;".to_string());
769            lines.push("  hardware.bluetooth.powerOnBoot = true;".to_string());
770        }
771        lines.push(String::new());
772    }
773
774    // Gaming
775    if let Some(gaming) = linux.get("gaming") {
776        let steam = gaming
777            .get("steam")
778            .and_then(|v| v.as_bool())
779            .unwrap_or(false);
780        let gamemode = gaming
781            .get("gamemode")
782            .and_then(|v| v.as_bool())
783            .unwrap_or(false);
784        let gamescope = gaming
785            .get("gamescope")
786            .and_then(|v| v.as_bool())
787            .unwrap_or(false);
788        let controllers = gaming
789            .get("controllers")
790            .and_then(|v| v.as_bool())
791            .unwrap_or(false);
792        let mangohud = gaming
793            .get("mangohud")
794            .and_then(|v| v.as_bool())
795            .unwrap_or(false);
796        let _proton_ge = gaming
797            .get("proton_ge")
798            .and_then(|v| v.as_bool())
799            .unwrap_or(false);
800
801        lines.push("  # Gaming".to_string());
802        if steam {
803            lines.push("  programs.steam = {".to_string());
804            lines.push("    enable = true;".to_string());
805            lines.push(format!(
806                "    gamescopeSession.enable = {};",
807                if gamescope { "true" } else { "false" }
808            ));
809            lines.push("  };".to_string());
810        }
811        if gamemode {
812            lines.push("  programs.gamemode.enable = true;".to_string());
813        }
814        if controllers {
815            lines.push("  hardware.steam-hardware.enable = true;".to_string());
816        }
817
818        let mut pkgs = Vec::new();
819        if mangohud {
820            pkgs.push("mangohud");
821        }
822        // proton-ge-bin is installed via Steam's compatibility tools, not as a system package
823        // if proton_ge { pkgs.push("proton-ge-bin"); }
824        if !pkgs.is_empty() {
825            lines.push("  environment.systemPackages = with pkgs; [".to_string());
826            for p in &pkgs {
827                lines.push(format!("    {p}"));
828            }
829            lines.push("  ];".to_string());
830        }
831        lines.push(String::new());
832    }
833
834    // GNOME dconf settings (via home-manager)
835    if let Some(gnome) = linux.get("gnome") {
836        lines.push("  # GNOME settings (applied via dconf in home-manager)".to_string());
837
838        let dark = gnome
839            .get("dark_mode")
840            .and_then(|v| v.as_bool())
841            .unwrap_or(false);
842        if dark {
843            // NixOS-level setting for GNOME dark mode
844            lines.push("  environment.sessionVariables.GTK_THEME = \"Adwaita:dark\";".to_string());
845        }
846
847        // Favorite apps for the GNOME dock (via dconf)
848        if let Some(favs) = gnome.get("favorite_apps").and_then(|v| v.as_array()) {
849            let apps: Vec<String> = favs
850                .iter()
851                .filter_map(|v| v.as_str())
852                .map(|s| format!("'{s}'"))
853                .collect();
854            if !apps.is_empty() {
855                // This needs to go in home-manager's dconf settings, but since we're
856                // generating system-level config here, we use environment.etc to write
857                // a dconf profile that gets applied on login.
858                lines.push("  # GNOME favorite apps — written as dconf db override".to_string());
859                let apps_str = apps.join(", ");
860                lines.push(
861                    "  environment.etc.\"dconf/db/local.d/01-nex-favorites\".text = ''".to_string(),
862                );
863                lines.push("    [org/gnome/shell]".to_string());
864                lines.push(format!("    favorite-apps=[{apps_str}]"));
865                if dark {
866                    lines.push(String::new());
867                    lines.push("    [org/gnome/desktop/interface]".to_string());
868                    lines.push("    color-scheme='prefer-dark'".to_string());
869                    lines.push("    gtk-theme='Adwaita-dark'".to_string());
870                }
871                lines.push("  '';".to_string());
872                // Need dconf update to apply
873                lines.push("  system.activationScripts.dconf-update = \"dconf update 2>/dev/null || true\";".to_string());
874            }
875        }
876
877        // Extensions
878        if let Some(exts) = gnome.get("extensions").and_then(|v| v.as_array()) {
879            let ext_pkgs: Vec<&str> = exts.iter().filter_map(|v| v.as_str()).collect();
880            if !ext_pkgs.is_empty() {
881                // NixOS module system merges multiple environment.systemPackages declarations
882                lines.push(
883                    "  environment.systemPackages = with pkgs.gnomeExtensions; [".to_string(),
884                );
885                for ext in &ext_pkgs {
886                    lines.push(format!("    {ext}"));
887                }
888                lines.push("  ];".to_string());
889            }
890        }
891
892        lines.push(String::new());
893    }
894
895    // COSMIC desktop settings
896    if let Some(cosmic) = linux.get("cosmic") {
897        lines.push("  # COSMIC desktop settings".to_string());
898
899        let dark = cosmic
900            .get("dark_mode")
901            .and_then(|v| v.as_bool())
902            .unwrap_or(true);
903        let autohide = cosmic
904            .get("dock_autohide")
905            .and_then(|v| v.as_bool())
906            .unwrap_or(false);
907
908        // COSMIC uses RON config files in ~/.config/cosmic/
909        // Write as /etc/skel entries so new users get them on first login.
910        lines.push(format!(
911            "  environment.etc.\"skel/.config/cosmic/com.system76.CosmicTheme.Mode/v1/is-dark\".text = \"{}\";",
912            dark
913        ));
914
915        if autohide {
916            lines.push(
917                "  environment.etc.\"skel/.config/cosmic/com.system76.CosmicPanel.Dock/v1/autohide\".text = \"true\";".to_string()
918            );
919        }
920
921        if let Some(favs) = cosmic.get("dock_favorites").and_then(|v| v.as_array()) {
922            let fav_list: Vec<String> = favs
923                .iter()
924                .filter_map(|v| v.as_str())
925                .map(|s| {
926                    if s.ends_with(".desktop") {
927                        s.to_string()
928                    } else {
929                        format!("{s}.desktop")
930                    }
931                })
932                .collect();
933            if !fav_list.is_empty() {
934                // Inner quotes must be escaped for Nix: \" inside "..."
935                let ron = fav_list
936                    .iter()
937                    .map(|f| format!("\\\"{f}\\\""))
938                    .collect::<Vec<_>>()
939                    .join(", ");
940                lines.push(format!(
941                    "  environment.etc.\"skel/.config/cosmic/com.system76.CosmicAppList/v1/favorites\".text = \"[{ron}]\";",
942                ));
943            }
944        }
945
946        lines.push(String::new());
947    }
948
949    // Extra NixOS services
950    if let Some(services) = linux.get("services").and_then(|v| v.as_array()) {
951        let services: Vec<&str> = services.iter().filter_map(|v| v.as_str()).collect();
952        if !services.is_empty() {
953            lines.push("  # Extra services".to_string());
954            for service in services {
955                lines.push(format!("  services.{service}.enable = true;"));
956            }
957            lines.push(String::new());
958        }
959    }
960
961    // Kernel parameters
962    if let Some(params) = linux.get("kernel_params").and_then(|v| v.as_array()) {
963        let params: Vec<String> = params
964            .iter()
965            .filter_map(|v| v.as_str())
966            .map(nix_string)
967            .collect();
968        if !params.is_empty() {
969            lines.push(format!("  boot.kernelParams = [ {} ];", params.join(" ")));
970            lines.push(String::new());
971        }
972    }
973
974    // Firewall ports
975    if let Some(firewall) = linux.get("firewall") {
976        if let Some(ports) = firewall.get("allowed_tcp_ports").and_then(|v| v.as_array()) {
977            let ports: Vec<String> = ports
978                .iter()
979                .filter_map(|v| v.as_integer())
980                .map(|p| p.to_string())
981                .collect();
982            if !ports.is_empty() {
983                lines.push(format!(
984                    "  networking.firewall.allowedTCPPorts = [ {} ];",
985                    ports.join(" ")
986                ));
987            }
988        }
989        if let Some(ports) = firewall.get("allowed_udp_ports").and_then(|v| v.as_array()) {
990            let ports: Vec<String> = ports
991                .iter()
992                .filter_map(|v| v.as_integer())
993                .map(|p| p.to_string())
994                .collect();
995            if !ports.is_empty() {
996                lines.push(format!(
997                    "  networking.firewall.allowedUDPPorts = [ {} ];",
998                    ports.join(" ")
999                ));
1000            }
1001        }
1002        lines.push(String::new());
1003    }
1004
1005    // K3s substrate support. Profiles may point at a token file, but should not
1006    // embed the token itself because Nix store paths are world-readable.
1007    if let Some(k3s) = linux.get("k3s") {
1008        let enabled = k3s.get("enable").and_then(|v| v.as_bool()).unwrap_or(true);
1009        if enabled {
1010            lines.push("  # K3s".to_string());
1011            lines.push("  services.k3s = {".to_string());
1012            lines.push("    enable = true;".to_string());
1013            let role = k3s.get("role").and_then(|v| v.as_str()).unwrap_or("server");
1014            lines.push(format!("    role = {};", nix_string(role)));
1015
1016            if let Some(cluster_init) = k3s.get("cluster_init").and_then(|v| v.as_bool()) {
1017                lines.push(format!(
1018                    "    clusterInit = {};",
1019                    if cluster_init { "true" } else { "false" }
1020                ));
1021            }
1022            if let Some(disable_agent) = k3s.get("disable_agent").and_then(|v| v.as_bool()) {
1023                lines.push(format!(
1024                    "    disableAgent = {};",
1025                    if disable_agent { "true" } else { "false" }
1026                ));
1027            }
1028            if let Some(server_addr) = k3s.get("server_addr").and_then(|v| v.as_str()) {
1029                lines.push(format!("    serverAddr = {};", nix_string(server_addr)));
1030            }
1031            if let Some(token_file) = k3s.get("token_file").and_then(|v| v.as_str()) {
1032                lines.push(format!("    tokenFile = {};", nix_string(token_file)));
1033            }
1034
1035            let mut flags: Vec<String> = Vec::new();
1036            if let Some(disabled) = k3s.get("disable").and_then(|v| v.as_array()) {
1037                for component in disabled.iter().filter_map(|v| v.as_str()) {
1038                    flags.push(format!("--disable={component}"));
1039                }
1040            }
1041            if let Some(extra_flags) = k3s.get("extra_flags").and_then(|v| v.as_array()) {
1042                for flag in extra_flags.iter().filter_map(|v| v.as_str()) {
1043                    flags.push(flag.to_string());
1044                }
1045            }
1046            if !flags.is_empty() {
1047                lines.push("    extraFlags = [".to_string());
1048                for flag in flags {
1049                    lines.push(format!("      {}", nix_string(&flag)));
1050                }
1051                lines.push("    ];".to_string());
1052            }
1053            lines.push("  };".to_string());
1054            lines.push(String::new());
1055        }
1056    }
1057
1058    let mut extra_configs = Vec::new();
1059    if let Some(extra_config) = linux.get("extra_config").and_then(|v| v.as_str()) {
1060        extra_configs.push(extra_config);
1061    }
1062    if let Some(fragments) = linux
1063        .get("extra_config_fragments")
1064        .and_then(|v| v.as_array())
1065    {
1066        for fragment in fragments.iter().filter_map(|v| v.as_str()) {
1067            extra_configs.push(fragment);
1068        }
1069    }
1070    for extra_config in extra_configs {
1071        append_extra_nixos_config(lines, extra_config);
1072    }
1073}
1074
1075fn nix_string(value: &str) -> String {
1076    format!("{value:?}")
1077}
1078
1079fn append_extra_nixos_config(lines: &mut Vec<String>, extra_config: &str) {
1080    let extra_config = extra_config.trim();
1081    if extra_config.is_empty() {
1082        return;
1083    }
1084
1085    lines.push("  # Extra NixOS config from profile".to_string());
1086    for line in extra_config.lines() {
1087        if line.trim().is_empty() {
1088            lines.push(String::new());
1089        } else {
1090            lines.push(format!("  {}", line));
1091        }
1092    }
1093    lines.push(String::new());
1094}
1095
1096/// Generate a legacy polymerize.sh installer script.
1097/// Superseded by `nex polymerize` but retained for non-nex environments.
1098#[allow(dead_code)]
1099#[cfg(test)]
1100mod tests {
1101    use super::*;
1102
1103    #[test]
1104    fn scaffold_nixos_config_includes_materialization_flake_inputs() {
1105        let dir = tempfile::tempdir().expect("create temp dir");
1106        let profile = r#"
1107[flake_inputs]
1108dns-dhcp = "github:styrene-lab/dhcp-dns-work"
1109nixos-hardware = "github:NixOS/nixos-hardware"
1110
1111[linux]
1112extra_config = ""
1113"#;
1114
1115        scaffold_nixos_config(dir.path(), "test-host", profile).expect("operation should succeed");
1116        let flake = std::fs::read_to_string(dir.path().join("flake.nix"))
1117            .expect("operation should succeed");
1118
1119        assert!(flake.contains("dns-dhcp.url = \"github:styrene-lab/dhcp-dns-work\";"));
1120        assert!(flake.contains("nixos-hardware.url = \"github:NixOS/nixos-hardware\";"));
1121        assert!(flake.contains("outputs = { self, nixpkgs, home-manager, ... }@inputs:"));
1122        assert!(flake.contains("specialArgs = { inherit inputs; username ="));
1123        assert!(flake.contains("./materialization-module.nix"));
1124        assert!(dir.path().join("materialization-module.nix").is_file());
1125        assert!(!dir.path().join("module.nix").exists());
1126    }
1127
1128    #[test]
1129    fn scaffold_nixos_config_rejects_invalid_flake_inputs() {
1130        let dir = tempfile::tempdir().expect("create temp dir");
1131        let profile = r#"
1132[flake_inputs]
1133bad = "github:owner/repo;rm-rf"
1134"#;
1135
1136        let error = scaffold_nixos_config(dir.path(), "test-host", profile)
1137            .expect_err("invalid flake input rejected");
1138        assert!(format!("{error:#}").contains("unsupported shell/template characters"));
1139    }
1140
1141    #[test]
1142    fn builds_nixos_toplevel_attr() {
1143        assert_eq!(
1144            nixos_toplevel_attr("test-host"),
1145            ".#nixosConfigurations.test-host.config.system.build.toplevel"
1146        );
1147    }
1148
1149    #[test]
1150    fn command_uses_eval_attr_and_workspace() {
1151        let dir = tempfile::tempdir().expect("tempdir");
1152        std::fs::write(dir.path().join("flake.nix"), "{}").expect("write flake");
1153        let check = MaterializationCheck {
1154            workspace: dir.path().to_path_buf(),
1155            hostname: "test-host".to_string(),
1156            target: MaterializationTarget::Toplevel,
1157        };
1158        let command = check.command().expect("valid command");
1159        let args = command
1160            .get_args()
1161            .map(|arg| arg.to_string_lossy().to_string())
1162            .collect::<Vec<_>>();
1163
1164        assert_eq!(
1165            args,
1166            vec![
1167                "--extra-experimental-features".to_string(),
1168                "nix-command flakes".to_string(),
1169                "eval".to_string(),
1170                "--no-update-lock-file".to_string(),
1171                "--no-write-lock-file".to_string(),
1172                "--offline".to_string(),
1173                nixos_toplevel_attr("test-host"),
1174            ]
1175        );
1176        assert_eq!(command.get_current_dir(), Some(dir.path()));
1177    }
1178
1179    #[test]
1180    fn rejects_invalid_hostname() {
1181        let error = validate_hostname("bad/host").expect_err("invalid hostname rejected");
1182        assert!(format!("{error:#}").contains("hostname must contain only"));
1183    }
1184
1185    #[test]
1186    fn rejects_workspace_without_flake() {
1187        let dir = tempfile::tempdir().expect("tempdir");
1188        let error = validate_workspace(dir.path()).expect_err("missing flake rejected");
1189        assert!(format!("{error:#}").contains("does not contain flake.nix"));
1190    }
1191
1192    #[test]
1193    fn parses_and_renders_flake_inputs() {
1194        let payload = MaterializationPayload::from_toml_str(
1195            r#"
1196[flake_inputs]
1197dns-dhcp = "github:styrene-lab/dhcp-dns-work"
1198nixos-hardware = "github:NixOS/nixos-hardware"
1199"#,
1200        )
1201        .expect("valid payload");
1202
1203        let rendered = render_flake_inputs(&payload.flake_inputs);
1204
1205        assert!(rendered.contains("    dns-dhcp.url = \"github:styrene-lab/dhcp-dns-work\";"));
1206        assert!(rendered.contains("    nixos-hardware.url = \"github:NixOS/nixos-hardware\";"));
1207    }
1208
1209    #[test]
1210    fn rejects_invalid_flake_input_names() {
1211        let error = MaterializationPayload::from_toml_str(
1212            r#"
1213[flake_inputs]
1214"9bad" = "github:owner/repo"
1215"#,
1216        )
1217        .expect_err("invalid input name rejected");
1218        assert!(format!("{error:#}").contains("must start with a letter or underscore"));
1219    }
1220
1221    #[test]
1222    fn rejects_suspicious_flake_input_refs() {
1223        let error = MaterializationPayload::from_toml_str(
1224            r#"
1225[flake_inputs]
1226bad = "github:owner/repo;rm-rf"
1227"#,
1228        )
1229        .expect_err("invalid input ref rejected");
1230        assert!(format!("{error:#}").contains("unsupported shell/template characters"));
1231    }
1232    #[test]
1233    fn parses_explicit_materialization_system() {
1234        let payload = MaterializationPayload::from_toml_str(
1235            r#"
1236system = "aarch64-linux"
1237"#,
1238        )
1239        .expect("valid payload");
1240        assert_eq!(payload.system.as_deref(), Some("aarch64-linux"));
1241    }
1242
1243    #[test]
1244    fn rejects_invalid_materialization_system() {
1245        let error = MaterializationPayload::from_toml_str(
1246            r#"
1247system = "mips-linux"
1248"#,
1249        )
1250        .expect_err("invalid system rejected");
1251        assert!(format!("{error:#}").contains("unsupported nix system"));
1252    }
1253
1254    #[test]
1255    fn materialization_target_builds_sd_image_attr() {
1256        let target = MaterializationTarget::parse("sd-image").expect("operation should succeed");
1257        assert_eq!(
1258            target.attr("test-host"),
1259            ".#nixosConfigurations.test-host.config.system.build.sdImage"
1260        );
1261    }
1262
1263    #[test]
1264    fn renders_nixos_module_extra_config() {
1265        let payload = MaterializationPayload {
1266            system: None,
1267            flake_inputs: BTreeMap::new(),
1268            nixos_module: NixosModulePayload {
1269                extra_config: vec!["services.openssh.enable = true;".to_string()],
1270            },
1271        };
1272        let module = render_nixos_module(&payload);
1273        assert!(module.contains("services.openssh.enable = true;"));
1274    }
1275
1276    #[test]
1277    fn rejects_impure_extra_config_fetches() {
1278        let error = MaterializationPayload::from_toml_str(
1279            r#"
1280[nixos_module]
1281extra_config = ["let x = builtins.getFlake \"github:owner/repo\"; in {}"]
1282"#,
1283        )
1284        .expect_err("impure fetch rejected");
1285        assert!(format!("{error:#}").contains("declare flake_inputs instead"));
1286    }
1287
1288    #[test]
1289    fn build_command_uses_deterministic_flags_and_out_link() {
1290        let dir = tempfile::tempdir().expect("tempdir");
1291        std::fs::write(dir.path().join("flake.nix"), "{}").expect("write flake");
1292        let build = MaterializationBuild {
1293            workspace: dir.path().to_path_buf(),
1294            hostname: "test-host".to_string(),
1295            target: MaterializationTarget::SdImage,
1296            out_link: dir.path().join("result-sd-image"),
1297        };
1298        let command = build.command().expect("valid command");
1299        let args = command
1300            .get_args()
1301            .map(|arg| arg.to_string_lossy().to_string())
1302            .collect::<Vec<_>>();
1303
1304        assert!(args.contains(&"build".to_string()));
1305        assert!(args.contains(&"--offline".to_string()));
1306        assert!(args.contains(&"--no-update-lock-file".to_string()));
1307        assert!(args.contains(&"--no-write-lock-file".to_string()));
1308        assert!(args.contains(&"--out-link".to_string()));
1309        assert!(args.contains(&nixos_sd_image_attr("test-host")));
1310    }
1311}