Skip to content

Commit e82706d

Browse files
feat(nickel): emit_host scaffold — ProbedState → Nickel host config text (#60)
Opens M4 W1 reconcile direction (Plan §7.5): the inverse of load_host. Where load_host shells out to nickel-export and parses TOML into a DeclaredState, emit_host renders a ProbedState as Nickel host-config text intended to land at <config_dir>/hosts/<host>.imported.ncl as the operator's reconcile review starting point. This first chunk emits only the always-present blocks: meta and kernel. Unprobed meta fields (timezone, arch_level, locale, keymap) get conservative placeholders that the operator hand-edits; in follow-up chunks reconcile probes /etc/timezone, /etc/locale.conf, /proc/cpuinfo etc. to populate them automatically. Subsequent chunks add the packages, services, users, and config-file blocks. The pure-function/no-I/O design keeps emit testable without the nickel binary; round-trip via nickel-export will be exercised once the engine method that wraps probe + emit ships. - New `pearlite-nickel::emit` module + `emit_host(probed) -> String`. - Conservative defaults documented in the rustdoc. - Backslash and double-quote escaping for Nickel string literals. - Five unit tests: basic emit, two escape paths, top-level brace shape, meta-before-kernel ordering. - time crate added to dev-dependencies for fixture construction. Tests: 321 passing (+5). Clippy clean. fmt clean. audit clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 28cb66d commit e82706d

3 files changed

Lines changed: 183 additions & 0 deletions

File tree

crates/pearlite-nickel/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ test-mocks = []
1717
[dependencies]
1818
pearlite-schema = { path = "../pearlite-schema", version = "0.1" }
1919
thiserror = { workspace = true }
20+
21+
[dev-dependencies]
22+
time = { workspace = true }

crates/pearlite-nickel/src/emit.rs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
}

crates/pearlite-nickel/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
//! capture + delegation to [`pearlite_schema::from_resolved_toml`] for
1010
//! the actual deserialisation.
1111
12+
mod emit;
1213
mod errors;
1314
mod live;
1415
#[cfg(any(test, feature = "test-mocks"))]
1516
mod mock;
1617

18+
pub use emit::emit_host;
1719
pub use errors::NickelError;
1820
pub use live::{LiveNickel, NickelEvaluator};
1921

0 commit comments

Comments
 (0)