quillmark_core/
validation.rs

1//! Schema validation module for Quillmark.
2//!
3//! This module provides utilities for converting TOML field definitions to JSON Schema
4//! and validating ParsedDocument data against schemas.
5
6use crate::{quill::FieldSchema, QuillValue, RenderError};
7use serde_json::{json, Map, Value};
8use std::collections::HashMap;
9
10/// Convert a HashMap of FieldSchema to a JSON Schema object
11pub fn build_schema_from_fields(
12    field_schemas: &HashMap<String, FieldSchema>,
13) -> Result<QuillValue, RenderError> {
14    let mut properties = Map::new();
15    let mut required_fields = Vec::new();
16
17    for (field_name, field_schema) in field_schemas {
18        // Build property schema
19        let mut property = Map::new();
20
21        // Add name
22        property.insert("name".to_string(), Value::String(field_schema.name.clone()));
23
24        // Add type if specified
25        if let Some(ref field_type) = field_schema.r#type {
26            let json_type = match field_type.as_str() {
27                "str" => "string",
28                "number" => "number",
29                "array" => "array",
30                "dict" => "object",
31                "date" => "string",
32                "datetime" => "string",
33                _ => "string", // default to string for unknown types
34            };
35            property.insert("type".to_string(), Value::String(json_type.to_string()));
36
37            // Add format for date types
38            if field_type == "date" {
39                property.insert("format".to_string(), Value::String("date".to_string()));
40            } else if field_type == "datetime" {
41                property.insert("format".to_string(), Value::String("date-time".to_string()));
42            }
43        }
44
45        // Add description
46        property.insert(
47            "description".to_string(),
48            Value::String(field_schema.description.clone()),
49        );
50
51        properties.insert(field_name.clone(), Value::Object(property));
52
53        // Determine if field is required based on the spec:
54        // - If default is present → field is optional
55        // - If default is absent → field is required
56        if field_schema.default.is_none() {
57            required_fields.push(field_name.clone());
58        }
59    }
60
61    // Build the complete JSON Schema
62    let schema = json!({
63        "$schema": "https://json-schema.org/draft/2019-09/schema",
64        "type": "object",
65        "properties": properties,
66        "required": required_fields,
67        "additionalProperties": true
68    });
69
70    Ok(QuillValue::from_json(schema))
71}
72
73/// Validate a document's fields against a JSON Schema
74pub fn validate_document(
75    schema: &QuillValue,
76    fields: &HashMap<String, crate::value::QuillValue>,
77) -> Result<(), Vec<String>> {
78    // Convert fields to JSON Value for validation
79    let mut doc_json = Map::new();
80    for (key, value) in fields {
81        doc_json.insert(key.clone(), value.as_json().clone());
82    }
83    let doc_value = Value::Object(doc_json);
84
85    // Compile the schema
86    let compiled = match jsonschema::Validator::new(schema.as_json()) {
87        Ok(c) => c,
88        Err(e) => return Err(vec![format!("Failed to compile schema: {}", e)]),
89    };
90
91    // Validate the document and collect errors immediately
92    let validation_result = compiled.validate(&doc_value);
93
94    match validation_result {
95        Ok(_) => Ok(()),
96        Err(error) => {
97            let path = error.instance_path.to_string();
98            let path_display = if path.is_empty() {
99                "document".to_string()
100            } else {
101                path
102            };
103            let message = format!("Validation error at {}: {}", path_display, error);
104            Err(vec![message])
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use crate::quill::FieldSchema;
113    use crate::value::QuillValue;
114
115    #[test]
116    fn test_build_schema_simple() {
117        let mut fields = HashMap::new();
118        let mut schema = FieldSchema::new(
119            "Author name".to_string(),
120            "The name of the author".to_string(),
121        );
122        schema.r#type = Some("str".to_string());
123        fields.insert("author".to_string(), schema);
124
125        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
126        assert_eq!(json_schema["type"], "object");
127        assert_eq!(json_schema["properties"]["author"]["type"], "string");
128        assert_eq!(json_schema["properties"]["author"]["name"], "Author name");
129        assert_eq!(
130            json_schema["properties"]["author"]["description"],
131            "The name of the author"
132        );
133    }
134
135    #[test]
136    fn test_build_schema_with_default() {
137        let mut fields = HashMap::new();
138        let mut schema = FieldSchema::new(
139            "Field with default".to_string(),
140            "A field with a default value".to_string(),
141        );
142        schema.r#type = Some("str".to_string());
143        schema.default = Some(QuillValue::from_json(json!("default value")));
144        // When default is present, field should be optional regardless of required flag
145        fields.insert("with_default".to_string(), schema);
146
147        build_schema_from_fields(&fields).unwrap();
148    }
149
150    #[test]
151    fn test_build_schema_date_types() {
152        let mut fields = HashMap::new();
153
154        let mut date_schema =
155            FieldSchema::new("Date field".to_string(), "A field for dates".to_string());
156        date_schema.r#type = Some("date".to_string());
157        fields.insert("date_field".to_string(), date_schema);
158
159        let mut datetime_schema = FieldSchema::new(
160            "DateTime field".to_string(),
161            "A field for date and time".to_string(),
162        );
163        datetime_schema.r#type = Some("datetime".to_string());
164        fields.insert("datetime_field".to_string(), datetime_schema);
165
166        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
167        assert_eq!(json_schema["properties"]["date_field"]["type"], "string");
168        assert_eq!(json_schema["properties"]["date_field"]["format"], "date");
169        assert_eq!(
170            json_schema["properties"]["datetime_field"]["type"],
171            "string"
172        );
173        assert_eq!(
174            json_schema["properties"]["datetime_field"]["format"],
175            "date-time"
176        );
177    }
178
179    #[test]
180    fn test_validate_document_success() {
181        let schema = json!({
182            "$schema": "https://json-schema.org/draft/2019-09/schema",
183            "type": "object",
184            "properties": {
185                "title": {"type": "string"},
186                "count": {"type": "number"}
187            },
188            "required": ["title"],
189            "additionalProperties": true
190        });
191
192        let mut fields = HashMap::new();
193        fields.insert(
194            "title".to_string(),
195            QuillValue::from_json(json!("Test Title")),
196        );
197        fields.insert("count".to_string(), QuillValue::from_json(json!(42)));
198
199        let result = validate_document(&QuillValue::from_json(schema), &fields);
200        assert!(result.is_ok());
201    }
202
203    #[test]
204    fn test_validate_document_missing_required() {
205        let schema = json!({
206            "$schema": "https://json-schema.org/draft/2019-09/schema",
207            "type": "object",
208            "properties": {
209                "title": {"type": "string"}
210            },
211            "required": ["title"],
212            "additionalProperties": true
213        });
214
215        let fields = HashMap::new(); // empty, missing required field
216
217        let result = validate_document(&QuillValue::from_json(schema), &fields);
218        assert!(result.is_err());
219        let errors = result.unwrap_err();
220        assert!(!errors.is_empty());
221    }
222
223    #[test]
224    fn test_validate_document_wrong_type() {
225        let schema = json!({
226            "$schema": "https://json-schema.org/draft/2019-09/schema",
227            "type": "object",
228            "properties": {
229                "count": {"type": "number"}
230            },
231            "additionalProperties": true
232        });
233
234        let mut fields = HashMap::new();
235        fields.insert(
236            "count".to_string(),
237            QuillValue::from_json(json!("not a number")),
238        );
239
240        let result = validate_document(&QuillValue::from_json(schema), &fields);
241        assert!(result.is_err());
242    }
243
244    #[test]
245    fn test_validate_document_allows_extra_fields() {
246        let schema = json!({
247            "$schema": "https://json-schema.org/draft/2019-09/schema",
248            "type": "object",
249            "properties": {
250                "title": {"type": "string"}
251            },
252            "required": ["title"],
253            "additionalProperties": true
254        });
255
256        let mut fields = HashMap::new();
257        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
258        fields.insert("extra".to_string(), QuillValue::from_json(json!("allowed")));
259
260        let result = validate_document(&QuillValue::from_json(schema), &fields);
261        assert!(result.is_ok());
262    }
263}