482 lines
14 KiB
Rust
Raw Normal View History

2026-01-14 21:12:49 +00:00
// 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<FieldSchema>,
/// Custom contract predicates (Nickel functions)
pub custom_contracts: Vec<String>,
/// 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<Contract>,
/// Default value (if field is optional)
pub default: Option<serde_json::Value>,
/// Documentation string
pub doc: Option<String>,
}
/// 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<FieldType>),
/// Object/Record type
Object,
/// Enum with allowed values
Enum(Vec<String>),
/// Union of types (Nickel `|`)
Union(Vec<FieldType>),
}
/// 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<RwLock<HashMap<String, CompiledSchema>>>,
/// 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<CompiledSchema> {
// 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<CompiledSchema> {
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<CompiledSchema> {
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<CompiledSchema> {
// 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<CompiledSchema> {
// 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::<Result<Vec<_>>>()?,
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::<Vec<_>>()
})
.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<FieldSchema> {
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<FieldType> {
// 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<Contract> {
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<Contract> {
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<String> {
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(_)));
}
}