//! Nickel configuration loader. //! //! Evaluates `.ncl` files to JSON via the `nickel export` CLI. //! Optionally resolves OCI imports via `ncl-import-resolver` when a //! `resolver-manifest.json` file is present alongside the config. //! //! # Requirements //! //! - `nickel` must be on `PATH`. //! - `ncl-import-resolver` must be on `PATH` when OCI imports are used. use std::path::Path; use anyhow::{anyhow, Context}; use serde::de::DeserializeOwned; use tokio::process::Command; /// Evaluate a Nickel configuration file and deserialize the result into `T`. /// /// Steps: /// 1. If a `resolver-manifest.json` exists beside the `.ncl` file, run /// `ncl-import-resolver ` to resolve OCI imports. /// 2. Run `nickel export --format json ` and capture stdout. /// 3. Deserialize the JSON output via `serde_json`. /// /// # Errors /// /// Returns an error if: /// - `ncl-import-resolver` exits non-zero (when `resolver-manifest.json` exists). /// - `nickel export` exits non-zero or cannot be found. /// - The JSON output cannot be deserialized into `T`. pub async fn load_nickel_config(ncl_path: &Path) -> anyhow::Result { // Resolve OCI imports if a manifest is co-located with the config file. if let Some(parent) = ncl_path.parent() { let manifest = parent.join("resolver-manifest.json"); if manifest.exists() { let status = Command::new("ncl-import-resolver") .arg(&manifest) .status() .await .context("launching ncl-import-resolver")?; if !status.success() { return Err(anyhow!( "ncl-import-resolver failed (exit {:?}) on '{}'", status.code(), manifest.display() )); } } } // Export the Nickel config to JSON. let out = Command::new("nickel") .args(["export", "--format", "json"]) .arg(ncl_path) .output() .await .with_context(|| { format!( "launching `nickel export` on '{}'", ncl_path.display() ) })?; if !out.status.success() { let stderr = String::from_utf8_lossy(&out.stderr); return Err(anyhow!( "`nickel export` failed on '{}': {}", ncl_path.display(), stderr.trim() )); } serde_json::from_slice::(&out.stdout).with_context(|| { format!( "deserializing Nickel output from '{}' into {}", ncl_path.display(), std::any::type_name::() ) }) } #[cfg(test)] mod tests { use super::*; use serde::Deserialize; use std::io::Write; #[derive(Debug, Deserialize, PartialEq)] struct Simple { name: String, value: u32, } /// Verifies the error path when `nickel` is not installed or the file /// doesn't exist (CI without nickel will hit this branch). #[tokio::test] async fn test_load_nickel_config_missing_file() { let result = load_nickel_config::(Path::new("/nonexistent/path.ncl")).await; assert!( result.is_err(), "expected error for non-existent .ncl file" ); } /// Verifies resolver-manifest detection: if no manifest exists alongside the /// config file, resolver is skipped (only nickel export runs). #[tokio::test] async fn test_load_nickel_config_no_manifest_skips_resolver() { let dir = tempfile::tempdir().unwrap(); let ncl_path = dir.path().join("config.ncl"); std::fs::write(&ncl_path, r#"{ name = "test", value = 42 }"#).unwrap(); // nickel may or may not be installed; either way, no manifest → no resolver invoked. let _ = load_nickel_config::(&ncl_path).await; } }