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