|
| 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 | +} |
0 commit comments