// 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 { 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 { 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 { 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 { 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()); } } }