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