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
393pub 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#[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 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 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 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 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 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 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 config_lines.push(" networking.networkmanager.enable = true;".to_string());
497 config_lines.push(String::new());
498
499 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 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 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 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 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
586pub
589fn generate_linux_config(lines: &mut Vec<String>, linux: &toml::Value) {
590 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 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 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 if vaapi {
667 extra_packages.push("intel-media-driver");
668 }
669 }
670 "" => {} 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 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 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 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 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 lines.push(" environment.sessionVariables.GTK_THEME = \"Adwaita:dark\";".to_string());
805 }
806
807 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 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 lines.push(" system.activationScripts.dconf-update = \"dconf update 2>/dev/null || true\";".to_string());
834 }
835 }
836
837 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 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 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 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 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 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 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 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 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#[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}