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