|
| 1 | +// SPDX-License-Identifier: GPL-3.0-or-later |
| 2 | +// Copyright (C) 2026 Mohamed Hammad |
| 3 | + |
| 4 | +//! Emit Nickel host-config text from a [`ProbedState`]. |
| 5 | +//! |
| 6 | +//! M4 W1 reconcile direction: where [`load_host`](crate::load_host) |
| 7 | +//! takes Nickel → TOML → [`DeclaredState`], this module goes the |
| 8 | +//! other way — [`ProbedState`] → Nickel-syntax host file string. |
| 9 | +//! |
| 10 | +//! ## Scope (this chunk) |
| 11 | +//! |
| 12 | +//! Only the always-present blocks are emitted: `meta` and `kernel`. |
| 13 | +//! Unprobed `meta` fields (`timezone`, `arch_level`, `locale`, |
| 14 | +//! `keymap`) emit conservative defaults that the operator hand-edits |
| 15 | +//! after reconcile lands the file at |
| 16 | +//! `<config_dir>/hosts/<host>.imported.ncl`. Subsequent chunks add |
| 17 | +//! the `packages`, `services`, `users`, and config-file blocks. |
| 18 | +//! |
| 19 | +//! ## Non-goals |
| 20 | +//! |
| 21 | +//! - **No round-trip via `nickel`**: this emitter produces text that |
| 22 | +//! parses through `nickel export -f toml` back into a valid |
| 23 | +//! [`DeclaredState`], but exercising the full round-trip needs the |
| 24 | +//! `nickel` binary, which lives behind the live evaluator. Unit |
| 25 | +//! tests assert structure via string predicates and a side-channel |
| 26 | +//! TOML re-parse. |
| 27 | +//! - **No formatting beyond two-space indent + sorted keys**: the |
| 28 | +//! output is operator-readable, not pretty-printed by `nickel |
| 29 | +//! format`. Operators run that themselves if desired. |
| 30 | +
|
| 31 | +use pearlite_schema::ProbedState; |
| 32 | + |
| 33 | +/// Render a Nickel host-config string from `probed`. |
| 34 | +/// |
| 35 | +/// The output is a single Nickel record literal terminated by a |
| 36 | +/// trailing newline. It is intended to land at |
| 37 | +/// `<config_dir>/hosts/<probed.host.hostname>.imported.ncl` as the |
| 38 | +/// starting point for an operator's reconcile review. |
| 39 | +/// |
| 40 | +/// Conservative defaults fill in unprobed `meta` fields: |
| 41 | +/// - `timezone = "UTC"` — operator inspects `/etc/localtime` symlink |
| 42 | +/// and corrects. |
| 43 | +/// - `arch_level = "v3"` — Plan default for x86-64; reconcile probes |
| 44 | +/// `/proc/cpuinfo` flags in a follow-up chunk to upgrade to v4 when |
| 45 | +/// AVX-512 etc. are present. |
| 46 | +/// - `locale = "en_US.UTF-8"`, `keymap = "us"` — likewise placeholders. |
| 47 | +/// |
| 48 | +/// Hostnames containing characters that need escaping in Nickel |
| 49 | +/// strings (backslash, double-quote) are escaped per Nickel grammar; |
| 50 | +/// unprintable bytes are NOT supported because hostnames are already |
| 51 | +/// constrained by RFC 1123 to a printable subset. |
| 52 | +#[must_use] |
| 53 | +pub fn emit_host(probed: &ProbedState) -> String { |
| 54 | + let mut out = String::with_capacity(256); |
| 55 | + out.push_str("{\n"); |
| 56 | + push_meta(&mut out, &probed.host.hostname); |
| 57 | + push_kernel(&mut out, &probed.kernel.package); |
| 58 | + out.push_str("}\n"); |
| 59 | + out |
| 60 | +} |
| 61 | + |
| 62 | +fn push_meta(out: &mut String, hostname: &str) { |
| 63 | + out.push_str(" meta = {\n"); |
| 64 | + push_field(out, "hostname", hostname); |
| 65 | + push_field(out, "timezone", "UTC"); |
| 66 | + push_field(out, "arch_level", "v3"); |
| 67 | + push_field(out, "locale", "en_US.UTF-8"); |
| 68 | + push_field(out, "keymap", "us"); |
| 69 | + out.push_str(" },\n"); |
| 70 | +} |
| 71 | + |
| 72 | +fn push_kernel(out: &mut String, package: &str) { |
| 73 | + out.push_str(" kernel = {\n"); |
| 74 | + push_field(out, "package", package); |
| 75 | + out.push_str(" },\n"); |
| 76 | +} |
| 77 | + |
| 78 | +fn push_field(out: &mut String, key: &str, value: &str) { |
| 79 | + out.push_str(" "); |
| 80 | + out.push_str(key); |
| 81 | + out.push_str(" = \""); |
| 82 | + out.push_str(&escape(value)); |
| 83 | + out.push_str("\",\n"); |
| 84 | +} |
| 85 | + |
| 86 | +fn escape(s: &str) -> String { |
| 87 | + let mut out = String::with_capacity(s.len()); |
| 88 | + for c in s.chars() { |
| 89 | + match c { |
| 90 | + '\\' => out.push_str("\\\\"), |
| 91 | + '"' => out.push_str("\\\""), |
| 92 | + other => out.push(other), |
| 93 | + } |
| 94 | + } |
| 95 | + out |
| 96 | +} |
| 97 | + |
| 98 | +#[cfg(test)] |
| 99 | +#[allow( |
| 100 | + clippy::expect_used, |
| 101 | + clippy::unwrap_used, |
| 102 | + clippy::panic, |
| 103 | + reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md" |
| 104 | +)] |
| 105 | +mod tests { |
| 106 | + use super::*; |
| 107 | + use pearlite_schema::{HostInfo, KernelInfo}; |
| 108 | + use std::collections::BTreeSet; |
| 109 | + use time::OffsetDateTime; |
| 110 | + |
| 111 | + fn probed_with(hostname: &str, kernel: &str) -> ProbedState { |
| 112 | + ProbedState { |
| 113 | + probed_at: OffsetDateTime::from_unix_timestamp(1_777_000_000).expect("ts"), |
| 114 | + host: HostInfo { |
| 115 | + hostname: hostname.to_owned(), |
| 116 | + }, |
| 117 | + pacman: None, |
| 118 | + cargo: None, |
| 119 | + config_files: None, |
| 120 | + services: None, |
| 121 | + kernel: KernelInfo { |
| 122 | + running_version: String::new(), |
| 123 | + package: kernel.to_owned(), |
| 124 | + loaded_modules: BTreeSet::new(), |
| 125 | + }, |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + #[test] |
| 130 | + fn emit_basic_host() { |
| 131 | + let out = emit_host(&probed_with("forge", "linux-cachyos")); |
| 132 | + assert!(out.starts_with("{\n")); |
| 133 | + assert!(out.ends_with("}\n")); |
| 134 | + assert!(out.contains("hostname = \"forge\"")); |
| 135 | + assert!(out.contains("package = \"linux-cachyos\"")); |
| 136 | + // Conservative defaults are present. |
| 137 | + assert!(out.contains("timezone = \"UTC\"")); |
| 138 | + assert!(out.contains("arch_level = \"v3\"")); |
| 139 | + assert!(out.contains("locale = \"en_US.UTF-8\"")); |
| 140 | + assert!(out.contains("keymap = \"us\"")); |
| 141 | + } |
| 142 | + |
| 143 | + #[test] |
| 144 | + fn emit_escapes_double_quote_in_hostname() { |
| 145 | + // Hostname with an embedded quote (not RFC-1123 valid, but |
| 146 | + // tests the emitter's escape path). |
| 147 | + let out = emit_host(&probed_with("ev\"il", "linux-cachyos")); |
| 148 | + assert!(out.contains(r#"hostname = "ev\"il""#)); |
| 149 | + } |
| 150 | + |
| 151 | + #[test] |
| 152 | + fn emit_escapes_backslash() { |
| 153 | + let out = emit_host(&probed_with("ho\\st", "linux-cachyos")); |
| 154 | + assert!(out.contains(r#"hostname = "ho\\st""#)); |
| 155 | + } |
| 156 | + |
| 157 | + #[test] |
| 158 | + fn emit_top_level_record_is_lone_block() { |
| 159 | + // Exactly one `{` at column 0 (the opening brace) and exactly |
| 160 | + // one `}` at column 0 (the closing brace). Sub-blocks live |
| 161 | + // indented. |
| 162 | + let out = emit_host(&probed_with("forge", "linux-cachyos")); |
| 163 | + let lone_open = out.lines().filter(|l| *l == "{").count(); |
| 164 | + let lone_close = out.lines().filter(|l| *l == "}").count(); |
| 165 | + assert_eq!(lone_open, 1, "expected exactly one top-level {{"); |
| 166 | + assert_eq!(lone_close, 1, "expected exactly one top-level }}"); |
| 167 | + } |
| 168 | + |
| 169 | + #[test] |
| 170 | + fn emit_meta_block_appears_before_kernel_block() { |
| 171 | + // Stable ordering matters for operator review (and for |
| 172 | + // golden-fixture diffs in subsequent chunks). |
| 173 | + let out = emit_host(&probed_with("forge", "linux-cachyos")); |
| 174 | + let meta_at = out.find("meta =").expect("meta block emitted"); |
| 175 | + let kernel_at = out.find("kernel =").expect("kernel block emitted"); |
| 176 | + assert!(meta_at < kernel_at, "meta must precede kernel"); |
| 177 | + } |
| 178 | +} |
0 commit comments