Skip to content

Commit d067e5b

Browse files
feat(nickel): NickelEvaluator + LiveNickel + MockNickel — closes Plan §6.5 (#10)
* feat(nickel): land NickelEvaluator trait, LiveNickel, MockNickel, load_host Plan §6.5 — first M1 Week 2 adapter probe path. The thinnest possible shim around the `nickel` binary: argv-array subprocess invocation, stdout capture, delegation to pearlite_schema::from_resolved_toml. No Nickel parsing in Rust per Plan §6.5 hard rule. Public surface (re-exported from lib.rs): - pub trait NickelEvaluator { fn evaluate(&self, &Path) -> Result<String, NickelError>; } - pub struct LiveNickel { binary: PathBuf } - pub struct MockNickel — feature-gated behind test-mocks, in-memory canned-output map BTreeMap<PathBuf, String>. - pub fn load_host(host_file: &Path, eval: &dyn NickelEvaluator) -> Result<DeclaredState, NickelError>. - pub enum NickelError — NotInPath (hints `paru -S nickel-lang`), Io, EvaluationFailed { code, stderr }, NotUtf8, Schema (transparent to pearlite_schema::SchemaError), MockMissing. LiveNickel: - Default constructor resolves `nickel` from $PATH. - with_binary() lets tests inject a known-good path. - Spawn translates ErrorKind::NotFound to NickelError::NotInPath with a runnable hint. - Captures stderr verbatim; surfaces with exit code on non-zero. CI: - Add nickel-lang to taiki-e/install-action's tool list in T2 Unit so the live tests actually exercise nickel rather than silent-skipping. Tests (5 passing locally; live tests silent-skip when nickel isn't on PATH — CI installs it so they exercise there): - mock: canned_output_round_trips_to_declared_state. - mock: missing_path_yields_mock_missing_error. - live: nickel_not_in_path_error_class — pointed at a bogus path. - live: version_probe_succeeds — nickel --version contains "nickel". - live: minimal_host_evaluates — fixture host_minimal.ncl evaluates to TOML that parses as DeclaredState. Fixture: fixtures/nickel/host_minimal.ncl mirrors the schema fixture. Cargo.toml: pearlite-schema dep pinned to "0.1" per the cargo-deny wildcard rule (PR #9 precedent). New `test-mocks` feature. Verification: - cargo test -p pearlite-nickel (5 passed; 0 failed) - cargo clippy --workspace --all-targets -- -D warnings (zero warnings) - cargo fmt --all --check - scripts/ci/check-spdx.sh - pearlite-audit check . (1 check, 0 violations) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): install nickel-lang-cli, not nickel-lang The library crate `nickel-lang` has no binaries; the CLI binary lives in the `nickel-lang-cli` crate. taiki-e/install-action's fallback to cargo-binstall correctly reported "no binaries specified" and failed the T2 Unit job. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ci): replace extractions/setup-just with taiki-e/install-action extractions/setup-just@v3 hit "no release for just matching version specifier" on every recent PR run — the action's default version resolution is broken. taiki-e/install-action is already used in every job for cargo tooling; adding `just` to its tool list is the consistent fix and avoids the second action entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9c7d438 commit d067e5b

7 files changed

Lines changed: 319 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@ jobs:
2424
- uses: dtolnay/rust-toolchain@1.85
2525
with:
2626
components: rustfmt, clippy
27-
- uses: extractions/setup-just@v3
2827
- uses: taiki-e/install-action@v2
2928
with:
30-
tool: cargo-machete
29+
tool: just,cargo-machete
3130
- run: just check
3231
- run: just spdx
3332
- run: just unused
@@ -40,10 +39,9 @@ jobs:
4039
- uses: actions/checkout@v4
4140
- uses: dtolnay/rust-toolchain@1.85
4241
- uses: Swatinem/rust-cache@v2
43-
- uses: extractions/setup-just@v3
4442
- uses: taiki-e/install-action@v2
4543
with:
46-
tool: cargo-nextest
44+
tool: just,cargo-nextest,nickel-lang-cli
4745
- run: just test
4846
- run: just test-doc
4947

@@ -55,9 +53,8 @@ jobs:
5553
- uses: actions/checkout@v4
5654
- uses: dtolnay/rust-toolchain@1.85
5755
- uses: Swatinem/rust-cache@v2
58-
- uses: extractions/setup-just@v3
5956
- uses: taiki-e/install-action@v2
6057
with:
61-
tool: cargo-audit,cargo-deny
58+
tool: just,cargo-audit,cargo-deny
6259
- run: just audit
6360
- run: just compliance

crates/pearlite-nickel/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,9 @@ repository.workspace = true
1111
[lints]
1212
workspace = true
1313

14+
[features]
15+
test-mocks = []
16+
1417
[dependencies]
18+
pearlite-schema = { path = "../pearlite-schema", version = "0.1" }
19+
thiserror = { workspace = true }
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Errors emitted by `pearlite-nickel`.
5+
6+
use thiserror::Error;
7+
8+
/// Errors emitted while spawning, evaluating, or parsing Nickel output.
9+
#[derive(Debug, Error)]
10+
pub enum NickelError {
11+
/// The configured `nickel` binary could not be spawned. Usually
12+
/// resolves to a Class 1 preflight failure at the engine level.
13+
#[error("nickel binary not found: {hint}")]
14+
NotInPath {
15+
/// Hint string with a runnable command (`pacman -S nickel-lang`).
16+
hint: &'static str,
17+
},
18+
/// `std::io` error during spawn or stdout capture.
19+
#[error("I/O error invoking nickel: {0}")]
20+
Io(#[from] std::io::Error),
21+
/// The `nickel` process exited non-zero. `stderr` carries the
22+
/// structured diagnostic verbatim per Plan §6.5.
23+
#[error("nickel exited with code {code}:\n{stderr}")]
24+
EvaluationFailed {
25+
/// Process exit code.
26+
code: i32,
27+
/// Captured stderr.
28+
stderr: String,
29+
},
30+
/// `nickel` stdout was not valid UTF-8.
31+
#[error("nickel stdout was not valid UTF-8: {0}")]
32+
NotUtf8(#[from] std::string::FromUtf8Error),
33+
/// The emitted TOML did not match the schema in `pearlite-schema`.
34+
#[error(transparent)]
35+
Schema(#[from] pearlite_schema::SchemaError),
36+
/// `MockNickel` was invoked with a path it had no canned output for.
37+
/// Test-only — never returned by [`LiveNickel`](crate::LiveNickel).
38+
#[error("MockNickel: no canned output for {0}")]
39+
MockMissing(std::path::PathBuf),
40+
}

crates/pearlite-nickel/src/lib.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,41 @@
11
// SPDX-License-Identifier: GPL-3.0-or-later
22
// Copyright (C) 2026 Mohamed Hammad
33

4-
//! Nickel evaluator adapter: spawns `nickel export -f toml` and captures output.
4+
//! Nickel evaluator adapter: spawns `nickel export -f toml` and captures
5+
//! the result as a resolved-TOML string.
6+
//!
7+
//! No Nickel parsing in Rust — Plan §6.5 hard rule. The adapter is the
8+
//! thinnest possible shim: argv-array subprocess invocation + stdout
9+
//! capture + delegation to [`pearlite_schema::from_resolved_toml`] for
10+
//! the actual deserialisation.
11+
12+
mod errors;
13+
mod live;
14+
#[cfg(any(test, feature = "test-mocks"))]
15+
mod mock;
16+
17+
pub use errors::NickelError;
18+
pub use live::{LiveNickel, NickelEvaluator};
19+
20+
#[cfg(feature = "test-mocks")]
21+
pub use mock::MockNickel;
22+
23+
use pearlite_schema::DeclaredState;
24+
use std::path::Path;
25+
26+
/// Evaluate a Nickel host file via `eval` and parse the resulting TOML
27+
/// into a [`DeclaredState`].
28+
///
29+
/// # Errors
30+
/// - [`NickelError::NotInPath`] if the configured binary cannot be
31+
/// spawned.
32+
/// - [`NickelError::EvaluationFailed`] if `nickel` exits non-zero.
33+
/// - [`NickelError::Schema`] if the emitted TOML does not match the
34+
/// schema in [`pearlite_schema`].
35+
pub fn load_host(
36+
host_file: &Path,
37+
eval: &dyn NickelEvaluator,
38+
) -> Result<DeclaredState, NickelError> {
39+
let toml = eval.evaluate(host_file)?;
40+
Ok(pearlite_schema::from_resolved_toml(&toml)?)
41+
}

crates/pearlite-nickel/src/live.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! [`NickelEvaluator`] trait + production [`LiveNickel`] implementation.
5+
6+
use crate::errors::NickelError;
7+
use std::path::{Path, PathBuf};
8+
use std::process::Command;
9+
10+
/// Trait the rest of the workspace consumes to evaluate a Nickel host
11+
/// file. Two implementations: [`LiveNickel`] (production) and
12+
/// [`MockNickel`](crate::MockNickel) under `feature = "test-mocks"`.
13+
pub trait NickelEvaluator: Send + Sync {
14+
/// Evaluate `host_file` and return its resolved TOML representation.
15+
///
16+
/// # Errors
17+
/// Implementations propagate adapter-specific failures via
18+
/// [`NickelError`].
19+
fn evaluate(&self, host_file: &Path) -> Result<String, NickelError>;
20+
}
21+
22+
/// Production [`NickelEvaluator`] backed by the `nickel` binary.
23+
///
24+
/// Uses argv-array subprocess invocation per Plan §6.5: the binary is
25+
/// invoked as `nickel export -f toml <host_file>`, with stdout captured
26+
/// as the resolved TOML and stderr surfaced verbatim on non-zero exit.
27+
#[derive(Clone, Debug)]
28+
pub struct LiveNickel {
29+
binary: PathBuf,
30+
}
31+
32+
impl LiveNickel {
33+
/// Construct a [`LiveNickel`] that resolves `nickel` from `PATH`.
34+
#[must_use]
35+
pub fn new() -> Self {
36+
Self {
37+
binary: PathBuf::from("nickel"),
38+
}
39+
}
40+
41+
/// Construct a [`LiveNickel`] with a caller-supplied binary path —
42+
/// useful for tests that ship a known-good binary alongside fixtures.
43+
pub fn with_binary(binary: impl Into<PathBuf>) -> Self {
44+
Self {
45+
binary: binary.into(),
46+
}
47+
}
48+
}
49+
50+
impl Default for LiveNickel {
51+
fn default() -> Self {
52+
Self::new()
53+
}
54+
}
55+
56+
impl NickelEvaluator for LiveNickel {
57+
fn evaluate(&self, host_file: &Path) -> Result<String, NickelError> {
58+
let output = match Command::new(&self.binary)
59+
.args(["export", "-f", "toml"])
60+
.arg(host_file)
61+
.output()
62+
{
63+
Ok(o) => o,
64+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
65+
return Err(NickelError::NotInPath {
66+
hint: "paru -S nickel-lang",
67+
});
68+
}
69+
Err(e) => return Err(NickelError::Io(e)),
70+
};
71+
72+
if !output.status.success() {
73+
let code = output.status.code().unwrap_or(-1);
74+
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
75+
return Err(NickelError::EvaluationFailed { code, stderr });
76+
}
77+
78+
Ok(String::from_utf8(output.stdout)?)
79+
}
80+
}
81+
82+
#[cfg(test)]
83+
#[allow(
84+
clippy::expect_used,
85+
clippy::unwrap_used,
86+
clippy::panic,
87+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
88+
)]
89+
mod tests {
90+
use super::*;
91+
use std::path::PathBuf;
92+
93+
#[test]
94+
fn nickel_not_in_path_error_class() {
95+
let live = LiveNickel::with_binary("/nonexistent/path/to/nickel-binary-12345");
96+
let err = live
97+
.evaluate(Path::new("/tmp/whatever.ncl"))
98+
.expect_err("must fail");
99+
assert!(matches!(err, NickelError::NotInPath { .. }), "got {err:?}");
100+
}
101+
102+
fn fixtures_dir() -> PathBuf {
103+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
104+
.parent()
105+
.expect("crates/")
106+
.parent()
107+
.expect("workspace")
108+
.join("fixtures")
109+
.join("nickel")
110+
}
111+
112+
/// Plan §6.5 acceptance: nickel --version succeeds. Ignored when
113+
/// nickel isn't on PATH (CI installs it; local devs may not).
114+
#[test]
115+
fn version_probe_succeeds() {
116+
let live = LiveNickel::new();
117+
let out = Command::new(&live.binary).arg("--version").output();
118+
if !matches!(&out, Ok(o) if o.status.success()) {
119+
// nickel not on PATH; CI installs it. On dev boxes without it,
120+
// these tests pass silently rather than failing — install
121+
// nickel-lang via `paru -S nickel-lang` to actually run them.
122+
return;
123+
}
124+
let stdout = String::from_utf8_lossy(&out.expect("ok").stdout).into_owned();
125+
assert!(
126+
stdout.to_lowercase().contains("nickel"),
127+
"expected 'nickel' in --version output, got: {stdout}"
128+
);
129+
}
130+
131+
/// Plan §6.5 acceptance: a minimal host file evaluates to TOML that
132+
/// parses as `DeclaredState`. Skipped when nickel isn't available.
133+
#[test]
134+
fn minimal_host_evaluates() {
135+
let probe = Command::new("nickel").arg("--version").output();
136+
if !matches!(&probe, Ok(o) if o.status.success()) {
137+
return;
138+
}
139+
let host = fixtures_dir().join("host_minimal.ncl");
140+
let live = LiveNickel::new();
141+
let toml = live.evaluate(&host).expect("evaluate");
142+
let declared = pearlite_schema::from_resolved_toml(&toml).expect("parse");
143+
assert_eq!(declared.host.hostname, "forge");
144+
assert_eq!(declared.kernel.package, "linux-cachyos");
145+
}
146+
}

crates/pearlite-nickel/src/mock.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! In-memory [`MockNickel`] for unit tests.
5+
//!
6+
//! Compiled in `cargo test` (no feature) and behind `feature =
7+
//! "test-mocks"` for downstream consumers (the engine's integration
8+
//! tests in M2+).
9+
10+
use crate::errors::NickelError;
11+
use crate::live::NickelEvaluator;
12+
use std::collections::BTreeMap;
13+
use std::path::{Path, PathBuf};
14+
15+
/// In-memory canned-output evaluator: maps host-file paths to their
16+
/// expected resolved-TOML strings.
17+
#[derive(Clone, Debug, Default)]
18+
pub struct MockNickel {
19+
canned: BTreeMap<PathBuf, String>,
20+
}
21+
22+
impl MockNickel {
23+
/// Construct an empty [`MockNickel`].
24+
#[must_use]
25+
pub fn new() -> Self {
26+
Self::default()
27+
}
28+
29+
/// Pre-seed `host_file → output`. Replaces any prior canned output
30+
/// for the same path.
31+
pub fn seed(&mut self, host_file: impl Into<PathBuf>, output: impl Into<String>) {
32+
self.canned.insert(host_file.into(), output.into());
33+
}
34+
}
35+
36+
impl NickelEvaluator for MockNickel {
37+
fn evaluate(&self, host_file: &Path) -> Result<String, NickelError> {
38+
self.canned
39+
.get(host_file)
40+
.cloned()
41+
.ok_or_else(|| NickelError::MockMissing(host_file.to_path_buf()))
42+
}
43+
}
44+
45+
#[cfg(test)]
46+
#[allow(
47+
clippy::expect_used,
48+
clippy::unwrap_used,
49+
clippy::panic,
50+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
51+
)]
52+
mod tests {
53+
use super::*;
54+
use crate::load_host;
55+
56+
const MINIMAL: &str = include_str!("../../../fixtures/schema/host_minimal.toml");
57+
58+
#[test]
59+
fn canned_output_round_trips_to_declared_state() {
60+
let mut mock = MockNickel::new();
61+
let host = Path::new("/cfg/forge.ncl");
62+
mock.seed(host, MINIMAL);
63+
64+
let declared = load_host(host, &mock).expect("load");
65+
assert_eq!(declared.host.hostname, "forge");
66+
assert_eq!(declared.kernel.package, "linux-cachyos");
67+
}
68+
69+
#[test]
70+
fn missing_path_yields_mock_missing_error() {
71+
let mock = MockNickel::new();
72+
let err = load_host(Path::new("/cfg/nope.ncl"), &mock).expect_err("must fail");
73+
assert!(matches!(err, NickelError::MockMissing(_)), "got {err:?}");
74+
}
75+
}

fixtures/nickel/host_minimal.ncl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
meta = {
3+
hostname = "forge",
4+
timezone = "Europe/London",
5+
arch_level = "v4",
6+
locale = "en_US.UTF-8",
7+
keymap = "us",
8+
},
9+
kernel = {
10+
package = "linux-cachyos",
11+
},
12+
}

0 commit comments

Comments
 (0)