2026-01-14 21:12:49 +00:00

255 lines
7.6 KiB
Rust

// vapora-shared: Nickel CLI bridge - Execute Nickel commands for schema
// operations Provides interface to nickel query, typecheck, and export commands
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command;
use tracing::debug;
use crate::error::{Result, VaporaError};
/// Nickel CLI bridge for schema operations
#[derive(Debug, Clone)]
pub struct NickelCli {
/// Path to nickel executable
nickel_path: PathBuf,
/// Timeout for nickel commands (milliseconds)
timeout_ms: u64,
}
impl NickelCli {
/// Create new Nickel CLI bridge with default path
pub fn new() -> Self {
Self {
nickel_path: PathBuf::from("nickel"),
timeout_ms: 30_000, // 30 seconds
}
}
/// Create Nickel CLI bridge with custom path
pub fn with_path(nickel_path: PathBuf) -> Self {
Self {
nickel_path,
timeout_ms: 30_000,
}
}
/// Set timeout for Nickel commands
pub fn with_timeout(mut self, timeout_ms: u64) -> Self {
self.timeout_ms = timeout_ms;
self
}
/// Execute `nickel query` to extract schema metadata
///
/// Returns JSON representation of Nickel value
pub async fn query(&self, path: &Path, field: Option<&str>) -> Result<serde_json::Value> {
let mut args = vec!["query", "--format", "json"];
if let Some(f) = field {
args.push("--field");
args.push(f);
}
let path_str = path.to_str().ok_or_else(|| {
VaporaError::ValidationError(format!("Invalid path: {}", path.display()))
})?;
args.push(path_str);
debug!("Executing: nickel {}", args.join(" "));
let output = self.execute_with_timeout(&args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VaporaError::ValidationError(format!(
"Nickel query failed: {}",
stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).map_err(|e| {
VaporaError::ValidationError(format!("Failed to parse Nickel output: {}", e))
})
}
/// Execute `nickel typecheck` to validate schema
///
/// Returns Ok(()) if typecheck passes, Err with details if it fails
pub async fn typecheck(&self, path: &Path) -> Result<()> {
let path_str = path.to_str().ok_or_else(|| {
VaporaError::ValidationError(format!("Invalid path: {}", path.display()))
})?;
let args = vec!["typecheck", path_str];
debug!("Executing: nickel {}", args.join(" "));
let output = self.execute_with_timeout(&args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VaporaError::ValidationError(format!(
"Nickel typecheck failed: {}",
stderr
)));
}
Ok(())
}
/// Execute `nickel export` to generate JSON output
///
/// Returns exported JSON value
pub async fn export(&self, path: &Path) -> Result<serde_json::Value> {
let path_str = path.to_str().ok_or_else(|| {
VaporaError::ValidationError(format!("Invalid path: {}", path.display()))
})?;
let args = vec!["export", "--format", "json", path_str];
debug!("Executing: nickel {}", args.join(" "));
let output = self.execute_with_timeout(&args).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(VaporaError::ValidationError(format!(
"Nickel export failed: {}",
stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).map_err(|e| {
VaporaError::ValidationError(format!("Failed to parse Nickel export: {}", e))
})
}
/// Check if Nickel CLI is available
pub async fn is_available(&self) -> bool {
let result = Command::new(&self.nickel_path)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await;
match result {
Ok(status) => status.success(),
Err(_) => false,
}
}
/// Get Nickel CLI version
pub async fn version(&self) -> Result<String> {
let output = Command::new(&self.nickel_path)
.arg("--version")
.output()
.await
.map_err(|e| {
VaporaError::ValidationError(format!("Failed to get Nickel version: {}", e))
})?;
if !output.status.success() {
return Err(VaporaError::ValidationError(
"Failed to get Nickel version".to_string(),
));
}
let version = String::from_utf8_lossy(&output.stdout);
Ok(version.trim().to_string())
}
/// Execute Nickel command with timeout
async fn execute_with_timeout(&self, args: &[&str]) -> Result<std::process::Output> {
let child = Command::new(&self.nickel_path)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
VaporaError::ValidationError(format!("Failed to execute Nickel: {}", e))
})?;
// Wait with timeout
let timeout = tokio::time::Duration::from_millis(self.timeout_ms);
let result = tokio::time::timeout(timeout, child.wait_with_output()).await;
match result {
Ok(Ok(output)) => Ok(output),
Ok(Err(e)) => Err(VaporaError::ValidationError(format!(
"Nickel command failed: {}",
e
))),
Err(_) => {
// Timeout occurred - process is still running but we give up
Err(VaporaError::ValidationError(format!(
"Nickel command timed out after {}ms",
self.timeout_ms
)))
}
}
}
}
impl Default for NickelCli {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_nickel_cli_creation() {
let cli = NickelCli::new();
assert_eq!(cli.nickel_path, PathBuf::from("nickel"));
assert_eq!(cli.timeout_ms, 30_000);
}
#[tokio::test]
async fn test_nickel_cli_with_path() {
let custom_path = PathBuf::from("/usr/local/bin/nickel");
let cli = NickelCli::with_path(custom_path.clone());
assert_eq!(cli.nickel_path, custom_path);
}
#[tokio::test]
async fn test_nickel_cli_with_timeout() {
let cli = NickelCli::new().with_timeout(5_000);
assert_eq!(cli.timeout_ms, 5_000);
}
#[tokio::test]
#[ignore] // Requires Nickel CLI installed
async fn test_nickel_is_available() {
let cli = NickelCli::new();
let available = cli.is_available().await;
// This will pass if nickel is in PATH
if available {
println!("Nickel CLI is available");
} else {
println!("Nickel CLI is not available (install it to run this test)");
}
}
#[tokio::test]
#[ignore] // Requires Nickel CLI installed
async fn test_nickel_version() {
let cli = NickelCli::new();
if cli.is_available().await {
let version = cli.version().await.unwrap();
println!("Nickel version: {}", version);
assert!(!version.is_empty());
}
}
}