Skip to content

Commit 571b5e5

Browse files
feat(nickel): emit services block from probed inventory (#62)
Continues M4 W1 reconcile (Plan §7.5). The services block is the last per-subsystem table that maps cleanly from the probed inventory. Emits enabled/disabled/masked Vec<String> from the ServiceInventory's BTreeSet members; the active set is dropped since it represents runtime drift, not declaration. Empty-block emission when probe.services is None — keeps the schema's #[serde(default)] entry points consistent with what the imported file declares. Per the module-level doc, the emitter does NOT try to filter "noisy" defaults out of services.enabled — guessing wrong silently drops state that ought to be declared. Operator review of the imported.ncl is the curation step; reconcile is just providing a starting draft. Three new unit tests: - enabled/disabled/masked arrays in sorted order - empty inventory case (no probed services) → empty arrays - service block appears AFTER packages for stable golden-fixture diffs Tests: 327 passing (+3). Clippy clean. fmt clean. audit clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f82e0c8 commit 571b5e5

1 file changed

Lines changed: 88 additions & 6 deletions

File tree

crates/pearlite-nickel/src/emit.rs

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,22 @@
99
//!
1010
//! ## Scope (this chunk)
1111
//!
12-
//! `meta`, `kernel`, and `packages` are emitted. Unprobed `meta`
13-
//! fields (`timezone`, `arch_level`, `locale`, `keymap`) emit
12+
//! `meta`, `kernel`, `packages`, and `services` are emitted. Unprobed
13+
//! `meta` fields (`timezone`, `arch_level`, `locale`, `keymap`) emit
1414
//! conservative defaults that the operator hand-edits after reconcile
1515
//! lands the file at `<config_dir>/hosts/<host>.imported.ncl`.
16-
//! Subsequent chunks add the `services`, `users`, and config-file
17-
//! blocks.
16+
//! Subsequent chunks add the `users` and config-file blocks.
17+
//!
18+
//! ## Services-block caveat
19+
//!
20+
//! On a typical CachyOS install, `systemctl list-unit-files` reports
21+
//! hundreds of enabled units (every package-installed default), so
22+
//! emitting `services.enabled` verbatim produces a noisy starting
23+
//! point. Per PRD §11 the imported file is a *review draft*, not a
24+
//! polished declaration; operators are expected to curate. We emit
25+
//! the full sorted/deduped set rather than try to guess "noise"
26+
//! versus "intentional" — guessing wrong silently drops state that
27+
//! ought to be declared.
1828
//!
1929
//! The `packages` block buckets explicit packages by their probed
2030
//! repo: `core`/`extra`/`multilib`/unknown → `packages.core`,
@@ -35,7 +45,7 @@
3545
//! output is operator-readable, not pretty-printed by `nickel
3646
//! format`. Operators run that themselves if desired.
3747
38-
use pearlite_schema::{CargoInventory, PacmanInventory, ProbedState};
48+
use pearlite_schema::{CargoInventory, PacmanInventory, ProbedState, ServiceInventory};
3949
use std::collections::BTreeMap;
4050

4151
/// Render a Nickel host-config string from `probed`.
@@ -64,6 +74,7 @@ pub fn emit_host(probed: &ProbedState) -> String {
6474
push_meta(&mut out, &probed.host.hostname);
6575
push_kernel(&mut out, &probed.kernel.package);
6676
push_packages(&mut out, probed.pacman.as_ref(), probed.cargo.as_ref());
77+
push_services(&mut out, probed.services.as_ref());
6778
out.push_str("}\n");
6879
out
6980
}
@@ -139,6 +150,28 @@ fn bucket_packages(
139150
buckets
140151
}
141152

153+
fn push_services(out: &mut String, services: Option<&ServiceInventory>) {
154+
out.push_str(" services = {\n");
155+
if let Some(s) = services {
156+
push_service_array(out, "enabled", &s.enabled);
157+
push_service_array(out, "disabled", &s.disabled);
158+
push_service_array(out, "masked", &s.masked);
159+
} else {
160+
// Probe didn't return a service inventory — emit empty
161+
// declarations so the schema's #[serde(default)] doesn't have
162+
// to absorb a missing block.
163+
push_array_field(out, "enabled", &[]);
164+
push_array_field(out, "disabled", &[]);
165+
push_array_field(out, "masked", &[]);
166+
}
167+
out.push_str(" },\n");
168+
}
169+
170+
fn push_service_array(out: &mut String, key: &str, units: &std::collections::BTreeSet<String>) {
171+
let v: Vec<String> = units.iter().cloned().collect();
172+
push_array_field(out, key, &v);
173+
}
174+
142175
fn bucket_for_repo(repo: Option<&str>) -> &'static str {
143176
match repo {
144177
Some("cachyos") => "cachyos",
@@ -207,7 +240,7 @@ fn escape(s: &str) -> String {
207240
)]
208241
mod tests {
209242
use super::*;
210-
use pearlite_schema::{HostInfo, KernelInfo};
243+
use pearlite_schema::{HostInfo, KernelInfo, ServiceInventory};
211244
use std::collections::{BTreeMap, BTreeSet};
212245
use time::OffsetDateTime;
213246

@@ -363,6 +396,55 @@ mod tests {
363396
assert!(out.contains(r#"core = ["chaotic-pkg"],"#));
364397
}
365398

399+
fn make_services(enabled: &[&str], disabled: &[&str], masked: &[&str]) -> ServiceInventory {
400+
let to_set =
401+
|xs: &[&str]| -> BTreeSet<String> { xs.iter().map(|x| (*x).to_owned()).collect() };
402+
ServiceInventory {
403+
enabled: to_set(enabled),
404+
disabled: to_set(disabled),
405+
masked: to_set(masked),
406+
active: BTreeSet::new(),
407+
}
408+
}
409+
410+
#[test]
411+
fn emit_services_emits_three_arrays() {
412+
let mut probed = probed_with("forge", "linux-cachyos");
413+
probed.services = Some(make_services(
414+
&["sshd.service", "NetworkManager.service"],
415+
&["bluetooth.service"],
416+
&["systemd-resolved.service"],
417+
));
418+
let out = emit_host(&probed);
419+
// BTreeSet enumerates in sorted order: NetworkManager <
420+
// sshd lexicographically.
421+
assert!(out.contains(r#"enabled = ["NetworkManager.service", "sshd.service"],"#));
422+
assert!(out.contains(r#"disabled = ["bluetooth.service"],"#));
423+
assert!(out.contains(r#"masked = ["systemd-resolved.service"],"#));
424+
}
425+
426+
#[test]
427+
fn emit_services_emits_empty_block_when_inventory_absent() {
428+
let probed = probed_with("forge", "linux-cachyos");
429+
let out = emit_host(&probed);
430+
assert!(out.contains("services = {"));
431+
assert!(out.contains("enabled = [],"));
432+
assert!(out.contains("disabled = [],"));
433+
assert!(out.contains("masked = [],"));
434+
}
435+
436+
#[test]
437+
fn emit_services_appears_after_packages() {
438+
let probed = probed_with("forge", "linux-cachyos");
439+
let out = emit_host(&probed);
440+
let packages_at = out.find("packages = ").expect("packages block");
441+
let services_at = out.find("services = ").expect("services block");
442+
assert!(
443+
packages_at < services_at,
444+
"packages must precede services for stable golden-fixture diffs"
445+
);
446+
}
447+
366448
#[test]
367449
fn emit_meta_block_appears_before_kernel_block() {
368450
// Stable ordering matters for operator review (and for

0 commit comments

Comments
 (0)