use std::collections::HashSet;
use serde_json::Value;
use super::error::{LlmError, LlmResult};
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub is_valid: bool,
pub errors: Vec<ValidationError>,
pub warnings: Vec<String>,
}
impl ValidationResult {
pub fn success() -> Self {
Self {
is_valid: true,
errors: Vec::new(),
warnings: Vec::new(),
}
}
pub fn failure(errors: Vec<ValidationError>) -> Self {
Self {
is_valid: false,
errors,
warnings: Vec::new(),
}
}
pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
self.warnings.push(warning.into());
self
}
pub fn with_warnings(mut self, warnings: Vec<String>) -> Self {
self.warnings.extend(warnings);
self
}
}
#[derive(Debug, Clone)]
pub enum ValidationError {
FieldRemoved { field: String },
FieldRenamed { original: String, renamed: String },
TypeChanged {
field: String,
original: String,
refined: String,
},
StructureChanged { description: String },
RequiredChanged { field: String, was_required: bool },
InvalidStructure { description: String },
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ValidationError::FieldRemoved { field } => {
write!(f, "Field '{}' was removed from the schema", field)
}
ValidationError::FieldRenamed { original, renamed } => {
write!(f, "Field '{}' was renamed to '{}'", original, renamed)
}
ValidationError::TypeChanged {
field,
original,
refined,
} => {
write!(
f,
"Field '{}' type changed incompatibly from '{}' to '{}'",
field, original, refined
)
}
ValidationError::StructureChanged { description } => {
write!(f, "Schema structure changed: {}", description)
}
ValidationError::RequiredChanged {
field,
was_required,
} => {
write!(
f,
"Field '{}' required status changed (was_required: {})",
field, was_required
)
}
ValidationError::InvalidStructure { description } => {
write!(f, "Invalid schema structure: {}", description)
}
}
}
}
pub fn validate_refinement(original: &Value, refined: &Value) -> ValidationResult {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let (orig_obj, refined_obj) = match (original.as_object(), refined.as_object()) {
(Some(o), Some(r)) => (o, r),
_ => {
return ValidationResult::failure(vec![ValidationError::InvalidStructure {
description: "Both schemas must be JSON objects".to_string(),
}]);
}
};
let orig_props = orig_obj.get("properties").and_then(|v| v.as_object());
let refined_props = refined_obj.get("properties").and_then(|v| v.as_object());
match (orig_props, refined_props) {
(Some(orig_p), Some(refined_p)) => {
validate_properties(orig_p, refined_p, "", &mut errors, &mut warnings);
}
(None, None) => {
validate_object_fields(orig_obj, refined_obj, "", &mut errors, &mut warnings);
}
_ => {
errors.push(ValidationError::StructureChanged {
description: "Properties structure mismatch".to_string(),
});
}
}
let orig_required: HashSet<&str> = orig_obj
.get("required")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let refined_required: HashSet<&str> = refined_obj
.get("required")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
for field in orig_required.difference(&refined_required) {
errors.push(ValidationError::RequiredChanged {
field: field.to_string(),
was_required: true,
});
}
for field in refined_required.difference(&orig_required) {
warnings.push(format!(
"Field '{}' is now marked as required (was optional)",
field
));
}
if errors.is_empty() {
ValidationResult::success().with_warnings(warnings)
} else {
ValidationResult::failure(errors).with_warnings(warnings)
}
}
fn validate_properties(
original: &serde_json::Map<String, Value>,
refined: &serde_json::Map<String, Value>,
path_prefix: &str,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<String>,
) {
for (field_name, orig_value) in original {
let full_path = if path_prefix.is_empty() {
field_name.clone()
} else {
format!("{}.{}", path_prefix, field_name)
};
match refined.get(field_name) {
Some(refined_value) => {
validate_field(
field_name,
orig_value,
refined_value,
&full_path,
errors,
warnings,
);
}
None => {
errors.push(ValidationError::FieldRemoved {
field: full_path.clone(),
});
}
}
}
for field_name in refined.keys() {
if !original.contains_key(field_name) {
let full_path = if path_prefix.is_empty() {
field_name.clone()
} else {
format!("{}.{}", path_prefix, field_name)
};
warnings.push(format!("New field added: {}", full_path));
}
}
}
fn validate_field(
_field_name: &str,
original: &Value,
refined: &Value,
full_path: &str,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<String>,
) {
let orig_obj = original.as_object();
let refined_obj = refined.as_object();
match (orig_obj, refined_obj) {
(Some(orig), Some(ref_obj)) => {
if let (Some(orig_type), Some(ref_type)) = (orig.get("type"), ref_obj.get("type")) {
if !is_type_compatible(orig_type, ref_type) {
errors.push(ValidationError::TypeChanged {
field: full_path.to_string(),
original: orig_type.to_string(),
refined: ref_type.to_string(),
});
}
}
if let (Some(orig_props), Some(ref_props)) = (
orig.get("properties").and_then(|v| v.as_object()),
ref_obj.get("properties").and_then(|v| v.as_object()),
) {
validate_properties(orig_props, ref_props, full_path, errors, warnings);
}
if ref_obj.get("description").is_some() && orig.get("description").is_none() {
warnings.push(format!("Description added to field: {}", full_path));
}
if ref_obj.get("format").is_some() && orig.get("format").is_none() {
warnings.push(format!("Format added to field: {}", full_path));
}
}
_ => {
if original != refined {
warnings.push(format!("Field {} value changed", full_path));
}
}
}
}
fn validate_object_fields(
original: &serde_json::Map<String, Value>,
refined: &serde_json::Map<String, Value>,
path_prefix: &str,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<String>,
) {
for (field_name, orig_value) in original {
let full_path = if path_prefix.is_empty() {
field_name.clone()
} else {
format!("{}.{}", path_prefix, field_name)
};
match refined.get(field_name) {
Some(refined_value) => {
if let (Some(orig_obj), Some(refined_obj)) =
(orig_value.as_object(), refined_value.as_object())
{
validate_object_fields(orig_obj, refined_obj, &full_path, errors, warnings);
}
}
None => {
errors.push(ValidationError::FieldRemoved {
field: full_path.clone(),
});
}
}
}
for field_name in refined.keys() {
if !original.contains_key(field_name) {
let full_path = if path_prefix.is_empty() {
field_name.clone()
} else {
format!("{}.{}", path_prefix, field_name)
};
warnings.push(format!("New field added: {}", full_path));
}
}
}
fn is_type_compatible(original: &Value, refined: &Value) -> bool {
match (original, refined) {
(Value::String(o), Value::String(r)) if o == r => true,
(Value::Array(orig_types), Value::Array(ref_types)) => {
ref_types.iter().all(|rt| orig_types.contains(rt))
}
(Value::Array(orig_types), Value::String(_)) => orig_types.contains(refined),
(Value::String(o), Value::Array(ref_types)) => {
ref_types.len() == 1 && ref_types.contains(&Value::String(o.clone()))
}
(Value::String(o), Value::String(r)) => {
o == r || (o == "number" && r == "integer")
}
_ => false,
}
}
impl ValidationResult {
pub fn to_result(&self) -> LlmResult<()> {
if self.is_valid {
Ok(())
} else {
let error_messages: Vec<String> = self.errors.iter().map(|e| e.to_string()).collect();
Err(LlmError::ValidationError(error_messages.join("; ")))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_validate_identical_schemas() {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
}
});
let result = validate_refinement(&schema, &schema);
assert!(result.is_valid);
assert!(result.errors.is_empty());
}
#[test]
fn test_validate_added_description() {
let original = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
}
});
let refined = json!({
"type": "object",
"properties": {
"name": {"type": "string", "description": "Customer name"}
}
});
let result = validate_refinement(&original, &refined);
assert!(result.is_valid);
assert!(
result
.warnings
.iter()
.any(|w| w.contains("Description added"))
);
}
#[test]
fn test_validate_field_removed() {
let original = json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"}
}
});
let refined = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
}
});
let result = validate_refinement(&original, &refined);
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::FieldRemoved { field } if field == "age"))
);
}
#[test]
fn test_validate_type_changed() {
let original = json!({
"type": "object",
"properties": {
"count": {"type": "string"}
}
});
let refined = json!({
"type": "object",
"properties": {
"count": {"type": "integer"}
}
});
let result = validate_refinement(&original, &refined);
assert!(!result.is_valid);
assert!(
result
.errors
.iter()
.any(|e| matches!(e, ValidationError::TypeChanged { .. }))
);
}
#[test]
fn test_validate_number_to_integer_allowed() {
let original = json!({
"type": "object",
"properties": {
"count": {"type": "number"}
}
});
let refined = json!({
"type": "object",
"properties": {
"count": {"type": "integer"}
}
});
let result = validate_refinement(&original, &refined);
assert!(result.is_valid);
}
#[test]
fn test_validate_required_removed() {
let original = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": ["name"]
});
let refined = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
},
"required": []
});
let result = validate_refinement(&original, &refined);
assert!(!result.is_valid);
assert!(result.errors.iter().any(|e| matches!(e, ValidationError::RequiredChanged { field, was_required: true } if field == "name")));
}
#[test]
fn test_validate_new_field_warning() {
let original = json!({
"type": "object",
"properties": {
"name": {"type": "string"}
}
});
let refined = json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"new_field": {"type": "string"}
}
});
let result = validate_refinement(&original, &refined);
assert!(result.is_valid);
assert!(result.warnings.iter().any(|w| w.contains("new_field")));
}
#[test]
fn test_validate_nested_properties() {
let original = json!({
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": {"type": "string"},
"city": {"type": "string"}
}
}
}
});
let refined = json!({
"type": "object",
"properties": {
"address": {
"type": "object",
"properties": {
"street": {"type": "string"}
}
}
}
});
let result = validate_refinement(&original, &refined);
assert!(!result.is_valid);
assert!(result.errors.iter().any(
|e| matches!(e, ValidationError::FieldRemoved { field } if field.contains("city"))
));
}
#[test]
fn test_validation_error_display() {
let err = ValidationError::FieldRemoved {
field: "test_field".to_string(),
};
assert!(err.to_string().contains("test_field"));
assert!(err.to_string().contains("removed"));
let err = ValidationError::TypeChanged {
field: "count".to_string(),
original: "string".to_string(),
refined: "integer".to_string(),
};
assert!(err.to_string().contains("count"));
assert!(err.to_string().contains("string"));
assert!(err.to_string().contains("integer"));
}
#[test]
fn test_validation_result_to_result() {
let success = ValidationResult::success();
assert!(success.to_result().is_ok());
let failure = ValidationResult::failure(vec![ValidationError::FieldRemoved {
field: "test".to_string(),
}]);
assert!(failure.to_result().is_err());
}
#[test]
fn test_is_type_compatible() {
assert!(is_type_compatible(&json!("string"), &json!("string")));
assert!(is_type_compatible(&json!("number"), &json!("integer")));
assert!(!is_type_compatible(&json!("string"), &json!("integer")));
assert!(is_type_compatible(
&json!(["string", "null"]),
&json!("string")
));
}
}