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::{field_key, ui_key, CardSchema, FieldSchema, FieldType};
7use crate::{QuillValue, RenderError};
8use serde_json::{json, Map, Value};
9use std::collections::HashMap;
10
11/// Build a single field property JSON Schema object from a FieldSchema
12fn build_field_property(field_schema: &FieldSchema) -> Map<String, Value> {
13    let mut property = Map::new();
14
15    // Add name
16    property.insert(
17        field_key::NAME.to_string(),
18        Value::String(field_schema.name.clone()),
19    );
20
21    // Map field type to JSON Schema type
22    if let Some(ref field_type) = field_schema.r#type {
23        let (json_type, format) = match field_type {
24            FieldType::Str | FieldType::String => ("string", None),
25            FieldType::Number => ("number", None),
26            FieldType::Boolean => ("boolean", None),
27            FieldType::Array => ("array", None),
28            FieldType::Dict => ("object", None),
29            FieldType::Date => ("string", Some("date")),
30            FieldType::DateTime => ("string", Some("date-time")),
31        };
32        property.insert(
33            field_key::TYPE.to_string(),
34            Value::String(json_type.to_string()),
35        );
36
37        // Add format for date types
38        if let Some(fmt) = format {
39            property.insert(
40                field_key::FORMAT.to_string(),
41                Value::String(fmt.to_string()),
42            );
43        }
44    }
45
46    // Add title if specified
47    if let Some(ref title) = field_schema.title {
48        property.insert(field_key::TITLE.to_string(), Value::String(title.clone()));
49    }
50
51    // Add description
52    property.insert(
53        field_key::DESCRIPTION.to_string(),
54        Value::String(field_schema.description.clone()),
55    );
56
57    // Add UI metadata as x-ui property if present
58    if let Some(ref ui) = field_schema.ui {
59        let mut ui_obj = Map::new();
60
61        if let Some(ref group) = ui.group {
62            ui_obj.insert(ui_key::GROUP.to_string(), Value::String(group.clone()));
63        }
64
65        if let Some(order) = ui.order {
66            ui_obj.insert(ui_key::ORDER.to_string(), json!(order));
67        }
68
69        if !ui_obj.is_empty() {
70            property.insert("x-ui".to_string(), Value::Object(ui_obj));
71        }
72    }
73
74    // Add examples if specified
75    if let Some(ref examples) = field_schema.examples {
76        if let Some(examples_array) = examples.as_array() {
77            if !examples_array.is_empty() {
78                property.insert(
79                    field_key::EXAMPLES.to_string(),
80                    Value::Array(examples_array.clone()),
81                );
82            }
83        }
84    }
85
86    // Add default if specified
87    if let Some(ref default) = field_schema.default {
88        property.insert(field_key::DEFAULT.to_string(), default.as_json().clone());
89    }
90
91    // Add enum constraint if specified (for string types)
92    if let Some(ref enum_values) = field_schema.enum_values {
93        let enum_array: Vec<Value> = enum_values
94            .iter()
95            .map(|s| Value::String(s.clone()))
96            .collect();
97        property.insert(field_key::ENUM.to_string(), Value::Array(enum_array));
98    }
99
100    property
101}
102
103/// Build a card schema definition for `$defs`
104fn build_card_def(name: &str, card: &CardSchema) -> Map<String, Value> {
105    let mut def = Map::new();
106
107    def.insert("type".to_string(), Value::String("object".to_string()));
108
109    // Add title if specified
110    if let Some(ref title) = card.title {
111        def.insert("title".to_string(), Value::String(title.clone()));
112    }
113
114    // Add description
115    if !card.description.is_empty() {
116        def.insert(
117            "description".to_string(),
118            Value::String(card.description.clone()),
119        );
120    }
121
122    // Build properties
123    let mut properties = Map::new();
124    let mut required = vec![Value::String("CARD".to_string())];
125
126    // Add CARD discriminator property
127    let mut card_prop = Map::new();
128    card_prop.insert("const".to_string(), Value::String(name.to_string()));
129    properties.insert("CARD".to_string(), Value::Object(card_prop));
130
131    // Add card field properties
132    for (field_name, field_schema) in &card.fields {
133        let field_prop = build_field_property(field_schema);
134        properties.insert(field_name.clone(), Value::Object(field_prop));
135
136        if field_schema.required {
137            required.push(Value::String(field_name.clone()));
138        }
139    }
140
141    def.insert("properties".to_string(), Value::Object(properties));
142    def.insert("required".to_string(), Value::Array(required));
143    def.insert("additionalProperties".to_string(), Value::Bool(true));
144
145    def
146}
147
148/// Build a JSON Schema from field and card schemas
149///
150/// Generates a JSON Schema with:
151/// - Regular fields in `properties`
152/// - Card schemas in `$defs`
153/// - `CARDS` array with `oneOf` refs and `x-discriminator`
154pub fn build_schema(
155    field_schemas: &HashMap<String, FieldSchema>,
156    card_schemas: &HashMap<String, CardSchema>,
157) -> Result<QuillValue, RenderError> {
158    let mut properties = Map::new();
159    let mut required_fields = Vec::new();
160    let mut defs = Map::new();
161
162    // Build field properties
163    for (field_name, field_schema) in field_schemas {
164        let property = build_field_property(field_schema);
165        properties.insert(field_name.clone(), Value::Object(property));
166
167        if field_schema.required {
168            required_fields.push(field_name.clone());
169        }
170    }
171
172    // Build card definitions and CARDS array
173    if !card_schemas.is_empty() {
174        let mut one_of = Vec::new();
175        let mut discriminator_mapping = Map::new();
176
177        for (card_name, card_schema) in card_schemas {
178            let def_name = format!("{}_card", card_name);
179            let ref_path = format!("#/$defs/{}", def_name);
180
181            // Add to $defs
182            defs.insert(
183                def_name.clone(),
184                Value::Object(build_card_def(card_name, card_schema)),
185            );
186
187            // Add to oneOf
188            let mut ref_obj = Map::new();
189            ref_obj.insert("$ref".to_string(), Value::String(ref_path.clone()));
190            one_of.push(Value::Object(ref_obj));
191
192            // Add to discriminator mapping
193            discriminator_mapping.insert(card_name.clone(), Value::String(ref_path));
194        }
195
196        // Build CARDS array property
197        let mut items_schema = Map::new();
198        items_schema.insert("oneOf".to_string(), Value::Array(one_of));
199
200        // x-discriminator removed in favor of const polymorphism
201
202        let mut cards_property = Map::new();
203        cards_property.insert("type".to_string(), Value::String("array".to_string()));
204        cards_property.insert("items".to_string(), Value::Object(items_schema));
205
206        properties.insert("CARDS".to_string(), Value::Object(cards_property));
207    }
208
209    // Build the complete JSON Schema
210    let mut schema_map = Map::new();
211    schema_map.insert(
212        "$schema".to_string(),
213        Value::String("https://json-schema.org/draft/2019-09/schema".to_string()),
214    );
215    schema_map.insert("type".to_string(), Value::String("object".to_string()));
216
217    // Add $defs if there are card schemas
218    if !defs.is_empty() {
219        schema_map.insert("$defs".to_string(), Value::Object(defs));
220    }
221
222    schema_map.insert("properties".to_string(), Value::Object(properties));
223    schema_map.insert(
224        "required".to_string(),
225        Value::Array(required_fields.into_iter().map(Value::String).collect()),
226    );
227    schema_map.insert("additionalProperties".to_string(), Value::Bool(true));
228
229    let schema = Value::Object(schema_map);
230
231    Ok(QuillValue::from_json(schema))
232}
233
234/// Backwards-compatible wrapper for build_schema (no cards)
235pub fn build_schema_from_fields(
236    field_schemas: &HashMap<String, FieldSchema>,
237) -> Result<QuillValue, RenderError> {
238    build_schema(field_schemas, &HashMap::new())
239}
240
241/// Extract default values from a JSON Schema
242///
243/// Parses the JSON schema's "properties" object and extracts any "default" values
244/// defined for each property. Returns a HashMap mapping field names to their default
245/// values.
246///
247/// # Arguments
248///
249/// * `schema` - A JSON Schema object (must have "properties" field)
250///
251/// # Returns
252///
253/// A HashMap of field names to their default QuillValues
254pub fn extract_defaults_from_schema(
255    schema: &QuillValue,
256) -> HashMap<String, crate::value::QuillValue> {
257    let mut defaults = HashMap::new();
258
259    // Get the properties object from the schema
260    if let Some(properties) = schema.as_json().get("properties") {
261        if let Some(properties_obj) = properties.as_object() {
262            for (field_name, field_schema) in properties_obj {
263                // Check if this field has a default value
264                if let Some(default_value) = field_schema.get("default") {
265                    defaults.insert(
266                        field_name.clone(),
267                        QuillValue::from_json(default_value.clone()),
268                    );
269                }
270            }
271        }
272    }
273
274    defaults
275}
276
277/// Extract example values from a JSON Schema
278///
279/// Parses the JSON schema's "properties" object and extracts any "examples" arrays
280/// defined for each property. Returns a HashMap mapping field names to their examples
281/// (as an array of QuillValues).
282///
283/// # Arguments
284///
285/// * `schema` - A JSON Schema object (must have "properties" field)
286///
287/// # Returns
288///
289/// A HashMap of field names to their examples (``Vec<QuillValue>``)
290pub fn extract_examples_from_schema(
291    schema: &QuillValue,
292) -> HashMap<String, Vec<crate::value::QuillValue>> {
293    let mut examples = HashMap::new();
294
295    // Get the properties object from the schema
296    if let Some(properties) = schema.as_json().get("properties") {
297        if let Some(properties_obj) = properties.as_object() {
298            for (field_name, field_schema) in properties_obj {
299                // Check if this field has examples
300                if let Some(examples_value) = field_schema.get("examples") {
301                    if let Some(examples_array) = examples_value.as_array() {
302                        let examples_vec: Vec<QuillValue> = examples_array
303                            .iter()
304                            .map(|v| QuillValue::from_json(v.clone()))
305                            .collect();
306                        if !examples_vec.is_empty() {
307                            examples.insert(field_name.clone(), examples_vec);
308                        }
309                    }
310                }
311            }
312        }
313    }
314
315    examples
316}
317
318/// Extract default values for card item fields from a JSON Schema
319///
320/// For card-typed fields (type = "array" with items.properties), extracts
321/// any default values defined for item properties.
322///
323/// # Arguments
324///
325/// * `schema` - A JSON Schema object (must have "properties" field)
326///
327/// # Returns
328///
329/// A HashMap of card field names to their item defaults:
330/// `HashMap<card_field_name, HashMap<item_field_name, default_value>>`
331pub fn extract_card_item_defaults(
332    schema: &QuillValue,
333) -> HashMap<String, HashMap<String, QuillValue>> {
334    let mut card_defaults = HashMap::new();
335
336    // Get the properties object from the schema
337    if let Some(properties) = schema.as_json().get("properties") {
338        if let Some(properties_obj) = properties.as_object() {
339            for (field_name, field_schema) in properties_obj {
340                // Check if this is a card-typed field (array with items)
341                let is_array = field_schema
342                    .get("type")
343                    .and_then(|t| t.as_str())
344                    .map(|t| t == "array")
345                    .unwrap_or(false);
346
347                if !is_array {
348                    continue;
349                }
350
351                // Get items schema
352                if let Some(items_schema) = field_schema.get("items") {
353                    // Get properties of items
354                    if let Some(item_props) = items_schema.get("properties") {
355                        if let Some(item_props_obj) = item_props.as_object() {
356                            let mut item_defaults = HashMap::new();
357
358                            for (item_field_name, item_field_schema) in item_props_obj {
359                                // Extract default value if present
360                                if let Some(default_value) = item_field_schema.get("default") {
361                                    item_defaults.insert(
362                                        item_field_name.clone(),
363                                        QuillValue::from_json(default_value.clone()),
364                                    );
365                                }
366                            }
367
368                            if !item_defaults.is_empty() {
369                                card_defaults.insert(field_name.clone(), item_defaults);
370                            }
371                        }
372                    }
373                }
374            }
375        }
376    }
377
378    card_defaults
379}
380
381/// Apply default values to card item fields in a document
382///
383/// For each card-typed field (arrays), iterates through items and
384/// inserts default values for missing fields.
385///
386/// # Arguments
387///
388/// * `fields` - The document fields containing card arrays
389/// * `card_defaults` - Defaults for card items from `extract_card_item_defaults`
390///
391/// # Returns
392///
393/// A new HashMap with default values applied to card items
394pub fn apply_card_item_defaults(
395    fields: &HashMap<String, QuillValue>,
396    card_defaults: &HashMap<String, HashMap<String, QuillValue>>,
397) -> HashMap<String, QuillValue> {
398    let mut result = fields.clone();
399
400    for (card_name, item_defaults) in card_defaults {
401        if let Some(card_value) = result.get(card_name) {
402            // Get the array of items
403            if let Some(items_array) = card_value.as_array() {
404                let mut updated_items: Vec<serde_json::Value> = Vec::new();
405
406                for item in items_array {
407                    // Get item as object
408                    if let Some(item_obj) = item.as_object() {
409                        let mut new_item = item_obj.clone();
410
411                        // Apply defaults for missing fields
412                        for (default_field, default_value) in item_defaults {
413                            if !new_item.contains_key(default_field) {
414                                new_item
415                                    .insert(default_field.clone(), default_value.as_json().clone());
416                            }
417                        }
418
419                        updated_items.push(serde_json::Value::Object(new_item));
420                    } else {
421                        // Item is not an object, keep as-is
422                        updated_items.push(item.clone());
423                    }
424                }
425
426                result.insert(
427                    card_name.clone(),
428                    QuillValue::from_json(serde_json::Value::Array(updated_items)),
429                );
430            }
431        }
432    }
433
434    result
435}
436
437/// Validate a document's fields against a JSON Schema
438pub fn validate_document(
439    schema: &QuillValue,
440    fields: &HashMap<String, crate::value::QuillValue>,
441) -> Result<(), Vec<String>> {
442    // Convert fields to JSON Value for validation
443    let mut doc_json = Map::new();
444    for (key, value) in fields {
445        doc_json.insert(key.clone(), value.as_json().clone());
446    }
447    let doc_value = Value::Object(doc_json);
448
449    // Compile the schema
450    let compiled = match jsonschema::Validator::new(schema.as_json()) {
451        Ok(c) => c,
452        Err(e) => return Err(vec![format!("Failed to compile schema: {}", e)]),
453    };
454
455    // Validate the document and collect errors immediately
456    let mut all_errors = Vec::new();
457
458    // 1. Recursive card validation
459    if let Some(cards) = doc_value.get("CARDS").and_then(|v| v.as_array()) {
460        let card_errors = validate_cards_array(schema, cards);
461        all_errors.extend(card_errors);
462    }
463
464    // 2. Standard validation
465    let validation_result = compiled.validate(&doc_value);
466
467    match validation_result {
468        Ok(_) => {
469            if all_errors.is_empty() {
470                Ok(())
471            } else {
472                Err(all_errors)
473            }
474        }
475        Err(error) => {
476            let path = error.instance_path().to_string();
477            let path_display = if path.is_empty() {
478                "document".to_string()
479            } else {
480                path.clone()
481            };
482
483            // If we have specific card errors, we might want to skip generic CARDS errors
484            // from the main schema validation to avoid noise.
485            // But for now, we'll include everything unless it's a "oneOf" error on a card we already diagnosed.
486            let is_generic_card_error = path.starts_with("/CARDS/")
487                && error.to_string().contains("oneOf")
488                && !all_errors.is_empty();
489
490            if !is_generic_card_error {
491                // Check for potential invalid card type error (legacy check, but still useful)
492                if path.starts_with("/CARDS/") && error.to_string().contains("oneOf") {
493                    // Try to parse the index from path /CARDS/n
494                    if let Some(rest) = path.strip_prefix("/CARDS/") {
495                        // path might be just "/CARDS/0" or "/CARDS/0/some/field"
496                        // We only want to intervene if the error is about the card item itself failing oneOf
497                        let is_item_error = !rest.contains('/');
498
499                        if is_item_error {
500                            if let Ok(idx) = rest.parse::<usize>() {
501                                if let Some(cards) =
502                                    doc_value.get("CARDS").and_then(|v| v.as_array())
503                                {
504                                    if let Some(item) = cards.get(idx) {
505                                        // Check if the item has a CARD field
506                                        if let Some(card_type) =
507                                            item.get("CARD").and_then(|v| v.as_str())
508                                        {
509                                            // Collect valid card types from schema definitions
510                                            let mut valid_types = Vec::new();
511                                            if let Some(defs) = schema
512                                                .as_json()
513                                                .get("$defs")
514                                                .and_then(|v| v.as_object())
515                                            {
516                                                for key in defs.keys() {
517                                                    if let Some(name) = key.strip_suffix("_card") {
518                                                        valid_types.push(name.to_string());
519                                                    }
520                                                }
521                                            }
522
523                                            // If we found valid types and the current type is NOT in the list
524                                            if !valid_types.is_empty()
525                                                && !valid_types.contains(&card_type.to_string())
526                                            {
527                                                valid_types.sort();
528                                                let valid_list = valid_types.join(", ");
529                                                let message = format!("Validation error at {}: Invalid card type '{}'. Valid types are: [{}]", path_display, card_type, valid_list);
530                                                all_errors.push(message);
531                                                return Err(all_errors);
532                                            }
533                                        }
534                                    }
535                                }
536                            }
537                        }
538                    }
539                }
540
541                let message = format!("Validation error at {}: {}", path_display, error);
542                all_errors.push(message);
543            }
544
545            Err(all_errors)
546        }
547    }
548}
549
550/// Helper to recursively validate an array of card objects
551fn validate_cards_array(document_schema: &QuillValue, cards_array: &[Value]) -> Vec<String> {
552    let mut errors = Vec::new();
553
554    // Get definitions for card schemas
555    let defs = document_schema
556        .as_json()
557        .get("$defs")
558        .and_then(|v| v.as_object());
559
560    for (idx, card) in cards_array.iter().enumerate() {
561        // We only process objects that have a CARD discriminator
562        if let Some(card_obj) = card.as_object() {
563            if let Some(card_type) = card_obj.get("CARD").and_then(|v| v.as_str()) {
564                // Construct the definition name: {type}_card
565                let def_name = format!("{}_card", card_type);
566
567                // Look up the schema for this card type
568                if let Some(card_schema_json) = defs.and_then(|d| d.get(&def_name)) {
569                    // Convert the card object to HashMap<String, QuillValue> for recursion
570                    let mut card_fields = HashMap::new();
571                    for (k, v) in card_obj {
572                        card_fields.insert(k.clone(), QuillValue::from_json(v.clone()));
573                    }
574
575                    // Recursively validate this card's fields
576                    if let Err(card_errors) = validate_document(
577                        &QuillValue::from_json(card_schema_json.clone()),
578                        &card_fields,
579                    ) {
580                        // Prefix errors with location
581                        for err in card_errors {
582                            // If the error already starts with "Validation error at ", insert the prefix
583                            // otherwise just prefix it.
584                            // Typical error: "Validation error at field: message"
585                            // We want: "Validation error at /CARDS/0/field: message"
586
587                            let prefix = format!("/CARDS/{}", idx);
588                            let new_msg =
589                                if let Some(rest) = err.strip_prefix("Validation error at ") {
590                                    if rest.starts_with("document") {
591                                        // "Validation error at document: message" -> "Validation error at /CARDS/0: message"
592                                        format!(
593                                            "Validation error at {}:{}",
594                                            prefix,
595                                            rest.strip_prefix("document").unwrap_or(rest)
596                                        )
597                                    } else {
598                                        // "Validation error at /field: message" -> "Validation error at /CARDS/0/field: message"
599                                        format!("Validation error at {}{}", prefix, rest)
600                                    }
601                                } else {
602                                    format!("Validation error at {}: {}", prefix, err)
603                                };
604
605                            errors.push(new_msg);
606                        }
607                    }
608                }
609            }
610        }
611    }
612
613    errors
614}
615
616/// Coerce a single value to match the expected schema type
617///
618/// Performs type coercions such as:
619/// - Singular values to single-element arrays when schema expects array
620/// - String "true"/"false" to boolean
621/// - Number 0/1 to boolean
622/// - String numbers to number type
623/// - Boolean to number (true->1, false->0)
624fn coerce_value(value: &QuillValue, expected_type: &str) -> QuillValue {
625    let json_value = value.as_json();
626
627    match expected_type {
628        "array" => {
629            // If value is already an array, return as-is
630            if json_value.is_array() {
631                return value.clone();
632            }
633            // Otherwise, wrap the value in a single-element array
634            QuillValue::from_json(Value::Array(vec![json_value.clone()]))
635        }
636        "boolean" => {
637            // If already a boolean, return as-is
638            if let Some(b) = json_value.as_bool() {
639                return QuillValue::from_json(Value::Bool(b));
640            }
641            // Coerce from string "true"/"false" (case-insensitive)
642            if let Some(s) = json_value.as_str() {
643                let lower = s.to_lowercase();
644                if lower == "true" {
645                    return QuillValue::from_json(Value::Bool(true));
646                } else if lower == "false" {
647                    return QuillValue::from_json(Value::Bool(false));
648                }
649            }
650            // Coerce from number (0 = false, non-zero = true)
651            if let Some(n) = json_value.as_i64() {
652                return QuillValue::from_json(Value::Bool(n != 0));
653            }
654            if let Some(n) = json_value.as_f64() {
655                // Handle NaN and use epsilon comparison for zero
656                if n.is_nan() {
657                    return QuillValue::from_json(Value::Bool(false));
658                }
659                return QuillValue::from_json(Value::Bool(n.abs() > f64::EPSILON));
660            }
661            // Can't coerce, return as-is
662            value.clone()
663        }
664        "number" => {
665            // If already a number, return as-is
666            if json_value.is_number() {
667                return value.clone();
668            }
669            // Coerce from string
670            if let Some(s) = json_value.as_str() {
671                // Try parsing as integer first
672                if let Ok(i) = s.parse::<i64>() {
673                    return QuillValue::from_json(serde_json::Number::from(i).into());
674                }
675                // Try parsing as float
676                if let Ok(f) = s.parse::<f64>() {
677                    if let Some(num) = serde_json::Number::from_f64(f) {
678                        return QuillValue::from_json(num.into());
679                    }
680                }
681            }
682            // Coerce from boolean (true -> 1, false -> 0)
683            if let Some(b) = json_value.as_bool() {
684                let num_value = if b { 1 } else { 0 };
685                return QuillValue::from_json(Value::Number(serde_json::Number::from(num_value)));
686            }
687            // Can't coerce, return as-is
688            value.clone()
689        }
690        _ => {
691            // For other types (string, object, etc.), no coercion needed
692            value.clone()
693        }
694    }
695}
696
697/// Coerce document fields to match the expected schema types
698///
699/// This function applies type coercions to document fields based on the schema.
700/// It's useful for handling flexible input formats.
701///
702/// # Arguments
703///
704/// * `schema` - A JSON Schema object (must have "properties" field)
705/// * `fields` - The document fields to coerce
706///
707/// # Returns
708///
709/// A new HashMap with coerced field values
710pub fn coerce_document(
711    schema: &QuillValue,
712    fields: &HashMap<String, QuillValue>,
713) -> HashMap<String, QuillValue> {
714    let mut coerced_fields = HashMap::new();
715
716    // Get the properties object from the schema
717    let properties = match schema.as_json().get("properties") {
718        Some(props) => props,
719        None => {
720            // No properties defined, return fields as-is
721            return fields.clone();
722        }
723    };
724
725    let properties_obj = match properties.as_object() {
726        Some(obj) => obj,
727        None => {
728            // Properties is not an object, return fields as-is
729            return fields.clone();
730        }
731    };
732
733    // Process each field
734    for (field_name, field_value) in fields {
735        // Check if there's a schema definition for this field
736        if let Some(field_schema) = properties_obj.get(field_name) {
737            // Get the expected type
738            if let Some(expected_type) = field_schema.get("type").and_then(|t| t.as_str()) {
739                // Apply coercion
740                let coerced_value = coerce_value(field_value, expected_type);
741                coerced_fields.insert(field_name.clone(), coerced_value);
742                continue;
743            }
744        }
745        // No schema or no type specified, keep the field as-is
746        coerced_fields.insert(field_name.clone(), field_value.clone());
747    }
748
749    // Recursively coerce cards if the CARDS field is present
750    if let Some(cards_value) = coerced_fields.get("CARDS") {
751        if let Some(cards_array) = cards_value.as_array() {
752            let coerced_cards = coerce_cards_array(schema, cards_array);
753            coerced_fields.insert(
754                "CARDS".to_string(),
755                QuillValue::from_json(Value::Array(coerced_cards)),
756            );
757        }
758    }
759
760    coerced_fields
761}
762
763/// Helper to recursively coerce an array of card objects
764fn coerce_cards_array(document_schema: &QuillValue, cards_array: &[Value]) -> Vec<Value> {
765    let mut coerced_cards = Vec::new();
766
767    // Get definitions for card schemas
768    let defs = document_schema
769        .as_json()
770        .get("$defs")
771        .and_then(|v| v.as_object());
772
773    for card in cards_array {
774        // We only process objects that have a CARD discriminator
775        if let Some(card_obj) = card.as_object() {
776            if let Some(card_type) = card_obj.get("CARD").and_then(|v| v.as_str()) {
777                // Construct the definition name: {type}_card
778                let def_name = format!("{}_card", card_type);
779
780                // Look up the schema for this card type
781                if let Some(card_schema_json) = defs.and_then(|d| d.get(&def_name)) {
782                    // Convert the card object to HashMap<String, QuillValue> for coerce_document
783                    let mut card_fields = HashMap::new();
784                    for (k, v) in card_obj {
785                        card_fields.insert(k.clone(), QuillValue::from_json(v.clone()));
786                    }
787
788                    // Recursively coerce this card's fields
789                    let coerced_card_fields = coerce_document(
790                        &QuillValue::from_json(card_schema_json.clone()),
791                        &card_fields,
792                    );
793
794                    // Convert back to JSON Value
795                    let mut coerced_card_obj = Map::new();
796                    for (k, v) in coerced_card_fields {
797                        coerced_card_obj.insert(k, v.into_json());
798                    }
799
800                    coerced_cards.push(Value::Object(coerced_card_obj));
801                    continue;
802                }
803            }
804        }
805
806        // If not an object, no CARD type, or no matching schema, keep as-is
807        coerced_cards.push(card.clone());
808    }
809
810    coerced_cards
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use crate::quill::FieldSchema;
817    use crate::value::QuillValue;
818
819    #[test]
820    fn test_build_schema_simple() {
821        let mut fields = HashMap::new();
822        let mut schema = FieldSchema::new(
823            "Author name".to_string(),
824            "The name of the author".to_string(),
825        );
826        schema.r#type = Some(FieldType::Str);
827        fields.insert("author".to_string(), schema);
828
829        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
830        assert_eq!(json_schema["type"], "object");
831        assert_eq!(json_schema["properties"]["author"]["type"], "string");
832        assert_eq!(json_schema["properties"]["author"]["name"], "Author name");
833        assert_eq!(
834            json_schema["properties"]["author"]["description"],
835            "The name of the author"
836        );
837    }
838
839    #[test]
840    fn test_build_schema_with_default() {
841        let mut fields = HashMap::new();
842        let mut schema = FieldSchema::new(
843            "Field with default".to_string(),
844            "A field with a default value".to_string(),
845        );
846        schema.r#type = Some(FieldType::Str);
847        schema.default = Some(QuillValue::from_json(json!("default value")));
848        // When default is present, field should be optional regardless of required flag
849        fields.insert("with_default".to_string(), schema);
850
851        build_schema_from_fields(&fields).unwrap();
852    }
853
854    #[test]
855    fn test_build_schema_date_types() {
856        let mut fields = HashMap::new();
857
858        let mut date_schema =
859            FieldSchema::new("Date field".to_string(), "A field for dates".to_string());
860        date_schema.r#type = Some(FieldType::Date);
861        fields.insert("date_field".to_string(), date_schema);
862
863        let mut datetime_schema = FieldSchema::new(
864            "DateTime field".to_string(),
865            "A field for date and time".to_string(),
866        );
867        datetime_schema.r#type = Some(FieldType::DateTime);
868        fields.insert("datetime_field".to_string(), datetime_schema);
869
870        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
871        assert_eq!(json_schema["properties"]["date_field"]["type"], "string");
872        assert_eq!(json_schema["properties"]["date_field"]["format"], "date");
873        assert_eq!(
874            json_schema["properties"]["datetime_field"]["type"],
875            "string"
876        );
877        assert_eq!(
878            json_schema["properties"]["datetime_field"]["format"],
879            "date-time"
880        );
881    }
882
883    #[test]
884    fn test_validate_document_success() {
885        let schema = json!({
886            "$schema": "https://json-schema.org/draft/2019-09/schema",
887            "type": "object",
888            "properties": {
889                "title": {"type": "string"},
890                "count": {"type": "number"}
891            },
892            "required": ["title"],
893            "additionalProperties": true
894        });
895
896        let mut fields = HashMap::new();
897        fields.insert(
898            "title".to_string(),
899            QuillValue::from_json(json!("Test Title")),
900        );
901        fields.insert("count".to_string(), QuillValue::from_json(json!(42)));
902
903        let result = validate_document(&QuillValue::from_json(schema), &fields);
904        assert!(result.is_ok());
905    }
906
907    #[test]
908    fn test_validate_document_missing_required() {
909        let schema = json!({
910            "$schema": "https://json-schema.org/draft/2019-09/schema",
911            "type": "object",
912            "properties": {
913                "title": {"type": "string"}
914            },
915            "required": ["title"],
916            "additionalProperties": true
917        });
918
919        let fields = HashMap::new(); // empty, missing required field
920
921        let result = validate_document(&QuillValue::from_json(schema), &fields);
922        assert!(result.is_err());
923        let errors = result.unwrap_err();
924        assert!(!errors.is_empty());
925    }
926
927    #[test]
928    fn test_validate_document_wrong_type() {
929        let schema = json!({
930            "$schema": "https://json-schema.org/draft/2019-09/schema",
931            "type": "object",
932            "properties": {
933                "count": {"type": "number"}
934            },
935            "additionalProperties": true
936        });
937
938        let mut fields = HashMap::new();
939        fields.insert(
940            "count".to_string(),
941            QuillValue::from_json(json!("not a number")),
942        );
943
944        let result = validate_document(&QuillValue::from_json(schema), &fields);
945        assert!(result.is_err());
946    }
947
948    #[test]
949    fn test_validate_document_allows_extra_fields() {
950        let schema = json!({
951            "$schema": "https://json-schema.org/draft/2019-09/schema",
952            "type": "object",
953            "properties": {
954                "title": {"type": "string"}
955            },
956            "required": ["title"],
957            "additionalProperties": true
958        });
959
960        let mut fields = HashMap::new();
961        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
962        fields.insert("extra".to_string(), QuillValue::from_json(json!("allowed")));
963
964        let result = validate_document(&QuillValue::from_json(schema), &fields);
965        assert!(result.is_ok());
966    }
967
968    #[test]
969    fn test_build_schema_with_example() {
970        let mut fields = HashMap::new();
971        let mut schema = FieldSchema::new(
972            "memo_for".to_string(),
973            "List of recipient organization symbols".to_string(),
974        );
975        schema.r#type = Some(FieldType::Array);
976        schema.examples = Some(QuillValue::from_json(json!([[
977            "ORG1/SYMBOL",
978            "ORG2/SYMBOL"
979        ]])));
980        fields.insert("memo_for".to_string(), schema);
981
982        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
983
984        // Verify that examples field is present in the schema
985        assert!(json_schema["properties"]["memo_for"]
986            .as_object()
987            .unwrap()
988            .contains_key("examples"));
989
990        let example_value = &json_schema["properties"]["memo_for"]["examples"][0];
991        assert_eq!(example_value, &json!(["ORG1/SYMBOL", "ORG2/SYMBOL"]));
992    }
993
994    #[test]
995    fn test_build_schema_includes_default_in_properties() {
996        let mut fields = HashMap::new();
997        let mut schema = FieldSchema::new(
998            "ice_cream".to_string(),
999            "favorite ice cream flavor".to_string(),
1000        );
1001        schema.r#type = Some(FieldType::String);
1002        schema.default = Some(QuillValue::from_json(json!("taro")));
1003        fields.insert("ice_cream".to_string(), schema);
1004
1005        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
1006
1007        // Verify that default field is present in the schema
1008        assert!(json_schema["properties"]["ice_cream"]
1009            .as_object()
1010            .unwrap()
1011            .contains_key("default"));
1012
1013        let default_value = &json_schema["properties"]["ice_cream"]["default"];
1014        assert_eq!(default_value, &json!("taro"));
1015
1016        // Verify that field with default is not required
1017        let required_fields = json_schema["required"].as_array().unwrap();
1018        assert!(!required_fields.contains(&json!("ice_cream")));
1019    }
1020
1021    #[test]
1022    fn test_extract_defaults_from_schema() {
1023        // Create a JSON schema with defaults
1024        let schema = json!({
1025            "$schema": "https://json-schema.org/draft/2019-09/schema",
1026            "type": "object",
1027            "properties": {
1028                "title": {
1029                    "type": "string",
1030                    "description": "Document title"
1031                },
1032                "author": {
1033                    "type": "string",
1034                    "description": "Document author",
1035                    "default": "Anonymous"
1036                },
1037                "status": {
1038                    "type": "string",
1039                    "description": "Document status",
1040                    "default": "draft"
1041                },
1042                "count": {
1043                    "type": "number",
1044                    "default": 42
1045                }
1046            },
1047            "required": ["title"]
1048        });
1049
1050        let defaults = extract_defaults_from_schema(&QuillValue::from_json(schema));
1051
1052        // Verify that only fields with defaults are extracted
1053        assert_eq!(defaults.len(), 3);
1054        assert!(!defaults.contains_key("title")); // no default
1055        assert!(defaults.contains_key("author"));
1056        assert!(defaults.contains_key("status"));
1057        assert!(defaults.contains_key("count"));
1058
1059        // Verify the default values
1060        assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
1061        assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
1062        assert_eq!(defaults.get("count").unwrap().as_json().as_i64(), Some(42));
1063    }
1064
1065    #[test]
1066    fn test_extract_defaults_from_schema_empty() {
1067        // Schema with no defaults
1068        let schema = json!({
1069            "$schema": "https://json-schema.org/draft/2019-09/schema",
1070            "type": "object",
1071            "properties": {
1072                "title": {"type": "string"},
1073                "author": {"type": "string"}
1074            },
1075            "required": ["title"]
1076        });
1077
1078        let defaults = extract_defaults_from_schema(&QuillValue::from_json(schema));
1079        assert_eq!(defaults.len(), 0);
1080    }
1081
1082    #[test]
1083    fn test_extract_defaults_from_schema_no_properties() {
1084        // Schema without properties field
1085        let schema = json!({
1086            "$schema": "https://json-schema.org/draft/2019-09/schema",
1087            "type": "object"
1088        });
1089
1090        let defaults = extract_defaults_from_schema(&QuillValue::from_json(schema));
1091        assert_eq!(defaults.len(), 0);
1092    }
1093
1094    #[test]
1095    fn test_extract_examples_from_schema() {
1096        // Create a JSON schema with examples
1097        let schema = json!({
1098            "$schema": "https://json-schema.org/draft/2019-09/schema",
1099            "type": "object",
1100            "properties": {
1101                "title": {
1102                    "type": "string",
1103                    "description": "Document title"
1104                },
1105                "memo_for": {
1106                    "type": "array",
1107                    "description": "List of recipients",
1108                    "examples": [
1109                        ["ORG1/SYMBOL", "ORG2/SYMBOL"],
1110                        ["DEPT/OFFICE"]
1111                    ]
1112                },
1113                "author": {
1114                    "type": "string",
1115                    "description": "Document author",
1116                    "examples": ["John Doe", "Jane Smith"]
1117                },
1118                "status": {
1119                    "type": "string",
1120                    "description": "Document status"
1121                }
1122            }
1123        });
1124
1125        let examples = extract_examples_from_schema(&QuillValue::from_json(schema));
1126
1127        // Verify that only fields with examples are extracted
1128        assert_eq!(examples.len(), 2);
1129        assert!(!examples.contains_key("title")); // no examples
1130        assert!(examples.contains_key("memo_for"));
1131        assert!(examples.contains_key("author"));
1132        assert!(!examples.contains_key("status")); // no examples
1133
1134        // Verify the example values for memo_for
1135        let memo_for_examples = examples.get("memo_for").unwrap();
1136        assert_eq!(memo_for_examples.len(), 2);
1137        assert_eq!(
1138            memo_for_examples[0].as_json(),
1139            &json!(["ORG1/SYMBOL", "ORG2/SYMBOL"])
1140        );
1141        assert_eq!(memo_for_examples[1].as_json(), &json!(["DEPT/OFFICE"]));
1142
1143        // Verify the example values for author
1144        let author_examples = examples.get("author").unwrap();
1145        assert_eq!(author_examples.len(), 2);
1146        assert_eq!(author_examples[0].as_str(), Some("John Doe"));
1147        assert_eq!(author_examples[1].as_str(), Some("Jane Smith"));
1148    }
1149
1150    #[test]
1151    fn test_extract_examples_from_schema_empty() {
1152        // Schema with no examples
1153        let schema = json!({
1154            "$schema": "https://json-schema.org/draft/2019-09/schema",
1155            "type": "object",
1156            "properties": {
1157                "title": {"type": "string"},
1158                "author": {"type": "string"}
1159            }
1160        });
1161
1162        let examples = extract_examples_from_schema(&QuillValue::from_json(schema));
1163        assert_eq!(examples.len(), 0);
1164    }
1165
1166    #[test]
1167    fn test_extract_examples_from_schema_no_properties() {
1168        // Schema without properties field
1169        let schema = json!({
1170            "$schema": "https://json-schema.org/draft/2019-09/schema",
1171            "type": "object"
1172        });
1173
1174        let examples = extract_examples_from_schema(&QuillValue::from_json(schema));
1175        assert_eq!(examples.len(), 0);
1176    }
1177
1178    #[test]
1179    fn test_coerce_singular_to_array() {
1180        let schema = json!({
1181            "$schema": "https://json-schema.org/draft/2019-09/schema",
1182            "type": "object",
1183            "properties": {
1184                "tags": {"type": "array"}
1185            }
1186        });
1187
1188        let mut fields = HashMap::new();
1189        fields.insert(
1190            "tags".to_string(),
1191            QuillValue::from_json(json!("single-tag")),
1192        );
1193
1194        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1195
1196        let tags = coerced.get("tags").unwrap();
1197        assert!(tags.as_array().is_some());
1198        let tags_array = tags.as_array().unwrap();
1199        assert_eq!(tags_array.len(), 1);
1200        assert_eq!(tags_array[0].as_str().unwrap(), "single-tag");
1201    }
1202
1203    #[test]
1204    fn test_coerce_array_unchanged() {
1205        let schema = json!({
1206            "$schema": "https://json-schema.org/draft/2019-09/schema",
1207            "type": "object",
1208            "properties": {
1209                "tags": {"type": "array"}
1210            }
1211        });
1212
1213        let mut fields = HashMap::new();
1214        fields.insert(
1215            "tags".to_string(),
1216            QuillValue::from_json(json!(["tag1", "tag2"])),
1217        );
1218
1219        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1220
1221        let tags = coerced.get("tags").unwrap();
1222        let tags_array = tags.as_array().unwrap();
1223        assert_eq!(tags_array.len(), 2);
1224    }
1225
1226    #[test]
1227    fn test_coerce_string_to_boolean() {
1228        let schema = json!({
1229            "$schema": "https://json-schema.org/draft/2019-09/schema",
1230            "type": "object",
1231            "properties": {
1232                "active": {"type": "boolean"},
1233                "enabled": {"type": "boolean"}
1234            }
1235        });
1236
1237        let mut fields = HashMap::new();
1238        fields.insert("active".to_string(), QuillValue::from_json(json!("true")));
1239        fields.insert("enabled".to_string(), QuillValue::from_json(json!("FALSE")));
1240
1241        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1242
1243        assert!(coerced.get("active").unwrap().as_bool().unwrap());
1244        assert!(!coerced.get("enabled").unwrap().as_bool().unwrap());
1245    }
1246
1247    #[test]
1248    fn test_coerce_number_to_boolean() {
1249        let schema = json!({
1250            "$schema": "https://json-schema.org/draft/2019-09/schema",
1251            "type": "object",
1252            "properties": {
1253                "flag1": {"type": "boolean"},
1254                "flag2": {"type": "boolean"},
1255                "flag3": {"type": "boolean"}
1256            }
1257        });
1258
1259        let mut fields = HashMap::new();
1260        fields.insert("flag1".to_string(), QuillValue::from_json(json!(0)));
1261        fields.insert("flag2".to_string(), QuillValue::from_json(json!(1)));
1262        fields.insert("flag3".to_string(), QuillValue::from_json(json!(42)));
1263
1264        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1265
1266        assert!(!coerced.get("flag1").unwrap().as_bool().unwrap());
1267        assert!(coerced.get("flag2").unwrap().as_bool().unwrap());
1268        assert!(coerced.get("flag3").unwrap().as_bool().unwrap());
1269    }
1270
1271    #[test]
1272    fn test_coerce_float_to_boolean() {
1273        let schema = json!({
1274            "$schema": "https://json-schema.org/draft/2019-09/schema",
1275            "type": "object",
1276            "properties": {
1277                "flag1": {"type": "boolean"},
1278                "flag2": {"type": "boolean"},
1279                "flag3": {"type": "boolean"},
1280                "flag4": {"type": "boolean"}
1281            }
1282        });
1283
1284        let mut fields = HashMap::new();
1285        fields.insert("flag1".to_string(), QuillValue::from_json(json!(0.0)));
1286        fields.insert("flag2".to_string(), QuillValue::from_json(json!(0.5)));
1287        fields.insert("flag3".to_string(), QuillValue::from_json(json!(-1.5)));
1288        // Very small number below epsilon - should be considered false
1289        fields.insert("flag4".to_string(), QuillValue::from_json(json!(1e-100)));
1290
1291        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1292
1293        assert!(!coerced.get("flag1").unwrap().as_bool().unwrap());
1294        assert!(coerced.get("flag2").unwrap().as_bool().unwrap());
1295        assert!(coerced.get("flag3").unwrap().as_bool().unwrap());
1296        // Very small numbers are considered false due to epsilon comparison
1297        assert!(!coerced.get("flag4").unwrap().as_bool().unwrap());
1298    }
1299
1300    #[test]
1301    fn test_coerce_string_to_number() {
1302        let schema = json!({
1303            "$schema": "https://json-schema.org/draft/2019-09/schema",
1304            "type": "object",
1305            "properties": {
1306                "count": {"type": "number"},
1307                "price": {"type": "number"}
1308            }
1309        });
1310
1311        let mut fields = HashMap::new();
1312        fields.insert("count".to_string(), QuillValue::from_json(json!("42")));
1313        fields.insert("price".to_string(), QuillValue::from_json(json!("19.99")));
1314
1315        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1316
1317        assert_eq!(coerced.get("count").unwrap().as_i64().unwrap(), 42);
1318        assert_eq!(coerced.get("price").unwrap().as_f64().unwrap(), 19.99);
1319    }
1320
1321    #[test]
1322    fn test_coerce_boolean_to_number() {
1323        let schema = json!({
1324            "$schema": "https://json-schema.org/draft/2019-09/schema",
1325            "type": "object",
1326            "properties": {
1327                "active": {"type": "number"},
1328                "disabled": {"type": "number"}
1329            }
1330        });
1331
1332        let mut fields = HashMap::new();
1333        fields.insert("active".to_string(), QuillValue::from_json(json!(true)));
1334        fields.insert("disabled".to_string(), QuillValue::from_json(json!(false)));
1335
1336        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1337
1338        assert_eq!(coerced.get("active").unwrap().as_i64().unwrap(), 1);
1339        assert_eq!(coerced.get("disabled").unwrap().as_i64().unwrap(), 0);
1340    }
1341
1342    #[test]
1343    fn test_coerce_no_schema_properties() {
1344        let schema = json!({
1345            "$schema": "https://json-schema.org/draft/2019-09/schema",
1346            "type": "object"
1347        });
1348
1349        let mut fields = HashMap::new();
1350        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
1351
1352        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1353
1354        // Fields should remain unchanged
1355        assert_eq!(coerced.get("title").unwrap().as_str().unwrap(), "Test");
1356    }
1357
1358    #[test]
1359    fn test_coerce_field_without_type() {
1360        let schema = json!({
1361            "$schema": "https://json-schema.org/draft/2019-09/schema",
1362            "type": "object",
1363            "properties": {
1364                "title": {"description": "A title field"}
1365            }
1366        });
1367
1368        let mut fields = HashMap::new();
1369        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
1370
1371        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1372
1373        // Field should remain unchanged when no type is specified
1374        assert_eq!(coerced.get("title").unwrap().as_str().unwrap(), "Test");
1375    }
1376
1377    #[test]
1378    fn test_coerce_mixed_fields() {
1379        let schema = json!({
1380            "$schema": "https://json-schema.org/draft/2019-09/schema",
1381            "type": "object",
1382            "properties": {
1383                "tags": {"type": "array"},
1384                "active": {"type": "boolean"},
1385                "count": {"type": "number"},
1386                "title": {"type": "string"}
1387            }
1388        });
1389
1390        let mut fields = HashMap::new();
1391        fields.insert("tags".to_string(), QuillValue::from_json(json!("single")));
1392        fields.insert("active".to_string(), QuillValue::from_json(json!("true")));
1393        fields.insert("count".to_string(), QuillValue::from_json(json!("42")));
1394        fields.insert(
1395            "title".to_string(),
1396            QuillValue::from_json(json!("Test Title")),
1397        );
1398
1399        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1400
1401        // Verify coercions
1402        assert_eq!(coerced.get("tags").unwrap().as_array().unwrap().len(), 1);
1403        assert!(coerced.get("active").unwrap().as_bool().unwrap());
1404        assert_eq!(coerced.get("count").unwrap().as_i64().unwrap(), 42);
1405        assert_eq!(
1406            coerced.get("title").unwrap().as_str().unwrap(),
1407            "Test Title"
1408        );
1409    }
1410
1411    #[test]
1412    fn test_coerce_invalid_string_to_number() {
1413        let schema = json!({
1414            "$schema": "https://json-schema.org/draft/2019-09/schema",
1415            "type": "object",
1416            "properties": {
1417                "count": {"type": "number"}
1418            }
1419        });
1420
1421        let mut fields = HashMap::new();
1422        fields.insert(
1423            "count".to_string(),
1424            QuillValue::from_json(json!("not-a-number")),
1425        );
1426
1427        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1428
1429        // Should remain unchanged when coercion fails
1430        assert_eq!(
1431            coerced.get("count").unwrap().as_str().unwrap(),
1432            "not-a-number"
1433        );
1434    }
1435
1436    #[test]
1437    fn test_coerce_object_to_array() {
1438        let schema = json!({
1439            "$schema": "https://json-schema.org/draft/2019-09/schema",
1440            "type": "object",
1441            "properties": {
1442                "items": {"type": "array"}
1443            }
1444        });
1445
1446        let mut fields = HashMap::new();
1447        fields.insert(
1448            "items".to_string(),
1449            QuillValue::from_json(json!({"key": "value"})),
1450        );
1451
1452        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1453
1454        // Object should be wrapped in an array
1455        let items = coerced.get("items").unwrap();
1456        assert!(items.as_array().is_some());
1457        let items_array = items.as_array().unwrap();
1458        assert_eq!(items_array.len(), 1);
1459        assert!(items_array[0].as_object().is_some());
1460    }
1461
1462    #[test]
1463    fn test_schema_card_in_defs() {
1464        // Test that cards are generated in $defs with discriminator
1465        use crate::quill::CardSchema;
1466
1467        let fields = HashMap::new();
1468        let mut cards = HashMap::new();
1469
1470        let mut name_schema = FieldSchema::new("name".to_string(), "Name field".to_string());
1471        name_schema.r#type = Some(FieldType::String);
1472
1473        let mut card_fields = HashMap::new();
1474        card_fields.insert("name".to_string(), name_schema);
1475
1476        let card = CardSchema {
1477            name: "endorsements".to_string(),
1478            title: Some("Endorsements".to_string()),
1479            description: "Chain of endorsements".to_string(),
1480            fields: card_fields,
1481        };
1482        cards.insert("endorsements".to_string(), card);
1483
1484        let json_schema = build_schema(&fields, &cards).unwrap().as_json().clone();
1485
1486        // Verify $defs exists
1487        assert!(json_schema["$defs"].is_object());
1488        assert!(json_schema["$defs"]["endorsements_card"].is_object());
1489
1490        // Verify card in $defs has correct structure
1491        let card_def = &json_schema["$defs"]["endorsements_card"];
1492        assert_eq!(card_def["type"], "object");
1493        assert_eq!(card_def["title"], "Endorsements");
1494        assert_eq!(card_def["description"], "Chain of endorsements");
1495
1496        // Verify CARD discriminator const
1497        assert_eq!(card_def["properties"]["CARD"]["const"], "endorsements");
1498
1499        // Verify field is in properties
1500        assert!(card_def["properties"]["name"].is_object());
1501        assert_eq!(card_def["properties"]["name"]["type"], "string");
1502
1503        // Verify CARD is required
1504        let required = card_def["required"].as_array().unwrap();
1505        assert!(required.contains(&json!("CARD")));
1506    }
1507
1508    #[test]
1509    fn test_schema_cards_array() {
1510        // Test that CARDS array is generated with oneOf but without x-discriminator
1511        use crate::quill::CardSchema;
1512
1513        let fields = HashMap::new();
1514        let mut cards = HashMap::new();
1515
1516        let mut name_schema = FieldSchema::new("name".to_string(), "Endorser name".to_string());
1517        name_schema.r#type = Some(FieldType::String);
1518        name_schema.required = true;
1519
1520        let mut org_schema = FieldSchema::new("org".to_string(), "Organization".to_string());
1521        org_schema.r#type = Some(FieldType::String);
1522        org_schema.default = Some(QuillValue::from_json(json!("Unknown")));
1523
1524        let mut card_fields = HashMap::new();
1525        card_fields.insert("name".to_string(), name_schema);
1526        card_fields.insert("org".to_string(), org_schema);
1527
1528        let card = CardSchema {
1529            name: "endorsements".to_string(),
1530            title: Some("Endorsements".to_string()),
1531            description: "Chain of endorsements".to_string(),
1532            fields: card_fields,
1533        };
1534        cards.insert("endorsements".to_string(), card);
1535
1536        let json_schema = build_schema(&fields, &cards).unwrap().as_json().clone();
1537
1538        // Verify CARDS array property exists
1539        let cards_prop = &json_schema["properties"]["CARDS"];
1540        assert_eq!(cards_prop["type"], "array");
1541
1542        // Verify items has oneOf with $ref
1543        let items = &cards_prop["items"];
1544        assert!(items["oneOf"].is_array());
1545        let one_of = items["oneOf"].as_array().unwrap();
1546        assert!(!one_of.is_empty());
1547        assert_eq!(one_of[0]["$ref"], "#/$defs/endorsements_card");
1548
1549        // Verify x-discriminator is NOT present
1550        assert!(items.get("x-discriminator").is_none());
1551
1552        // Verify card field properties in $defs
1553        let card_def = &json_schema["$defs"]["endorsements_card"];
1554        assert_eq!(card_def["properties"]["name"]["type"], "string");
1555        assert_eq!(card_def["properties"]["org"]["default"], "Unknown");
1556
1557        // Verify required includes name (marked as required) and CARD
1558        let required = card_def["required"].as_array().unwrap();
1559        assert!(required.contains(&json!("CARD")));
1560        assert!(required.contains(&json!("name")));
1561        assert!(!required.contains(&json!("org")));
1562
1563        // Verify additionalProperties is set
1564        assert_eq!(card_def["additionalProperties"], true);
1565    }
1566
1567    #[test]
1568    fn test_extract_card_item_defaults() {
1569        // Create a JSON schema with card items that have defaults
1570        let schema = json!({
1571            "$schema": "https://json-schema.org/draft/2019-09/schema",
1572            "type": "object",
1573            "properties": {
1574                "endorsements": {
1575                    "type": "array",
1576                    "items": {
1577                        "type": "object",
1578                        "properties": {
1579                            "name": { "type": "string" },
1580                            "org": { "type": "string", "default": "Unknown Org" },
1581                            "rank": { "type": "string", "default": "N/A" }
1582                        }
1583                    }
1584                },
1585                "title": { "type": "string" }
1586            }
1587        });
1588
1589        let card_defaults = extract_card_item_defaults(&QuillValue::from_json(schema));
1590
1591        // Should have one card field with defaults
1592        assert_eq!(card_defaults.len(), 1);
1593        assert!(card_defaults.contains_key("endorsements"));
1594
1595        let endorsements_defaults = card_defaults.get("endorsements").unwrap();
1596        assert_eq!(endorsements_defaults.len(), 2); // org and rank have defaults
1597        assert!(!endorsements_defaults.contains_key("name")); // name has no default
1598        assert_eq!(
1599            endorsements_defaults.get("org").unwrap().as_str(),
1600            Some("Unknown Org")
1601        );
1602        assert_eq!(
1603            endorsements_defaults.get("rank").unwrap().as_str(),
1604            Some("N/A")
1605        );
1606    }
1607
1608    #[test]
1609    fn test_extract_card_item_defaults_empty() {
1610        // Schema with no card fields
1611        let schema = json!({
1612            "type": "object",
1613            "properties": {
1614                "title": { "type": "string" }
1615            }
1616        });
1617
1618        let card_defaults = extract_card_item_defaults(&QuillValue::from_json(schema));
1619        assert!(card_defaults.is_empty());
1620    }
1621
1622    #[test]
1623    fn test_extract_card_item_defaults_no_item_defaults() {
1624        // Schema with card field but no item defaults
1625        let schema = json!({
1626            "type": "object",
1627            "properties": {
1628                "endorsements": {
1629                    "type": "array",
1630                    "items": {
1631                        "type": "object",
1632                        "properties": {
1633                            "name": { "type": "string" },
1634                            "org": { "type": "string" }
1635                        }
1636                    }
1637                }
1638            }
1639        });
1640
1641        let card_defaults = extract_card_item_defaults(&QuillValue::from_json(schema));
1642        assert!(card_defaults.is_empty()); // No defaults defined
1643    }
1644
1645    #[test]
1646    fn test_apply_card_item_defaults() {
1647        // Set up card defaults
1648        let mut item_defaults = HashMap::new();
1649        item_defaults.insert(
1650            "org".to_string(),
1651            QuillValue::from_json(json!("Default Org")),
1652        );
1653
1654        let mut card_defaults = HashMap::new();
1655        card_defaults.insert("endorsements".to_string(), item_defaults);
1656
1657        // Set up document fields with card items missing the 'org' field
1658        let mut fields = HashMap::new();
1659        fields.insert(
1660            "endorsements".to_string(),
1661            QuillValue::from_json(json!([
1662                { "name": "John Doe" },
1663                { "name": "Jane Smith", "org": "Custom Org" }
1664            ])),
1665        );
1666
1667        let result = apply_card_item_defaults(&fields, &card_defaults);
1668
1669        // Verify defaults were applied
1670        let endorsements = result.get("endorsements").unwrap().as_array().unwrap();
1671        assert_eq!(endorsements.len(), 2);
1672
1673        // First item should have default applied
1674        assert_eq!(endorsements[0]["name"], "John Doe");
1675        assert_eq!(endorsements[0]["org"], "Default Org");
1676
1677        // Second item should preserve existing value
1678        assert_eq!(endorsements[1]["name"], "Jane Smith");
1679        assert_eq!(endorsements[1]["org"], "Custom Org");
1680    }
1681
1682    #[test]
1683    fn test_apply_card_item_defaults_empty_card() {
1684        let mut item_defaults = HashMap::new();
1685        item_defaults.insert(
1686            "org".to_string(),
1687            QuillValue::from_json(json!("Default Org")),
1688        );
1689
1690        let mut card_defaults = HashMap::new();
1691        card_defaults.insert("endorsements".to_string(), item_defaults);
1692
1693        // Empty endorsements array
1694        let mut fields = HashMap::new();
1695        fields.insert("endorsements".to_string(), QuillValue::from_json(json!([])));
1696
1697        let result = apply_card_item_defaults(&fields, &card_defaults);
1698
1699        // Should still be empty array
1700        let endorsements = result.get("endorsements").unwrap().as_array().unwrap();
1701        assert!(endorsements.is_empty());
1702    }
1703
1704    #[test]
1705    fn test_apply_card_item_defaults_no_matching_card() {
1706        let mut item_defaults = HashMap::new();
1707        item_defaults.insert(
1708            "org".to_string(),
1709            QuillValue::from_json(json!("Default Org")),
1710        );
1711
1712        let mut card_defaults = HashMap::new();
1713        card_defaults.insert("endorsements".to_string(), item_defaults);
1714
1715        // Document has different card field
1716        let mut fields = HashMap::new();
1717        fields.insert(
1718            "reviews".to_string(),
1719            QuillValue::from_json(json!([{ "author": "Bob" }])),
1720        );
1721
1722        let result = apply_card_item_defaults(&fields, &card_defaults);
1723
1724        // reviews should be unchanged
1725        let reviews = result.get("reviews").unwrap().as_array().unwrap();
1726        assert_eq!(reviews.len(), 1);
1727        assert_eq!(reviews[0]["author"], "Bob");
1728        assert!(reviews[0].get("org").is_none());
1729    }
1730
1731    #[test]
1732    fn test_card_validation_with_required_fields() {
1733        // Test that JSON Schema validation rejects card items missing required fields
1734        let schema = json!({
1735            "$schema": "https://json-schema.org/draft/2019-09/schema",
1736            "type": "object",
1737            "properties": {
1738                "endorsements": {
1739                    "type": "array",
1740                    "items": {
1741                        "type": "object",
1742                        "properties": {
1743                            "name": { "type": "string" },
1744                            "org": { "type": "string", "default": "Unknown" }
1745                        },
1746                        "required": ["name"]
1747                    }
1748                }
1749            }
1750        });
1751
1752        // Valid: has required 'name' field
1753        let mut valid_fields = HashMap::new();
1754        valid_fields.insert(
1755            "endorsements".to_string(),
1756            QuillValue::from_json(json!([{ "name": "John" }])),
1757        );
1758
1759        let result = validate_document(&QuillValue::from_json(schema.clone()), &valid_fields);
1760        assert!(result.is_ok());
1761
1762        // Invalid: missing required 'name' field
1763        let mut invalid_fields = HashMap::new();
1764        invalid_fields.insert(
1765            "endorsements".to_string(),
1766            QuillValue::from_json(json!([{ "org": "SomeOrg" }])),
1767        );
1768
1769        let result = validate_document(&QuillValue::from_json(schema), &invalid_fields);
1770        assert!(result.is_err());
1771    }
1772    #[test]
1773    fn test_validate_document_invalid_card_type() {
1774        use crate::quill::{CardSchema, FieldSchema};
1775
1776        let mut card_fields = HashMap::new();
1777        card_fields.insert(
1778            "field1".to_string(),
1779            FieldSchema::new("f1".to_string(), "desc".to_string()),
1780        );
1781        let mut card_schemas = HashMap::new();
1782        card_schemas.insert(
1783            "valid_card".to_string(),
1784            CardSchema {
1785                name: "valid_card".to_string(),
1786                title: None,
1787                description: "".to_string(),
1788                fields: card_fields,
1789            },
1790        );
1791
1792        let schema = build_schema(&HashMap::new(), &card_schemas).unwrap();
1793
1794        let mut fields = HashMap::new();
1795        // invalid card
1796        let invalid_card = json!({
1797            "CARD": "invalid_type",
1798            "field1": "value" // field1 is valid for valid_card but type is wrong
1799        });
1800        fields.insert(
1801            "CARDS".to_string(),
1802            QuillValue::from_json(json!([invalid_card])),
1803        );
1804
1805        let result = validate_document(&QuillValue::from_json(schema.as_json().clone()), &fields);
1806        assert!(result.is_err());
1807        let errs = result.unwrap_err();
1808        // Check for specific improved message
1809        let err_msg = &errs[0];
1810        assert!(err_msg.contains("Invalid card type 'invalid_type'"));
1811        assert!(err_msg.contains("Valid types are: [valid_card]"));
1812    }
1813
1814    #[test]
1815    fn test_coerce_document_cards() {
1816        let mut card_fields = HashMap::new();
1817        let mut count_schema = FieldSchema::new("Count".to_string(), "A number".to_string());
1818        count_schema.r#type = Some(FieldType::Number);
1819        card_fields.insert("count".to_string(), count_schema);
1820
1821        let mut active_schema = FieldSchema::new("Active".to_string(), "A boolean".to_string());
1822        active_schema.r#type = Some(FieldType::Boolean);
1823        card_fields.insert("active".to_string(), active_schema);
1824
1825        let mut card_schemas = HashMap::new();
1826        card_schemas.insert(
1827            "test_card".to_string(),
1828            CardSchema {
1829                name: "test_card".to_string(),
1830                title: None,
1831                description: "Test card".to_string(),
1832                fields: card_fields,
1833            },
1834        );
1835
1836        let schema = build_schema(&HashMap::new(), &card_schemas).unwrap();
1837
1838        let mut fields = HashMap::new();
1839        let card_value = json!({
1840            "CARD": "test_card",
1841            "count": "42",
1842            "active": "true"
1843        });
1844        fields.insert(
1845            "CARDS".to_string(),
1846            QuillValue::from_json(json!([card_value])),
1847        );
1848
1849        let coerced_fields = coerce_document(&schema, &fields);
1850
1851        let cards_array = coerced_fields.get("CARDS").unwrap().as_array().unwrap();
1852        let coerced_card = cards_array[0].as_object().unwrap();
1853
1854        assert_eq!(coerced_card.get("count").unwrap().as_i64(), Some(42));
1855        assert_eq!(coerced_card.get("active").unwrap().as_bool(), Some(true));
1856    }
1857
1858    #[test]
1859    fn test_validate_document_card_fields() {
1860        let mut card_fields = HashMap::new();
1861        let mut count_schema = FieldSchema::new("Count".to_string(), "A number".to_string());
1862        count_schema.r#type = Some(FieldType::Number);
1863        card_fields.insert("count".to_string(), count_schema);
1864
1865        let mut card_schemas = HashMap::new();
1866        card_schemas.insert(
1867            "test_card".to_string(),
1868            CardSchema {
1869                name: "test_card".to_string(),
1870                title: None,
1871                description: "Test card".to_string(),
1872                fields: card_fields,
1873            },
1874        );
1875
1876        let schema = build_schema(&HashMap::new(), &card_schemas).unwrap();
1877
1878        let mut fields = HashMap::new();
1879        let card_value = json!({
1880            "CARD": "test_card",
1881            "count": "not a number" // Invalid type
1882        });
1883        fields.insert(
1884            "CARDS".to_string(),
1885            QuillValue::from_json(json!([card_value])),
1886        );
1887
1888        let result = validate_document(&QuillValue::from_json(schema.as_json().clone()), &fields);
1889        assert!(result.is_err());
1890        let errs = result.unwrap_err();
1891
1892        // We expect a specific error from recursive validation
1893        let found_specific_error = errs
1894            .iter()
1895            .any(|e| e.contains("/CARDS/0") && e.contains("not a number") && !e.contains("oneOf"));
1896
1897        assert!(
1898            found_specific_error,
1899            "Did not find specific error msg in: {:?}",
1900            errs
1901        );
1902    }
1903
1904    #[test]
1905    fn test_card_field_ui_metadata() {
1906        // Verify that card fields with ui.group produce x-ui in JSON schema
1907        use crate::quill::{CardSchema, UiSchema};
1908
1909        let mut field_schema = FieldSchema::new("from".to_string(), "Sender".to_string());
1910        field_schema.r#type = Some(FieldType::String);
1911        field_schema.ui = Some(UiSchema {
1912            group: Some("Header".to_string()),
1913            order: Some(0),
1914        });
1915
1916        let mut card_fields = HashMap::new();
1917        card_fields.insert("from".to_string(), field_schema);
1918
1919        let card = CardSchema {
1920            name: "indorsement".to_string(),
1921            title: Some("Indorsement".to_string()),
1922            description: "An indorsement".to_string(),
1923            fields: card_fields,
1924        };
1925
1926        let mut cards = HashMap::new();
1927        cards.insert("indorsement".to_string(), card);
1928
1929        let schema = build_schema(&HashMap::new(), &cards).unwrap();
1930        let card_def = &schema.as_json()["$defs"]["indorsement_card"];
1931        let from_field = &card_def["properties"]["from"];
1932
1933        assert_eq!(from_field["x-ui"]["group"], "Header");
1934        assert_eq!(from_field["x-ui"]["order"], 0);
1935    }
1936}