Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
tool: just,cargo-nextest,nickel-lang-cli
- run: just test
- run: just test-doc

Expand All @@ -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
5 changes: 5 additions & 0 deletions crates/pearlite-nickel/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
40 changes: 40 additions & 0 deletions crates/pearlite-nickel/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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),
}
39 changes: 38 additions & 1 deletion crates/pearlite-nickel/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<DeclaredState, NickelError> {
let toml = eval.evaluate(host_file)?;
Ok(pearlite_schema::from_resolved_toml(&toml)?)
}
146 changes: 146 additions & 0 deletions crates/pearlite-nickel/src/live.rs
Original file line number Diff line number Diff line change
@@ -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<String, NickelError>;
}

/// 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 <host_file>`, 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<PathBuf>) -> 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<String, NickelError> {
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");
}
}
75 changes: 75 additions & 0 deletions crates/pearlite-nickel/src/mock.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf, String>,
}

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<PathBuf>, output: impl Into<String>) {
self.canned.insert(host_file.into(), output.into());
}
}

impl NickelEvaluator for MockNickel {
fn evaluate(&self, host_file: &Path) -> Result<String, NickelError> {
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:?}");
}
}
12 changes: 12 additions & 0 deletions fixtures/nickel/host_minimal.ncl
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
meta = {
hostname = "forge",
timezone = "Europe/London",
arch_level = "v4",
locale = "en_US.UTF-8",
keymap = "us",
},
kernel = {
package = "linux-cachyos",
},
}
Loading