syntaxis/shared/rust/nickel.rs

119 lines
3.8 KiB
Rust
Raw Normal View History

//! 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 <manifest>` to resolve OCI imports.
/// 2. Run `nickel export --format json <ncl_path>` 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<T: DeserializeOwned>(ncl_path: &Path) -> anyhow::Result<T> {
// 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::<T>(&out.stdout).with_context(|| {
format!(
"deserializing Nickel output from '{}' into {}",
ncl_path.display(),
std::any::type_name::<T>()
)
})
}
#[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::<Simple>(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::<Simple>(&ncl_path).await;
}
}