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