From a3e3b3a4371a59c7a4a6eb7f96d56acec2fe5aa2 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ <34196588+UnbreakableMJ@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:35:57 +0300 Subject: [PATCH 1/3] feat(nickel): land NickelEvaluator trait, LiveNickel, MockNickel, load_host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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; } - pub struct LiveNickel { binary: PathBuf } - pub struct MockNickel — feature-gated behind test-mocks, in-memory canned-output map BTreeMap. - pub fn load_host(host_file: &Path, eval: &dyn NickelEvaluator) -> Result. - 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) --- .github/workflows/ci.yml | 2 +- crates/pearlite-nickel/Cargo.toml | 5 + crates/pearlite-nickel/src/errors.rs | 40 ++++++++ crates/pearlite-nickel/src/lib.rs | 39 ++++++- crates/pearlite-nickel/src/live.rs | 146 +++++++++++++++++++++++++++ crates/pearlite-nickel/src/mock.rs | 75 ++++++++++++++ fixtures/nickel/host_minimal.ncl | 12 +++ 7 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 crates/pearlite-nickel/src/errors.rs create mode 100644 crates/pearlite-nickel/src/live.rs create mode 100644 crates/pearlite-nickel/src/mock.rs create mode 100644 fixtures/nickel/host_minimal.ncl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6df7a1e..89c26e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - uses: extractions/setup-just@v3 - uses: taiki-e/install-action@v2 with: - tool: cargo-nextest + tool: cargo-nextest,nickel-lang - run: just test - run: just test-doc diff --git a/crates/pearlite-nickel/Cargo.toml b/crates/pearlite-nickel/Cargo.toml index fa328e1..72a963b 100644 --- a/crates/pearlite-nickel/Cargo.toml +++ b/crates/pearlite-nickel/Cargo.toml @@ -11,4 +11,9 @@ repository.workspace = true [lints] workspace = true +[features] +test-mocks = [] + [dependencies] +pearlite-schema = { path = "../pearlite-schema", version = "0.1" } +thiserror = { workspace = true } diff --git a/crates/pearlite-nickel/src/errors.rs b/crates/pearlite-nickel/src/errors.rs new file mode 100644 index 0000000..a73276d --- /dev/null +++ b/crates/pearlite-nickel/src/errors.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Mohamed Hammad + +//! Errors emitted by `pearlite-nickel`. + +use thiserror::Error; + +/// Errors emitted while spawning, evaluating, or parsing Nickel output. +#[derive(Debug, Error)] +pub enum NickelError { + /// The configured `nickel` binary could not be spawned. Usually + /// resolves to a Class 1 preflight failure at the engine level. + #[error("nickel binary not found: {hint}")] + NotInPath { + /// Hint string with a runnable command (`pacman -S nickel-lang`). + hint: &'static str, + }, + /// `std::io` error during spawn or stdout capture. + #[error("I/O error invoking nickel: {0}")] + Io(#[from] std::io::Error), + /// The `nickel` process exited non-zero. `stderr` carries the + /// structured diagnostic verbatim per Plan §6.5. + #[error("nickel exited with code {code}:\n{stderr}")] + EvaluationFailed { + /// Process exit code. + code: i32, + /// Captured stderr. + stderr: String, + }, + /// `nickel` stdout was not valid UTF-8. + #[error("nickel stdout was not valid UTF-8: {0}")] + NotUtf8(#[from] std::string::FromUtf8Error), + /// The emitted TOML did not match the schema in `pearlite-schema`. + #[error(transparent)] + Schema(#[from] pearlite_schema::SchemaError), + /// `MockNickel` was invoked with a path it had no canned output for. + /// Test-only — never returned by [`LiveNickel`](crate::LiveNickel). + #[error("MockNickel: no canned output for {0}")] + MockMissing(std::path::PathBuf), +} diff --git a/crates/pearlite-nickel/src/lib.rs b/crates/pearlite-nickel/src/lib.rs index c80185c..bdce46a 100644 --- a/crates/pearlite-nickel/src/lib.rs +++ b/crates/pearlite-nickel/src/lib.rs @@ -1,4 +1,41 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2026 Mohamed Hammad -//! Nickel evaluator adapter: spawns `nickel export -f toml` and captures output. +//! Nickel evaluator adapter: spawns `nickel export -f toml` and captures +//! the result as a resolved-TOML string. +//! +//! No Nickel parsing in Rust — Plan §6.5 hard rule. The adapter is the +//! thinnest possible shim: argv-array subprocess invocation + stdout +//! capture + delegation to [`pearlite_schema::from_resolved_toml`] for +//! the actual deserialisation. + +mod errors; +mod live; +#[cfg(any(test, feature = "test-mocks"))] +mod mock; + +pub use errors::NickelError; +pub use live::{LiveNickel, NickelEvaluator}; + +#[cfg(feature = "test-mocks")] +pub use mock::MockNickel; + +use pearlite_schema::DeclaredState; +use std::path::Path; + +/// Evaluate a Nickel host file via `eval` and parse the resulting TOML +/// into a [`DeclaredState`]. +/// +/// # Errors +/// - [`NickelError::NotInPath`] if the configured binary cannot be +/// spawned. +/// - [`NickelError::EvaluationFailed`] if `nickel` exits non-zero. +/// - [`NickelError::Schema`] if the emitted TOML does not match the +/// schema in [`pearlite_schema`]. +pub fn load_host( + host_file: &Path, + eval: &dyn NickelEvaluator, +) -> Result { + let toml = eval.evaluate(host_file)?; + Ok(pearlite_schema::from_resolved_toml(&toml)?) +} diff --git a/crates/pearlite-nickel/src/live.rs b/crates/pearlite-nickel/src/live.rs new file mode 100644 index 0000000..bf0acd3 --- /dev/null +++ b/crates/pearlite-nickel/src/live.rs @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Mohamed Hammad + +//! [`NickelEvaluator`] trait + production [`LiveNickel`] implementation. + +use crate::errors::NickelError; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Trait the rest of the workspace consumes to evaluate a Nickel host +/// file. Two implementations: [`LiveNickel`] (production) and +/// [`MockNickel`](crate::MockNickel) under `feature = "test-mocks"`. +pub trait NickelEvaluator: Send + Sync { + /// Evaluate `host_file` and return its resolved TOML representation. + /// + /// # Errors + /// Implementations propagate adapter-specific failures via + /// [`NickelError`]. + fn evaluate(&self, host_file: &Path) -> Result; +} + +/// Production [`NickelEvaluator`] backed by the `nickel` binary. +/// +/// Uses argv-array subprocess invocation per Plan §6.5: the binary is +/// invoked as `nickel export -f toml `, with stdout captured +/// as the resolved TOML and stderr surfaced verbatim on non-zero exit. +#[derive(Clone, Debug)] +pub struct LiveNickel { + binary: PathBuf, +} + +impl LiveNickel { + /// Construct a [`LiveNickel`] that resolves `nickel` from `PATH`. + #[must_use] + pub fn new() -> Self { + Self { + binary: PathBuf::from("nickel"), + } + } + + /// Construct a [`LiveNickel`] with a caller-supplied binary path — + /// useful for tests that ship a known-good binary alongside fixtures. + pub fn with_binary(binary: impl Into) -> Self { + Self { + binary: binary.into(), + } + } +} + +impl Default for LiveNickel { + fn default() -> Self { + Self::new() + } +} + +impl NickelEvaluator for LiveNickel { + fn evaluate(&self, host_file: &Path) -> Result { + let output = match Command::new(&self.binary) + .args(["export", "-f", "toml"]) + .arg(host_file) + .output() + { + Ok(o) => o, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(NickelError::NotInPath { + hint: "paru -S nickel-lang", + }); + } + Err(e) => return Err(NickelError::Io(e)), + }; + + if !output.status.success() { + let code = output.status.code().unwrap_or(-1); + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(NickelError::EvaluationFailed { code, stderr }); + } + + Ok(String::from_utf8(output.stdout)?) + } +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md" +)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn nickel_not_in_path_error_class() { + let live = LiveNickel::with_binary("/nonexistent/path/to/nickel-binary-12345"); + let err = live + .evaluate(Path::new("/tmp/whatever.ncl")) + .expect_err("must fail"); + assert!(matches!(err, NickelError::NotInPath { .. }), "got {err:?}"); + } + + fn fixtures_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crates/") + .parent() + .expect("workspace") + .join("fixtures") + .join("nickel") + } + + /// Plan §6.5 acceptance: nickel --version succeeds. Ignored when + /// nickel isn't on PATH (CI installs it; local devs may not). + #[test] + fn version_probe_succeeds() { + let live = LiveNickel::new(); + let out = Command::new(&live.binary).arg("--version").output(); + if !matches!(&out, Ok(o) if o.status.success()) { + // nickel not on PATH; CI installs it. On dev boxes without it, + // these tests pass silently rather than failing — install + // nickel-lang via `paru -S nickel-lang` to actually run them. + return; + } + let stdout = String::from_utf8_lossy(&out.expect("ok").stdout).into_owned(); + assert!( + stdout.to_lowercase().contains("nickel"), + "expected 'nickel' in --version output, got: {stdout}" + ); + } + + /// Plan §6.5 acceptance: a minimal host file evaluates to TOML that + /// parses as `DeclaredState`. Skipped when nickel isn't available. + #[test] + fn minimal_host_evaluates() { + let probe = Command::new("nickel").arg("--version").output(); + if !matches!(&probe, Ok(o) if o.status.success()) { + return; + } + let host = fixtures_dir().join("host_minimal.ncl"); + let live = LiveNickel::new(); + let toml = live.evaluate(&host).expect("evaluate"); + let declared = pearlite_schema::from_resolved_toml(&toml).expect("parse"); + assert_eq!(declared.host.hostname, "forge"); + assert_eq!(declared.kernel.package, "linux-cachyos"); + } +} diff --git a/crates/pearlite-nickel/src/mock.rs b/crates/pearlite-nickel/src/mock.rs new file mode 100644 index 0000000..2de64f7 --- /dev/null +++ b/crates/pearlite-nickel/src/mock.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Mohamed Hammad + +//! In-memory [`MockNickel`] for unit tests. +//! +//! Compiled in `cargo test` (no feature) and behind `feature = +//! "test-mocks"` for downstream consumers (the engine's integration +//! tests in M2+). + +use crate::errors::NickelError; +use crate::live::NickelEvaluator; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// In-memory canned-output evaluator: maps host-file paths to their +/// expected resolved-TOML strings. +#[derive(Clone, Debug, Default)] +pub struct MockNickel { + canned: BTreeMap, +} + +impl MockNickel { + /// Construct an empty [`MockNickel`]. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Pre-seed `host_file → output`. Replaces any prior canned output + /// for the same path. + pub fn seed(&mut self, host_file: impl Into, output: impl Into) { + self.canned.insert(host_file.into(), output.into()); + } +} + +impl NickelEvaluator for MockNickel { + fn evaluate(&self, host_file: &Path) -> Result { + self.canned + .get(host_file) + .cloned() + .ok_or_else(|| NickelError::MockMissing(host_file.to_path_buf())) + } +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md" +)] +mod tests { + use super::*; + use crate::load_host; + + const MINIMAL: &str = include_str!("../../../fixtures/schema/host_minimal.toml"); + + #[test] + fn canned_output_round_trips_to_declared_state() { + let mut mock = MockNickel::new(); + let host = Path::new("/cfg/forge.ncl"); + mock.seed(host, MINIMAL); + + let declared = load_host(host, &mock).expect("load"); + assert_eq!(declared.host.hostname, "forge"); + assert_eq!(declared.kernel.package, "linux-cachyos"); + } + + #[test] + fn missing_path_yields_mock_missing_error() { + let mock = MockNickel::new(); + let err = load_host(Path::new("/cfg/nope.ncl"), &mock).expect_err("must fail"); + assert!(matches!(err, NickelError::MockMissing(_)), "got {err:?}"); + } +} diff --git a/fixtures/nickel/host_minimal.ncl b/fixtures/nickel/host_minimal.ncl new file mode 100644 index 0000000..10fb70f --- /dev/null +++ b/fixtures/nickel/host_minimal.ncl @@ -0,0 +1,12 @@ +{ + meta = { + hostname = "forge", + timezone = "Europe/London", + arch_level = "v4", + locale = "en_US.UTF-8", + keymap = "us", + }, + kernel = { + package = "linux-cachyos", + }, +} From 6dae554d3f87b702dc68fb4dcced0ebac6cb2d0f Mon Sep 17 00:00:00 2001 From: UnbreakableMJ <34196588+UnbreakableMJ@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:38:05 +0300 Subject: [PATCH 2/3] 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) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89c26e9..be6925a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: - uses: extractions/setup-just@v3 - uses: taiki-e/install-action@v2 with: - tool: cargo-nextest,nickel-lang + tool: cargo-nextest,nickel-lang-cli - run: just test - run: just test-doc From c742e32d630a65a07e59c48fe84da0c6f49c23dd Mon Sep 17 00:00:00 2001 From: UnbreakableMJ <34196588+UnbreakableMJ@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:48:17 +0300 Subject: [PATCH 3/3] fix(ci): replace extractions/setup-just with taiki-e/install-action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/ci.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be6925a..200f889 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,9 @@ jobs: - uses: dtolnay/rust-toolchain@1.85 with: components: rustfmt, clippy - - uses: extractions/setup-just@v3 - uses: taiki-e/install-action@v2 with: - tool: cargo-machete + tool: just,cargo-machete - run: just check - run: just spdx - run: just unused @@ -40,10 +39,9 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.85 - uses: Swatinem/rust-cache@v2 - - uses: extractions/setup-just@v3 - uses: taiki-e/install-action@v2 with: - tool: cargo-nextest,nickel-lang-cli + tool: just,cargo-nextest,nickel-lang-cli - run: just test - run: just test-doc @@ -55,9 +53,8 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@1.85 - uses: Swatinem/rust-cache@v2 - - uses: extractions/setup-just@v3 - uses: taiki-e/install-action@v2 with: - tool: cargo-audit,cargo-deny + tool: just,cargo-audit,cargo-deny - run: just audit - run: just compliance