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
413pub 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#[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 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 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 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 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 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 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 config_lines.push(" networking.networkmanager.enable = true;".to_string());
518 config_lines.push(String::new());
519
520 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 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 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 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 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
612pub
615fn generate_linux_config(lines: &mut Vec<String>, linux: &toml::Value) {
616 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 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 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 if vaapi {
693 extra_packages.push("intel-media-driver");
694 }
695 }
696 "" => {} 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 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 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 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 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 lines.push(" environment.sessionVariables.GTK_THEME = \"Adwaita:dark\";".to_string());
831 }
832
833 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 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 lines.push(" system.activationScripts.dconf-update = \"dconf update 2>/dev/null || true\";".to_string());
860 }
861 }
862
863 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 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 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 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 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 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 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 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 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#[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}