quillmark_core/
schema.rs

1//! Schema validation and utilities 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        // Add UI metadata as x-ui property if present
52        if let Some(ref ui) = field_schema.ui {
53            let mut ui_obj = Map::new();
54
55            if let Some(ref group) = ui.group {
56                ui_obj.insert("group".to_string(), Value::String(group.clone()));
57            }
58
59            if let Some(ref tooltip) = ui.tooltip {
60                ui_obj.insert("tooltip".to_string(), Value::String(tooltip.clone()));
61            }
62
63            if let Some(order) = ui.order {
64                ui_obj.insert("order".to_string(), json!(order));
65            }
66
67            property.insert("x-ui".to_string(), Value::Object(ui_obj));
68        }
69
70        let mut examples_array = if let Some(ref examples) = field_schema.examples {
71            examples.as_array().cloned().unwrap_or_else(Vec::new)
72        } else {
73            Vec::new()
74        };
75
76        // Add example (singular) if specified after examples
77        if let Some(ref example) = field_schema.example {
78            examples_array.push(example.as_json().clone());
79        }
80        if !examples_array.is_empty() {
81            property.insert("examples".to_string(), Value::Array(examples_array));
82        }
83
84        // Add default if specified
85        if let Some(ref default) = field_schema.default {
86            property.insert("default".to_string(), default.as_json().clone());
87        }
88
89        properties.insert(field_name.clone(), Value::Object(property));
90
91        // Determine if field is required based on the spec:
92        // - If default is present → field is optional
93        // - If default is absent → field is required
94        if field_schema.default.is_none() {
95            required_fields.push(field_name.clone());
96        }
97    }
98
99    // Build the complete JSON Schema
100    let schema = json!({
101        "$schema": "https://json-schema.org/draft/2019-09/schema",
102        "type": "object",
103        "properties": properties,
104        "required": required_fields,
105        "additionalProperties": true
106    });
107
108    Ok(QuillValue::from_json(schema))
109}
110
111/// Extract default values from a JSON Schema
112///
113/// Parses the JSON schema's "properties" object and extracts any "default" values
114/// defined for each property. Returns a HashMap mapping field names to their default
115/// values.
116///
117/// # Arguments
118///
119/// * `schema` - A JSON Schema object (must have "properties" field)
120///
121/// # Returns
122///
123/// A HashMap of field names to their default QuillValues
124pub fn extract_defaults_from_schema(
125    schema: &QuillValue,
126) -> HashMap<String, crate::value::QuillValue> {
127    let mut defaults = HashMap::new();
128
129    // Get the properties object from the schema
130    if let Some(properties) = schema.as_json().get("properties") {
131        if let Some(properties_obj) = properties.as_object() {
132            for (field_name, field_schema) in properties_obj {
133                // Check if this field has a default value
134                if let Some(default_value) = field_schema.get("default") {
135                    defaults.insert(
136                        field_name.clone(),
137                        QuillValue::from_json(default_value.clone()),
138                    );
139                }
140            }
141        }
142    }
143
144    defaults
145}
146
147/// Extract example values from a JSON Schema
148///
149/// Parses the JSON schema's "properties" object and extracts any "examples" arrays
150/// defined for each property. Returns a HashMap mapping field names to their examples
151/// (as an array of QuillValues).
152///
153/// # Arguments
154///
155/// * `schema` - A JSON Schema object (must have "properties" field)
156///
157/// # Returns
158///
159/// A HashMap of field names to their examples (``Vec<QuillValue>``)
160pub fn extract_examples_from_schema(
161    schema: &QuillValue,
162) -> HashMap<String, Vec<crate::value::QuillValue>> {
163    let mut examples = HashMap::new();
164
165    // Get the properties object from the schema
166    if let Some(properties) = schema.as_json().get("properties") {
167        if let Some(properties_obj) = properties.as_object() {
168            for (field_name, field_schema) in properties_obj {
169                // Check if this field has examples
170                if let Some(examples_value) = field_schema.get("examples") {
171                    if let Some(examples_array) = examples_value.as_array() {
172                        let examples_vec: Vec<QuillValue> = examples_array
173                            .iter()
174                            .map(|v| QuillValue::from_json(v.clone()))
175                            .collect();
176                        if !examples_vec.is_empty() {
177                            examples.insert(field_name.clone(), examples_vec);
178                        }
179                    }
180                }
181            }
182        }
183    }
184
185    examples
186}
187
188/// Validate a document's fields against a JSON Schema
189pub fn validate_document(
190    schema: &QuillValue,
191    fields: &HashMap<String, crate::value::QuillValue>,
192) -> Result<(), Vec<String>> {
193    // Convert fields to JSON Value for validation
194    let mut doc_json = Map::new();
195    for (key, value) in fields {
196        doc_json.insert(key.clone(), value.as_json().clone());
197    }
198    let doc_value = Value::Object(doc_json);
199
200    // Compile the schema
201    let compiled = match jsonschema::Validator::new(schema.as_json()) {
202        Ok(c) => c,
203        Err(e) => return Err(vec![format!("Failed to compile schema: {}", e)]),
204    };
205
206    // Validate the document and collect errors immediately
207    let validation_result = compiled.validate(&doc_value);
208
209    match validation_result {
210        Ok(_) => Ok(()),
211        Err(error) => {
212            let path = error.instance_path.to_string();
213            let path_display = if path.is_empty() {
214                "document".to_string()
215            } else {
216                path
217            };
218            let message = format!("Validation error at {}: {}", path_display, error);
219            Err(vec![message])
220        }
221    }
222}
223
224/// Coerce a single value to match the expected schema type
225///
226/// Performs type coercions such as:
227/// - Singular values to single-element arrays when schema expects array
228/// - String "true"/"false" to boolean
229/// - Number 0/1 to boolean
230/// - String numbers to number type
231/// - Boolean to number (true->1, false->0)
232fn coerce_value(value: &QuillValue, expected_type: &str) -> QuillValue {
233    let json_value = value.as_json();
234
235    match expected_type {
236        "array" => {
237            // If value is already an array, return as-is
238            if json_value.is_array() {
239                return value.clone();
240            }
241            // Otherwise, wrap the value in a single-element array
242            QuillValue::from_json(Value::Array(vec![json_value.clone()]))
243        }
244        "boolean" => {
245            // If already a boolean, return as-is
246            if let Some(b) = json_value.as_bool() {
247                return QuillValue::from_json(Value::Bool(b));
248            }
249            // Coerce from string "true"/"false" (case-insensitive)
250            if let Some(s) = json_value.as_str() {
251                let lower = s.to_lowercase();
252                if lower == "true" {
253                    return QuillValue::from_json(Value::Bool(true));
254                } else if lower == "false" {
255                    return QuillValue::from_json(Value::Bool(false));
256                }
257            }
258            // Coerce from number (0 = false, non-zero = true)
259            if let Some(n) = json_value.as_i64() {
260                return QuillValue::from_json(Value::Bool(n != 0));
261            }
262            if let Some(n) = json_value.as_f64() {
263                // Handle NaN and use epsilon comparison for zero
264                if n.is_nan() {
265                    return QuillValue::from_json(Value::Bool(false));
266                }
267                return QuillValue::from_json(Value::Bool(n.abs() > f64::EPSILON));
268            }
269            // Can't coerce, return as-is
270            value.clone()
271        }
272        "number" => {
273            // If already a number, return as-is
274            if json_value.is_number() {
275                return value.clone();
276            }
277            // Coerce from string
278            if let Some(s) = json_value.as_str() {
279                // Try parsing as integer first
280                if let Ok(i) = s.parse::<i64>() {
281                    return QuillValue::from_json(serde_json::Number::from(i).into());
282                }
283                // Try parsing as float
284                if let Ok(f) = s.parse::<f64>() {
285                    if let Some(num) = serde_json::Number::from_f64(f) {
286                        return QuillValue::from_json(num.into());
287                    }
288                }
289            }
290            // Coerce from boolean (true -> 1, false -> 0)
291            if let Some(b) = json_value.as_bool() {
292                let num_value = if b { 1 } else { 0 };
293                return QuillValue::from_json(Value::Number(serde_json::Number::from(num_value)));
294            }
295            // Can't coerce, return as-is
296            value.clone()
297        }
298        _ => {
299            // For other types (string, object, etc.), no coercion needed
300            value.clone()
301        }
302    }
303}
304
305/// Coerce document fields to match the expected schema types
306///
307/// This function applies type coercions to document fields based on the schema.
308/// It's useful for handling flexible input formats.
309///
310/// # Arguments
311///
312/// * `schema` - A JSON Schema object (must have "properties" field)
313/// * `fields` - The document fields to coerce
314///
315/// # Returns
316///
317/// A new HashMap with coerced field values
318pub fn coerce_document(
319    schema: &QuillValue,
320    fields: &HashMap<String, QuillValue>,
321) -> HashMap<String, QuillValue> {
322    let mut coerced_fields = HashMap::new();
323
324    // Get the properties object from the schema
325    let properties = match schema.as_json().get("properties") {
326        Some(props) => props,
327        None => {
328            // No properties defined, return fields as-is
329            return fields.clone();
330        }
331    };
332
333    let properties_obj = match properties.as_object() {
334        Some(obj) => obj,
335        None => {
336            // Properties is not an object, return fields as-is
337            return fields.clone();
338        }
339    };
340
341    // Process each field
342    for (field_name, field_value) in fields {
343        // Check if there's a schema definition for this field
344        if let Some(field_schema) = properties_obj.get(field_name) {
345            // Get the expected type
346            if let Some(expected_type) = field_schema.get("type").and_then(|t| t.as_str()) {
347                // Apply coercion
348                let coerced_value = coerce_value(field_value, expected_type);
349                coerced_fields.insert(field_name.clone(), coerced_value);
350                continue;
351            }
352        }
353        // No schema or no type specified, keep the field as-is
354        coerced_fields.insert(field_name.clone(), field_value.clone());
355    }
356
357    coerced_fields
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::quill::FieldSchema;
364    use crate::value::QuillValue;
365
366    #[test]
367    fn test_build_schema_simple() {
368        let mut fields = HashMap::new();
369        let mut schema = FieldSchema::new(
370            "Author name".to_string(),
371            "The name of the author".to_string(),
372        );
373        schema.r#type = Some("str".to_string());
374        fields.insert("author".to_string(), schema);
375
376        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
377        assert_eq!(json_schema["type"], "object");
378        assert_eq!(json_schema["properties"]["author"]["type"], "string");
379        assert_eq!(json_schema["properties"]["author"]["name"], "Author name");
380        assert_eq!(
381            json_schema["properties"]["author"]["description"],
382            "The name of the author"
383        );
384    }
385
386    #[test]
387    fn test_build_schema_with_default() {
388        let mut fields = HashMap::new();
389        let mut schema = FieldSchema::new(
390            "Field with default".to_string(),
391            "A field with a default value".to_string(),
392        );
393        schema.r#type = Some("str".to_string());
394        schema.default = Some(QuillValue::from_json(json!("default value")));
395        // When default is present, field should be optional regardless of required flag
396        fields.insert("with_default".to_string(), schema);
397
398        build_schema_from_fields(&fields).unwrap();
399    }
400
401    #[test]
402    fn test_build_schema_date_types() {
403        let mut fields = HashMap::new();
404
405        let mut date_schema =
406            FieldSchema::new("Date field".to_string(), "A field for dates".to_string());
407        date_schema.r#type = Some("date".to_string());
408        fields.insert("date_field".to_string(), date_schema);
409
410        let mut datetime_schema = FieldSchema::new(
411            "DateTime field".to_string(),
412            "A field for date and time".to_string(),
413        );
414        datetime_schema.r#type = Some("datetime".to_string());
415        fields.insert("datetime_field".to_string(), datetime_schema);
416
417        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
418        assert_eq!(json_schema["properties"]["date_field"]["type"], "string");
419        assert_eq!(json_schema["properties"]["date_field"]["format"], "date");
420        assert_eq!(
421            json_schema["properties"]["datetime_field"]["type"],
422            "string"
423        );
424        assert_eq!(
425            json_schema["properties"]["datetime_field"]["format"],
426            "date-time"
427        );
428    }
429
430    #[test]
431    fn test_validate_document_success() {
432        let schema = json!({
433            "$schema": "https://json-schema.org/draft/2019-09/schema",
434            "type": "object",
435            "properties": {
436                "title": {"type": "string"},
437                "count": {"type": "number"}
438            },
439            "required": ["title"],
440            "additionalProperties": true
441        });
442
443        let mut fields = HashMap::new();
444        fields.insert(
445            "title".to_string(),
446            QuillValue::from_json(json!("Test Title")),
447        );
448        fields.insert("count".to_string(), QuillValue::from_json(json!(42)));
449
450        let result = validate_document(&QuillValue::from_json(schema), &fields);
451        assert!(result.is_ok());
452    }
453
454    #[test]
455    fn test_validate_document_missing_required() {
456        let schema = json!({
457            "$schema": "https://json-schema.org/draft/2019-09/schema",
458            "type": "object",
459            "properties": {
460                "title": {"type": "string"}
461            },
462            "required": ["title"],
463            "additionalProperties": true
464        });
465
466        let fields = HashMap::new(); // empty, missing required field
467
468        let result = validate_document(&QuillValue::from_json(schema), &fields);
469        assert!(result.is_err());
470        let errors = result.unwrap_err();
471        assert!(!errors.is_empty());
472    }
473
474    #[test]
475    fn test_validate_document_wrong_type() {
476        let schema = json!({
477            "$schema": "https://json-schema.org/draft/2019-09/schema",
478            "type": "object",
479            "properties": {
480                "count": {"type": "number"}
481            },
482            "additionalProperties": true
483        });
484
485        let mut fields = HashMap::new();
486        fields.insert(
487            "count".to_string(),
488            QuillValue::from_json(json!("not a number")),
489        );
490
491        let result = validate_document(&QuillValue::from_json(schema), &fields);
492        assert!(result.is_err());
493    }
494
495    #[test]
496    fn test_validate_document_allows_extra_fields() {
497        let schema = json!({
498            "$schema": "https://json-schema.org/draft/2019-09/schema",
499            "type": "object",
500            "properties": {
501                "title": {"type": "string"}
502            },
503            "required": ["title"],
504            "additionalProperties": true
505        });
506
507        let mut fields = HashMap::new();
508        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
509        fields.insert("extra".to_string(), QuillValue::from_json(json!("allowed")));
510
511        let result = validate_document(&QuillValue::from_json(schema), &fields);
512        assert!(result.is_ok());
513    }
514
515    #[test]
516    fn test_build_schema_with_example() {
517        let mut fields = HashMap::new();
518        let mut schema = FieldSchema::new(
519            "memo_for".to_string(),
520            "List of recipient organization symbols".to_string(),
521        );
522        schema.r#type = Some("array".to_string());
523        schema.example = Some(QuillValue::from_json(json!(["ORG1/SYMBOL", "ORG2/SYMBOL"])));
524        fields.insert("memo_for".to_string(), schema);
525
526        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
527
528        // Verify that example field is present in the schema
529        assert!(json_schema["properties"]["memo_for"]
530            .as_object()
531            .unwrap()
532            .contains_key("examples"));
533
534        let example_value = &json_schema["properties"]["memo_for"]["examples"][0];
535        assert_eq!(example_value, &json!(["ORG1/SYMBOL", "ORG2/SYMBOL"]));
536    }
537
538    #[test]
539    fn test_build_schema_includes_default_in_properties() {
540        let mut fields = HashMap::new();
541        let mut schema = FieldSchema::new(
542            "ice_cream".to_string(),
543            "favorite ice cream flavor".to_string(),
544        );
545        schema.r#type = Some("string".to_string());
546        schema.default = Some(QuillValue::from_json(json!("taro")));
547        fields.insert("ice_cream".to_string(), schema);
548
549        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
550
551        // Verify that default field is present in the schema
552        assert!(json_schema["properties"]["ice_cream"]
553            .as_object()
554            .unwrap()
555            .contains_key("default"));
556
557        let default_value = &json_schema["properties"]["ice_cream"]["default"];
558        assert_eq!(default_value, &json!("taro"));
559
560        // Verify that field with default is not required
561        let required_fields = json_schema["required"].as_array().unwrap();
562        assert!(!required_fields.contains(&json!("ice_cream")));
563    }
564
565    #[test]
566    fn test_extract_defaults_from_schema() {
567        // Create a JSON schema with defaults
568        let schema = json!({
569            "$schema": "https://json-schema.org/draft/2019-09/schema",
570            "type": "object",
571            "properties": {
572                "title": {
573                    "type": "string",
574                    "description": "Document title"
575                },
576                "author": {
577                    "type": "string",
578                    "description": "Document author",
579                    "default": "Anonymous"
580                },
581                "status": {
582                    "type": "string",
583                    "description": "Document status",
584                    "default": "draft"
585                },
586                "count": {
587                    "type": "number",
588                    "default": 42
589                }
590            },
591            "required": ["title"]
592        });
593
594        let defaults = extract_defaults_from_schema(&QuillValue::from_json(schema));
595
596        // Verify that only fields with defaults are extracted
597        assert_eq!(defaults.len(), 3);
598        assert!(!defaults.contains_key("title")); // no default
599        assert!(defaults.contains_key("author"));
600        assert!(defaults.contains_key("status"));
601        assert!(defaults.contains_key("count"));
602
603        // Verify the default values
604        assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
605        assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
606        assert_eq!(defaults.get("count").unwrap().as_json().as_i64(), Some(42));
607    }
608
609    #[test]
610    fn test_extract_defaults_from_schema_empty() {
611        // Schema with no defaults
612        let schema = json!({
613            "$schema": "https://json-schema.org/draft/2019-09/schema",
614            "type": "object",
615            "properties": {
616                "title": {"type": "string"},
617                "author": {"type": "string"}
618            },
619            "required": ["title"]
620        });
621
622        let defaults = extract_defaults_from_schema(&QuillValue::from_json(schema));
623        assert_eq!(defaults.len(), 0);
624    }
625
626    #[test]
627    fn test_extract_defaults_from_schema_no_properties() {
628        // Schema without properties field
629        let schema = json!({
630            "$schema": "https://json-schema.org/draft/2019-09/schema",
631            "type": "object"
632        });
633
634        let defaults = extract_defaults_from_schema(&QuillValue::from_json(schema));
635        assert_eq!(defaults.len(), 0);
636    }
637
638    #[test]
639    fn test_extract_examples_from_schema() {
640        // Create a JSON schema with examples
641        let schema = json!({
642            "$schema": "https://json-schema.org/draft/2019-09/schema",
643            "type": "object",
644            "properties": {
645                "title": {
646                    "type": "string",
647                    "description": "Document title"
648                },
649                "memo_for": {
650                    "type": "array",
651                    "description": "List of recipients",
652                    "examples": [
653                        ["ORG1/SYMBOL", "ORG2/SYMBOL"],
654                        ["DEPT/OFFICE"]
655                    ]
656                },
657                "author": {
658                    "type": "string",
659                    "description": "Document author",
660                    "examples": ["John Doe", "Jane Smith"]
661                },
662                "status": {
663                    "type": "string",
664                    "description": "Document status"
665                }
666            }
667        });
668
669        let examples = extract_examples_from_schema(&QuillValue::from_json(schema));
670
671        // Verify that only fields with examples are extracted
672        assert_eq!(examples.len(), 2);
673        assert!(!examples.contains_key("title")); // no examples
674        assert!(examples.contains_key("memo_for"));
675        assert!(examples.contains_key("author"));
676        assert!(!examples.contains_key("status")); // no examples
677
678        // Verify the example values for memo_for
679        let memo_for_examples = examples.get("memo_for").unwrap();
680        assert_eq!(memo_for_examples.len(), 2);
681        assert_eq!(
682            memo_for_examples[0].as_json(),
683            &json!(["ORG1/SYMBOL", "ORG2/SYMBOL"])
684        );
685        assert_eq!(memo_for_examples[1].as_json(), &json!(["DEPT/OFFICE"]));
686
687        // Verify the example values for author
688        let author_examples = examples.get("author").unwrap();
689        assert_eq!(author_examples.len(), 2);
690        assert_eq!(author_examples[0].as_str(), Some("John Doe"));
691        assert_eq!(author_examples[1].as_str(), Some("Jane Smith"));
692    }
693
694    #[test]
695    fn test_extract_examples_from_schema_empty() {
696        // Schema with no examples
697        let schema = json!({
698            "$schema": "https://json-schema.org/draft/2019-09/schema",
699            "type": "object",
700            "properties": {
701                "title": {"type": "string"},
702                "author": {"type": "string"}
703            }
704        });
705
706        let examples = extract_examples_from_schema(&QuillValue::from_json(schema));
707        assert_eq!(examples.len(), 0);
708    }
709
710    #[test]
711    fn test_extract_examples_from_schema_no_properties() {
712        // Schema without properties field
713        let schema = json!({
714            "$schema": "https://json-schema.org/draft/2019-09/schema",
715            "type": "object"
716        });
717
718        let examples = extract_examples_from_schema(&QuillValue::from_json(schema));
719        assert_eq!(examples.len(), 0);
720    }
721
722    #[test]
723    fn test_coerce_singular_to_array() {
724        let schema = json!({
725            "$schema": "https://json-schema.org/draft/2019-09/schema",
726            "type": "object",
727            "properties": {
728                "tags": {"type": "array"}
729            }
730        });
731
732        let mut fields = HashMap::new();
733        fields.insert(
734            "tags".to_string(),
735            QuillValue::from_json(json!("single-tag")),
736        );
737
738        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
739
740        let tags = coerced.get("tags").unwrap();
741        assert!(tags.as_array().is_some());
742        let tags_array = tags.as_array().unwrap();
743        assert_eq!(tags_array.len(), 1);
744        assert_eq!(tags_array[0].as_str().unwrap(), "single-tag");
745    }
746
747    #[test]
748    fn test_coerce_array_unchanged() {
749        let schema = json!({
750            "$schema": "https://json-schema.org/draft/2019-09/schema",
751            "type": "object",
752            "properties": {
753                "tags": {"type": "array"}
754            }
755        });
756
757        let mut fields = HashMap::new();
758        fields.insert(
759            "tags".to_string(),
760            QuillValue::from_json(json!(["tag1", "tag2"])),
761        );
762
763        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
764
765        let tags = coerced.get("tags").unwrap();
766        let tags_array = tags.as_array().unwrap();
767        assert_eq!(tags_array.len(), 2);
768    }
769
770    #[test]
771    fn test_coerce_string_to_boolean() {
772        let schema = json!({
773            "$schema": "https://json-schema.org/draft/2019-09/schema",
774            "type": "object",
775            "properties": {
776                "active": {"type": "boolean"},
777                "enabled": {"type": "boolean"}
778            }
779        });
780
781        let mut fields = HashMap::new();
782        fields.insert("active".to_string(), QuillValue::from_json(json!("true")));
783        fields.insert("enabled".to_string(), QuillValue::from_json(json!("FALSE")));
784
785        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
786
787        assert_eq!(coerced.get("active").unwrap().as_bool().unwrap(), true);
788        assert_eq!(coerced.get("enabled").unwrap().as_bool().unwrap(), false);
789    }
790
791    #[test]
792    fn test_coerce_number_to_boolean() {
793        let schema = json!({
794            "$schema": "https://json-schema.org/draft/2019-09/schema",
795            "type": "object",
796            "properties": {
797                "flag1": {"type": "boolean"},
798                "flag2": {"type": "boolean"},
799                "flag3": {"type": "boolean"}
800            }
801        });
802
803        let mut fields = HashMap::new();
804        fields.insert("flag1".to_string(), QuillValue::from_json(json!(0)));
805        fields.insert("flag2".to_string(), QuillValue::from_json(json!(1)));
806        fields.insert("flag3".to_string(), QuillValue::from_json(json!(42)));
807
808        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
809
810        assert_eq!(coerced.get("flag1").unwrap().as_bool().unwrap(), false);
811        assert_eq!(coerced.get("flag2").unwrap().as_bool().unwrap(), true);
812        assert_eq!(coerced.get("flag3").unwrap().as_bool().unwrap(), true);
813    }
814
815    #[test]
816    fn test_coerce_float_to_boolean() {
817        let schema = json!({
818            "$schema": "https://json-schema.org/draft/2019-09/schema",
819            "type": "object",
820            "properties": {
821                "flag1": {"type": "boolean"},
822                "flag2": {"type": "boolean"},
823                "flag3": {"type": "boolean"},
824                "flag4": {"type": "boolean"}
825            }
826        });
827
828        let mut fields = HashMap::new();
829        fields.insert("flag1".to_string(), QuillValue::from_json(json!(0.0)));
830        fields.insert("flag2".to_string(), QuillValue::from_json(json!(0.5)));
831        fields.insert("flag3".to_string(), QuillValue::from_json(json!(-1.5)));
832        // Very small number below epsilon - should be considered false
833        fields.insert("flag4".to_string(), QuillValue::from_json(json!(1e-100)));
834
835        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
836
837        assert_eq!(coerced.get("flag1").unwrap().as_bool().unwrap(), false);
838        assert_eq!(coerced.get("flag2").unwrap().as_bool().unwrap(), true);
839        assert_eq!(coerced.get("flag3").unwrap().as_bool().unwrap(), true);
840        // Very small numbers are considered false due to epsilon comparison
841        assert_eq!(coerced.get("flag4").unwrap().as_bool().unwrap(), false);
842    }
843
844    #[test]
845    fn test_coerce_string_to_number() {
846        let schema = json!({
847            "$schema": "https://json-schema.org/draft/2019-09/schema",
848            "type": "object",
849            "properties": {
850                "count": {"type": "number"},
851                "price": {"type": "number"}
852            }
853        });
854
855        let mut fields = HashMap::new();
856        fields.insert("count".to_string(), QuillValue::from_json(json!("42")));
857        fields.insert("price".to_string(), QuillValue::from_json(json!("19.99")));
858
859        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
860
861        assert_eq!(coerced.get("count").unwrap().as_i64().unwrap(), 42);
862        assert_eq!(coerced.get("price").unwrap().as_f64().unwrap(), 19.99);
863    }
864
865    #[test]
866    fn test_coerce_boolean_to_number() {
867        let schema = json!({
868            "$schema": "https://json-schema.org/draft/2019-09/schema",
869            "type": "object",
870            "properties": {
871                "active": {"type": "number"},
872                "disabled": {"type": "number"}
873            }
874        });
875
876        let mut fields = HashMap::new();
877        fields.insert("active".to_string(), QuillValue::from_json(json!(true)));
878        fields.insert("disabled".to_string(), QuillValue::from_json(json!(false)));
879
880        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
881
882        assert_eq!(coerced.get("active").unwrap().as_i64().unwrap(), 1);
883        assert_eq!(coerced.get("disabled").unwrap().as_i64().unwrap(), 0);
884    }
885
886    #[test]
887    fn test_coerce_no_schema_properties() {
888        let schema = json!({
889            "$schema": "https://json-schema.org/draft/2019-09/schema",
890            "type": "object"
891        });
892
893        let mut fields = HashMap::new();
894        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
895
896        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
897
898        // Fields should remain unchanged
899        assert_eq!(coerced.get("title").unwrap().as_str().unwrap(), "Test");
900    }
901
902    #[test]
903    fn test_coerce_field_without_type() {
904        let schema = json!({
905            "$schema": "https://json-schema.org/draft/2019-09/schema",
906            "type": "object",
907            "properties": {
908                "title": {"description": "A title field"}
909            }
910        });
911
912        let mut fields = HashMap::new();
913        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
914
915        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
916
917        // Field should remain unchanged when no type is specified
918        assert_eq!(coerced.get("title").unwrap().as_str().unwrap(), "Test");
919    }
920
921    #[test]
922    fn test_coerce_mixed_fields() {
923        let schema = json!({
924            "$schema": "https://json-schema.org/draft/2019-09/schema",
925            "type": "object",
926            "properties": {
927                "tags": {"type": "array"},
928                "active": {"type": "boolean"},
929                "count": {"type": "number"},
930                "title": {"type": "string"}
931            }
932        });
933
934        let mut fields = HashMap::new();
935        fields.insert("tags".to_string(), QuillValue::from_json(json!("single")));
936        fields.insert("active".to_string(), QuillValue::from_json(json!("true")));
937        fields.insert("count".to_string(), QuillValue::from_json(json!("42")));
938        fields.insert(
939            "title".to_string(),
940            QuillValue::from_json(json!("Test Title")),
941        );
942
943        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
944
945        // Verify coercions
946        assert_eq!(coerced.get("tags").unwrap().as_array().unwrap().len(), 1);
947        assert_eq!(coerced.get("active").unwrap().as_bool().unwrap(), true);
948        assert_eq!(coerced.get("count").unwrap().as_i64().unwrap(), 42);
949        assert_eq!(
950            coerced.get("title").unwrap().as_str().unwrap(),
951            "Test Title"
952        );
953    }
954
955    #[test]
956    fn test_coerce_invalid_string_to_number() {
957        let schema = json!({
958            "$schema": "https://json-schema.org/draft/2019-09/schema",
959            "type": "object",
960            "properties": {
961                "count": {"type": "number"}
962            }
963        });
964
965        let mut fields = HashMap::new();
966        fields.insert(
967            "count".to_string(),
968            QuillValue::from_json(json!("not-a-number")),
969        );
970
971        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
972
973        // Should remain unchanged when coercion fails
974        assert_eq!(
975            coerced.get("count").unwrap().as_str().unwrap(),
976            "not-a-number"
977        );
978    }
979
980    #[test]
981    fn test_coerce_object_to_array() {
982        let schema = json!({
983            "$schema": "https://json-schema.org/draft/2019-09/schema",
984            "type": "object",
985            "properties": {
986                "items": {"type": "array"}
987            }
988        });
989
990        let mut fields = HashMap::new();
991        fields.insert(
992            "items".to_string(),
993            QuillValue::from_json(json!({"key": "value"})),
994        );
995
996        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
997
998        // Object should be wrapped in an array
999        let items = coerced.get("items").unwrap();
1000        assert!(items.as_array().is_some());
1001        let items_array = items.as_array().unwrap();
1002        assert_eq!(items_array.len(), 1);
1003        assert!(items_array[0].as_object().is_some());
1004    }
1005}