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