Skip to main content

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