// vapora-shared: Schema registry - Loads and caches Nickel schemas for // validation Compiles Nickel schemas to internal representation for runtime // validation use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; use tracing::{debug, info, warn}; use super::nickel_bridge::NickelCli; use crate::error::{Result, VaporaError}; /// Compiled schema representation (from Nickel) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CompiledSchema { /// Schema name (e.g., "tools/kanban_create_task") pub name: String, /// Field definitions with types and contracts pub fields: Vec, /// Custom contract predicates (Nickel functions) pub custom_contracts: Vec, /// Source file path pub source_path: PathBuf, } /// Field schema with type and validation contracts #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FieldSchema { /// Field name (flattened path for nested fields) pub name: String, /// Field type (Nickel type) pub field_type: FieldType, /// Is this field required? pub required: bool, /// Validation contracts (predicates) pub contracts: Vec, /// Default value (if field is optional) pub default: Option, /// Documentation string pub doc: Option, } /// Nickel field types #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum FieldType { /// String type String, /// Number type (f64) Number, /// Boolean type Bool, /// Array type with element type Array(Box), /// Object/Record type Object, /// Enum with allowed values Enum(Vec), /// Union of types (Nickel `|`) Union(Vec), } /// Validation contract (Nickel predicate) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum Contract { /// Non-empty string (std.string.NonEmpty) NonEmpty, /// Minimum string length MinLength(usize), /// Maximum string length MaxLength(usize), /// Regex pattern match Pattern(String), /// Numeric range validation Range { min: f64, max: f64 }, /// Greater than (exclusive) GreaterThan(f64), /// Less than (exclusive) LessThan(f64), /// Email validation Email, /// URL validation Url, /// UUID validation Uuid, /// Custom Nickel predicate (function source code) Custom(String), } /// Schema source (file or inline definition) #[derive(Debug, Clone)] pub enum SchemaSource { /// Load from Nickel file File(PathBuf), /// Inline Nickel code Inline(String), /// Pre-compiled schema (for testing) Compiled(CompiledSchema), } /// Registry that loads and caches Nickel schemas pub struct SchemaRegistry { /// Cached compiled schemas schemas: Arc>>, /// Base directory for schema files schema_dir: PathBuf, /// Nickel CLI bridge nickel_cli: NickelCli, } impl SchemaRegistry { /// Create a new schema registry pub fn new(schema_dir: PathBuf) -> Self { Self { schemas: Arc::new(RwLock::new(HashMap::new())), schema_dir, nickel_cli: NickelCli::new(), } } /// Create registry with custom Nickel CLI path pub fn with_nickel_cli(schema_dir: PathBuf, nickel_path: PathBuf) -> Self { Self { schemas: Arc::new(RwLock::new(HashMap::new())), schema_dir, nickel_cli: NickelCli::with_path(nickel_path), } } /// Load schema from Nickel file, compile and cache pub async fn load_schema(&self, schema_name: &str) -> Result { // Check cache first { let cache = self.schemas.read().await; if let Some(schema) = cache.get(schema_name) { debug!("Schema {} loaded from cache", schema_name); return Ok(schema.clone()); } } // Load from file let schema_path = self.resolve_schema_path(schema_name); let compiled = self.compile_schema(&schema_path, schema_name).await?; // Cache it { let mut cache = self.schemas.write().await; cache.insert(schema_name.to_string(), compiled.clone()); } info!("Schema {} compiled and cached", schema_name); Ok(compiled) } /// Load schema from source (file, inline, or pre-compiled) pub async fn load_from_source( &self, source: SchemaSource, schema_name: &str, ) -> Result { match source { SchemaSource::File(path) => self.compile_schema(&path, schema_name).await, SchemaSource::Inline(code) => self.compile_inline(&code, schema_name).await, SchemaSource::Compiled(schema) => Ok(schema), } } /// Resolve schema name to file path fn resolve_schema_path(&self, schema_name: &str) -> PathBuf { let filename = if schema_name.ends_with(".ncl") { schema_name.to_string() } else { format!("{}.ncl", schema_name) }; self.schema_dir.join(filename) } /// Compile Nickel schema from file to internal representation async fn compile_schema(&self, path: &Path, name: &str) -> Result { if !path.exists() { return Err(VaporaError::ValidationError(format!( "Schema file not found: {}", path.display() ))); } // Use nickel query to extract schema metadata let metadata_json = self.nickel_cli.query(path, None).await?; self.parse_nickel_metadata(&metadata_json, name, path) } /// Compile inline Nickel code async fn compile_inline(&self, code: &str, name: &str) -> Result { // Write to temp file for Nickel CLI processing let temp_dir = std::env::temp_dir(); let temp_path = temp_dir.join(format!("vapora_schema_{}.ncl", name.replace('/', "_"))); tokio::fs::write(&temp_path, code).await.map_err(|e| { VaporaError::ValidationError(format!("Failed to write temp file: {}", e)) })?; let result = self.compile_schema(&temp_path, name).await; // Clean up temp file let _ = tokio::fs::remove_file(&temp_path).await; result } /// Parse Nickel metadata JSON into CompiledSchema fn parse_nickel_metadata( &self, json: &serde_json::Value, name: &str, source_path: &Path, ) -> Result { // Extract schema name from metadata or use provided name let schema_name = json["tool_name"] .as_str() .or_else(|| json["schema_name"].as_str()) .unwrap_or(name) .to_string(); // Extract parameters/fields object let params = json["parameters"] .as_object() .or_else(|| json["fields"].as_object()); let fields = match params { Some(params_obj) => params_obj .iter() .map(|(field_name, field_def)| self.parse_field(field_name, field_def)) .collect::>>()?, None => { warn!("No parameters or fields found in schema {}", name); vec![] } }; // Extract custom contracts let custom_contracts = json["contracts"] .as_object() .map(|obj| { obj.iter() .map(|(name, _code)| name.clone()) .collect::>() }) .unwrap_or_default(); Ok(CompiledSchema { name: schema_name, fields, custom_contracts, source_path: source_path.to_path_buf(), }) } /// Parse a single field definition from Nickel metadata fn parse_field(&self, name: &str, def: &serde_json::Value) -> Result { let field_type = self.parse_type(def)?; let contracts = self.extract_contracts(def); let default = def.get("default").cloned(); let required = default.is_none(); let doc = def.get("doc").and_then(|v| v.as_str()).map(String::from); Ok(FieldSchema { name: name.to_string(), field_type, required, contracts, default, doc, }) } /// Parse Nickel type from metadata fn parse_type(&self, def: &serde_json::Value) -> Result { // Type information comes from Nickel metadata let type_str = def["type"].as_str().unwrap_or("String"); let field_type = match type_str { "String" => FieldType::String, "Number" => FieldType::Number, "Bool" => FieldType::Bool, "Object" | "Record" => FieldType::Object, t if t.starts_with("Array") => { // Parse array element type FieldType::Array(Box::new(FieldType::String)) } t if t.starts_with("Enum") => { // Extract enum variants if let Some(variants) = def["variants"].as_array() { let values = variants .iter() .filter_map(|v| v.as_str().map(String::from)) .collect(); FieldType::Enum(values) } else { FieldType::String } } _ => FieldType::String, }; Ok(field_type) } /// Extract validation contracts from Nickel metadata fn extract_contracts(&self, def: &serde_json::Value) -> Vec { let mut contracts = vec![]; // Parse contract annotations from Nickel metadata if let Some(annotations) = def["annotations"].as_array() { for ann in annotations { if let Some(contract) = self.parse_contract_annotation(ann) { contracts.push(contract); } } } contracts } /// Parse a single contract annotation fn parse_contract_annotation(&self, ann: &serde_json::Value) -> Option { let name = ann["name"].as_str()?; match name { "std.string.NonEmpty" => Some(Contract::NonEmpty), "std.string.Email" => Some(Contract::Email), "std.string.Url" => Some(Contract::Url), "std.string.Uuid" => Some(Contract::Uuid), n if n.starts_with("std.string.length.min") => { let min = ann["args"][0].as_u64()? as usize; Some(Contract::MinLength(min)) } n if n.starts_with("std.string.length.max") => { let max = ann["args"][0].as_u64()? as usize; Some(Contract::MaxLength(max)) } n if n.starts_with("std.number.between") => { let min = ann["args"][0].as_f64()?; let max = ann["args"][1].as_f64()?; Some(Contract::Range { min, max }) } n if n.starts_with("std.number.greater_than") => { let min = ann["args"][0].as_f64()?; Some(Contract::GreaterThan(min)) } n if n.starts_with("std.number.less_than") => { let max = ann["args"][0].as_f64()?; Some(Contract::LessThan(max)) } n if n.starts_with("std.string.match") => { let pattern = ann["args"][0].as_str()?.to_string(); Some(Contract::Pattern(pattern)) } _ => { // Custom contract - store source code ann["source"] .as_str() .map(|source| Contract::Custom(source.to_string())) } } } /// Invalidate cache for a specific schema (for hot-reload) pub async fn invalidate(&self, schema_name: &str) { let mut cache = self.schemas.write().await; cache.remove(schema_name); debug!("Schema {} invalidated from cache", schema_name); } /// Invalidate all cached schemas pub async fn invalidate_all(&self) { let mut cache = self.schemas.write().await; let count = cache.len(); cache.clear(); info!("Invalidated {} schemas from cache", count); } /// Get all cached schema names pub async fn list_cached(&self) -> Vec { let cache = self.schemas.read().await; cache.keys().cloned().collect() } /// Check if schema is cached pub async fn is_cached(&self, schema_name: &str) -> bool { let cache = self.schemas.read().await; cache.contains_key(schema_name) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_parse_field_type() { let registry = SchemaRegistry::new(PathBuf::from("/tmp")); let string_def = serde_json::json!({"type": "String"}); let field_type = registry.parse_type(&string_def).unwrap(); assert_eq!(field_type, FieldType::String); let number_def = serde_json::json!({"type": "Number"}); let field_type = registry.parse_type(&number_def).unwrap(); assert_eq!(field_type, FieldType::Number); } #[tokio::test] async fn test_parse_contract_annotation() { let registry = SchemaRegistry::new(PathBuf::from("/tmp")); let non_empty = serde_json::json!({"name": "std.string.NonEmpty"}); let contract = registry.parse_contract_annotation(&non_empty); assert_eq!(contract, Some(Contract::NonEmpty)); let min_length = serde_json::json!({ "name": "std.string.length.min", "args": [5] }); let contract = registry.parse_contract_annotation(&min_length); assert_eq!(contract, Some(Contract::MinLength(5))); let range = serde_json::json!({ "name": "std.number.between", "args": [0.0, 100.0] }); let contract = registry.parse_contract_annotation(&range); assert_eq!( contract, Some(Contract::Range { min: 0.0, max: 100.0 }) ); } #[tokio::test] async fn test_schema_source() { let inline_source = SchemaSource::Inline(r#"{ name = "test" }"#.to_string()); assert!(matches!(inline_source, SchemaSource::Inline(_))); let file_source = SchemaSource::File(PathBuf::from("test.ncl")); assert!(matches!(file_source, SchemaSource::File(_))); } }