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::{CardSchema, FieldSchema};
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("name".to_string(), Value::String(field_schema.name.clone()));
17
18    // Map field type to JSON Schema type
19    if let Some(ref field_type) = field_schema.r#type {
20        let json_type = match field_type.as_str() {
21            "str" => "string",
22            "string" => "string",
23            "number" => "number",
24            "boolean" => "boolean",
25            "array" => "array",
26            "dict" => "object",
27            "date" => "string",
28            "datetime" => "string",
29            _ => "string", // default to string for unknown types
30        };
31        property.insert("type".to_string(), Value::String(json_type.to_string()));
32
33        // Add format for date types
34        if field_type == "date" {
35            property.insert("format".to_string(), Value::String("date".to_string()));
36        } else if field_type == "datetime" {
37            property.insert("format".to_string(), Value::String("date-time".to_string()));
38        }
39    }
40
41    // Add title if specified
42    if let Some(ref title) = field_schema.title {
43        property.insert("title".to_string(), Value::String(title.clone()));
44    }
45
46    // Add description
47    property.insert(
48        "description".to_string(),
49        Value::String(field_schema.description.clone()),
50    );
51
52    // Add UI metadata as x-ui property if present
53    if let Some(ref ui) = field_schema.ui {
54        let mut ui_obj = Map::new();
55
56        if let Some(ref group) = ui.group {
57            ui_obj.insert("group".to_string(), Value::String(group.clone()));
58        }
59
60        if let Some(order) = ui.order {
61            ui_obj.insert("order".to_string(), json!(order));
62        }
63
64        if !ui_obj.is_empty() {
65            property.insert("x-ui".to_string(), Value::Object(ui_obj));
66        }
67    }
68
69    // Add examples if specified
70    if let Some(ref examples) = field_schema.examples {
71        if let Some(examples_array) = examples.as_array() {
72            if !examples_array.is_empty() {
73                property.insert("examples".to_string(), Value::Array(examples_array.clone()));
74            }
75        }
76    }
77
78    // Add default if specified
79    if let Some(ref default) = field_schema.default {
80        property.insert("default".to_string(), default.as_json().clone());
81    }
82
83    property
84}
85
86/// Build a card schema definition for `$defs`
87fn build_card_def(name: &str, card: &CardSchema) -> Map<String, Value> {
88    let mut def = Map::new();
89
90    def.insert("type".to_string(), Value::String("object".to_string()));
91
92    // Add title if specified
93    if let Some(ref title) = card.title {
94        def.insert("title".to_string(), Value::String(title.clone()));
95    }
96
97    // Add description
98    if !card.description.is_empty() {
99        def.insert(
100            "description".to_string(),
101            Value::String(card.description.clone()),
102        );
103    }
104
105    // Build properties
106    let mut properties = Map::new();
107    let mut required = vec![Value::String("CARD".to_string())];
108
109    // Add CARD discriminator property
110    let mut card_prop = Map::new();
111    card_prop.insert("const".to_string(), Value::String(name.to_string()));
112    properties.insert("CARD".to_string(), Value::Object(card_prop));
113
114    // Add card field properties
115    for (field_name, field_schema) in &card.fields {
116        let field_prop = build_field_property(field_schema);
117        properties.insert(field_name.clone(), Value::Object(field_prop));
118
119        if field_schema.required {
120            required.push(Value::String(field_name.clone()));
121        }
122    }
123
124    def.insert("properties".to_string(), Value::Object(properties));
125    def.insert("required".to_string(), Value::Array(required));
126    def.insert("additionalProperties".to_string(), Value::Bool(true));
127
128    def
129}
130
131/// Build a JSON Schema from field and card schemas
132///
133/// Generates a JSON Schema with:
134/// - Regular fields in `properties`
135/// - Card schemas in `$defs`
136/// - `CARDS` array with `oneOf` refs and `x-discriminator`
137pub fn build_schema(
138    field_schemas: &HashMap<String, FieldSchema>,
139    card_schemas: &HashMap<String, CardSchema>,
140) -> Result<QuillValue, RenderError> {
141    let mut properties = Map::new();
142    let mut required_fields = Vec::new();
143    let mut defs = Map::new();
144
145    // Build field properties
146    for (field_name, field_schema) in field_schemas {
147        let property = build_field_property(field_schema);
148        properties.insert(field_name.clone(), Value::Object(property));
149
150        if field_schema.required {
151            required_fields.push(field_name.clone());
152        }
153    }
154
155    // Build card definitions and CARDS array
156    if !card_schemas.is_empty() {
157        let mut one_of = Vec::new();
158        let mut discriminator_mapping = Map::new();
159
160        for (card_name, card_schema) in card_schemas {
161            let def_name = format!("{}_card", card_name);
162            let ref_path = format!("#/$defs/{}", def_name);
163
164            // Add to $defs
165            defs.insert(
166                def_name.clone(),
167                Value::Object(build_card_def(card_name, card_schema)),
168            );
169
170            // Add to oneOf
171            let mut ref_obj = Map::new();
172            ref_obj.insert("$ref".to_string(), Value::String(ref_path.clone()));
173            one_of.push(Value::Object(ref_obj));
174
175            // Add to discriminator mapping
176            discriminator_mapping.insert(card_name.clone(), Value::String(ref_path));
177        }
178
179        // Build CARDS array property
180        let mut items_schema = Map::new();
181        items_schema.insert("oneOf".to_string(), Value::Array(one_of));
182
183        // x-discriminator removed in favor of const polymorphism
184
185        let mut cards_property = Map::new();
186        cards_property.insert("type".to_string(), Value::String("array".to_string()));
187        cards_property.insert("items".to_string(), Value::Object(items_schema));
188
189        properties.insert("CARDS".to_string(), Value::Object(cards_property));
190    }
191
192    // Build the complete JSON Schema
193    let mut schema_map = Map::new();
194    schema_map.insert(
195        "$schema".to_string(),
196        Value::String("https://json-schema.org/draft/2019-09/schema".to_string()),
197    );
198    schema_map.insert("type".to_string(), Value::String("object".to_string()));
199
200    // Add $defs if there are card schemas
201    if !defs.is_empty() {
202        schema_map.insert("$defs".to_string(), Value::Object(defs));
203    }
204
205    schema_map.insert("properties".to_string(), Value::Object(properties));
206    schema_map.insert(
207        "required".to_string(),
208        Value::Array(required_fields.into_iter().map(Value::String).collect()),
209    );
210    schema_map.insert("additionalProperties".to_string(), Value::Bool(true));
211
212    let schema = Value::Object(schema_map);
213
214    Ok(QuillValue::from_json(schema))
215}
216
217/// Backwards-compatible wrapper for build_schema (no cards)
218pub fn build_schema_from_fields(
219    field_schemas: &HashMap<String, FieldSchema>,
220) -> Result<QuillValue, RenderError> {
221    build_schema(field_schemas, &HashMap::new())
222}
223
224/// Extract default values from a JSON Schema
225///
226/// Parses the JSON schema's "properties" object and extracts any "default" values
227/// defined for each property. Returns a HashMap mapping field names to their default
228/// values.
229///
230/// # Arguments
231///
232/// * `schema` - A JSON Schema object (must have "properties" field)
233///
234/// # Returns
235///
236/// A HashMap of field names to their default QuillValues
237pub fn extract_defaults_from_schema(
238    schema: &QuillValue,
239) -> HashMap<String, crate::value::QuillValue> {
240    let mut defaults = HashMap::new();
241
242    // Get the properties object from the schema
243    if let Some(properties) = schema.as_json().get("properties") {
244        if let Some(properties_obj) = properties.as_object() {
245            for (field_name, field_schema) in properties_obj {
246                // Check if this field has a default value
247                if let Some(default_value) = field_schema.get("default") {
248                    defaults.insert(
249                        field_name.clone(),
250                        QuillValue::from_json(default_value.clone()),
251                    );
252                }
253            }
254        }
255    }
256
257    defaults
258}
259
260/// Extract example values from a JSON Schema
261///
262/// Parses the JSON schema's "properties" object and extracts any "examples" arrays
263/// defined for each property. Returns a HashMap mapping field names to their examples
264/// (as an array of QuillValues).
265///
266/// # Arguments
267///
268/// * `schema` - A JSON Schema object (must have "properties" field)
269///
270/// # Returns
271///
272/// A HashMap of field names to their examples (``Vec<QuillValue>``)
273pub fn extract_examples_from_schema(
274    schema: &QuillValue,
275) -> HashMap<String, Vec<crate::value::QuillValue>> {
276    let mut examples = HashMap::new();
277
278    // Get the properties object from the schema
279    if let Some(properties) = schema.as_json().get("properties") {
280        if let Some(properties_obj) = properties.as_object() {
281            for (field_name, field_schema) in properties_obj {
282                // Check if this field has examples
283                if let Some(examples_value) = field_schema.get("examples") {
284                    if let Some(examples_array) = examples_value.as_array() {
285                        let examples_vec: Vec<QuillValue> = examples_array
286                            .iter()
287                            .map(|v| QuillValue::from_json(v.clone()))
288                            .collect();
289                        if !examples_vec.is_empty() {
290                            examples.insert(field_name.clone(), examples_vec);
291                        }
292                    }
293                }
294            }
295        }
296    }
297
298    examples
299}
300
301/// Extract default values for card item fields from a JSON Schema
302///
303/// For card-typed fields (type = "array" with items.properties), extracts
304/// any default values defined for item properties.
305///
306/// # Arguments
307///
308/// * `schema` - A JSON Schema object (must have "properties" field)
309///
310/// # Returns
311///
312/// A HashMap of card field names to their item defaults:
313/// `HashMap<card_field_name, HashMap<item_field_name, default_value>>`
314pub fn extract_card_item_defaults(
315    schema: &QuillValue,
316) -> HashMap<String, HashMap<String, QuillValue>> {
317    let mut card_defaults = HashMap::new();
318
319    // Get the properties object from the schema
320    if let Some(properties) = schema.as_json().get("properties") {
321        if let Some(properties_obj) = properties.as_object() {
322            for (field_name, field_schema) in properties_obj {
323                // Check if this is a card-typed field (array with items)
324                let is_array = field_schema
325                    .get("type")
326                    .and_then(|t| t.as_str())
327                    .map(|t| t == "array")
328                    .unwrap_or(false);
329
330                if !is_array {
331                    continue;
332                }
333
334                // Get items schema
335                if let Some(items_schema) = field_schema.get("items") {
336                    // Get properties of items
337                    if let Some(item_props) = items_schema.get("properties") {
338                        if let Some(item_props_obj) = item_props.as_object() {
339                            let mut item_defaults = HashMap::new();
340
341                            for (item_field_name, item_field_schema) in item_props_obj {
342                                // Extract default value if present
343                                if let Some(default_value) = item_field_schema.get("default") {
344                                    item_defaults.insert(
345                                        item_field_name.clone(),
346                                        QuillValue::from_json(default_value.clone()),
347                                    );
348                                }
349                            }
350
351                            if !item_defaults.is_empty() {
352                                card_defaults.insert(field_name.clone(), item_defaults);
353                            }
354                        }
355                    }
356                }
357            }
358        }
359    }
360
361    card_defaults
362}
363
364/// Apply default values to card item fields in a document
365///
366/// For each card-typed field (arrays), iterates through items and
367/// inserts default values for missing fields.
368///
369/// # Arguments
370///
371/// * `fields` - The document fields containing card arrays
372/// * `card_defaults` - Defaults for card items from `extract_card_item_defaults`
373///
374/// # Returns
375///
376/// A new HashMap with default values applied to card items
377pub fn apply_card_item_defaults(
378    fields: &HashMap<String, QuillValue>,
379    card_defaults: &HashMap<String, HashMap<String, QuillValue>>,
380) -> HashMap<String, QuillValue> {
381    let mut result = fields.clone();
382
383    for (card_name, item_defaults) in card_defaults {
384        if let Some(card_value) = result.get(card_name) {
385            // Get the array of items
386            if let Some(items_array) = card_value.as_array() {
387                let mut updated_items: Vec<serde_json::Value> = Vec::new();
388
389                for item in items_array {
390                    // Get item as object
391                    if let Some(item_obj) = item.as_object() {
392                        let mut new_item = item_obj.clone();
393
394                        // Apply defaults for missing fields
395                        for (default_field, default_value) in item_defaults {
396                            if !new_item.contains_key(default_field) {
397                                new_item
398                                    .insert(default_field.clone(), default_value.as_json().clone());
399                            }
400                        }
401
402                        updated_items.push(serde_json::Value::Object(new_item));
403                    } else {
404                        // Item is not an object, keep as-is
405                        updated_items.push(item.clone());
406                    }
407                }
408
409                result.insert(
410                    card_name.clone(),
411                    QuillValue::from_json(serde_json::Value::Array(updated_items)),
412                );
413            }
414        }
415    }
416
417    result
418}
419
420/// Validate a document's fields against a JSON Schema
421pub fn validate_document(
422    schema: &QuillValue,
423    fields: &HashMap<String, crate::value::QuillValue>,
424) -> Result<(), Vec<String>> {
425    // Convert fields to JSON Value for validation
426    let mut doc_json = Map::new();
427    for (key, value) in fields {
428        doc_json.insert(key.clone(), value.as_json().clone());
429    }
430    let doc_value = Value::Object(doc_json);
431
432    // Compile the schema
433    let compiled = match jsonschema::Validator::new(schema.as_json()) {
434        Ok(c) => c,
435        Err(e) => return Err(vec![format!("Failed to compile schema: {}", e)]),
436    };
437
438    // Validate the document and collect errors immediately
439    let validation_result = compiled.validate(&doc_value);
440
441    match validation_result {
442        Ok(_) => Ok(()),
443        Err(error) => {
444            let path = error.instance_path().to_string();
445            let path_display = if path.is_empty() {
446                "document".to_string()
447            } else {
448                path
449            };
450            let message = format!("Validation error at {}: {}", path_display, error);
451            Err(vec![message])
452        }
453    }
454}
455
456/// Coerce a single value to match the expected schema type
457///
458/// Performs type coercions such as:
459/// - Singular values to single-element arrays when schema expects array
460/// - String "true"/"false" to boolean
461/// - Number 0/1 to boolean
462/// - String numbers to number type
463/// - Boolean to number (true->1, false->0)
464fn coerce_value(value: &QuillValue, expected_type: &str) -> QuillValue {
465    let json_value = value.as_json();
466
467    match expected_type {
468        "array" => {
469            // If value is already an array, return as-is
470            if json_value.is_array() {
471                return value.clone();
472            }
473            // Otherwise, wrap the value in a single-element array
474            QuillValue::from_json(Value::Array(vec![json_value.clone()]))
475        }
476        "boolean" => {
477            // If already a boolean, return as-is
478            if let Some(b) = json_value.as_bool() {
479                return QuillValue::from_json(Value::Bool(b));
480            }
481            // Coerce from string "true"/"false" (case-insensitive)
482            if let Some(s) = json_value.as_str() {
483                let lower = s.to_lowercase();
484                if lower == "true" {
485                    return QuillValue::from_json(Value::Bool(true));
486                } else if lower == "false" {
487                    return QuillValue::from_json(Value::Bool(false));
488                }
489            }
490            // Coerce from number (0 = false, non-zero = true)
491            if let Some(n) = json_value.as_i64() {
492                return QuillValue::from_json(Value::Bool(n != 0));
493            }
494            if let Some(n) = json_value.as_f64() {
495                // Handle NaN and use epsilon comparison for zero
496                if n.is_nan() {
497                    return QuillValue::from_json(Value::Bool(false));
498                }
499                return QuillValue::from_json(Value::Bool(n.abs() > f64::EPSILON));
500            }
501            // Can't coerce, return as-is
502            value.clone()
503        }
504        "number" => {
505            // If already a number, return as-is
506            if json_value.is_number() {
507                return value.clone();
508            }
509            // Coerce from string
510            if let Some(s) = json_value.as_str() {
511                // Try parsing as integer first
512                if let Ok(i) = s.parse::<i64>() {
513                    return QuillValue::from_json(serde_json::Number::from(i).into());
514                }
515                // Try parsing as float
516                if let Ok(f) = s.parse::<f64>() {
517                    if let Some(num) = serde_json::Number::from_f64(f) {
518                        return QuillValue::from_json(num.into());
519                    }
520                }
521            }
522            // Coerce from boolean (true -> 1, false -> 0)
523            if let Some(b) = json_value.as_bool() {
524                let num_value = if b { 1 } else { 0 };
525                return QuillValue::from_json(Value::Number(serde_json::Number::from(num_value)));
526            }
527            // Can't coerce, return as-is
528            value.clone()
529        }
530        _ => {
531            // For other types (string, object, etc.), no coercion needed
532            value.clone()
533        }
534    }
535}
536
537/// Coerce document fields to match the expected schema types
538///
539/// This function applies type coercions to document fields based on the schema.
540/// It's useful for handling flexible input formats.
541///
542/// # Arguments
543///
544/// * `schema` - A JSON Schema object (must have "properties" field)
545/// * `fields` - The document fields to coerce
546///
547/// # Returns
548///
549/// A new HashMap with coerced field values
550pub fn coerce_document(
551    schema: &QuillValue,
552    fields: &HashMap<String, QuillValue>,
553) -> HashMap<String, QuillValue> {
554    let mut coerced_fields = HashMap::new();
555
556    // Get the properties object from the schema
557    let properties = match schema.as_json().get("properties") {
558        Some(props) => props,
559        None => {
560            // No properties defined, return fields as-is
561            return fields.clone();
562        }
563    };
564
565    let properties_obj = match properties.as_object() {
566        Some(obj) => obj,
567        None => {
568            // Properties is not an object, return fields as-is
569            return fields.clone();
570        }
571    };
572
573    // Process each field
574    for (field_name, field_value) in fields {
575        // Check if there's a schema definition for this field
576        if let Some(field_schema) = properties_obj.get(field_name) {
577            // Get the expected type
578            if let Some(expected_type) = field_schema.get("type").and_then(|t| t.as_str()) {
579                // Apply coercion
580                let coerced_value = coerce_value(field_value, expected_type);
581                coerced_fields.insert(field_name.clone(), coerced_value);
582                continue;
583            }
584        }
585        // No schema or no type specified, keep the field as-is
586        coerced_fields.insert(field_name.clone(), field_value.clone());
587    }
588
589    coerced_fields
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::quill::FieldSchema;
596    use crate::value::QuillValue;
597
598    #[test]
599    fn test_build_schema_simple() {
600        let mut fields = HashMap::new();
601        let mut schema = FieldSchema::new(
602            "Author name".to_string(),
603            "The name of the author".to_string(),
604        );
605        schema.r#type = Some("str".to_string());
606        fields.insert("author".to_string(), schema);
607
608        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
609        assert_eq!(json_schema["type"], "object");
610        assert_eq!(json_schema["properties"]["author"]["type"], "string");
611        assert_eq!(json_schema["properties"]["author"]["name"], "Author name");
612        assert_eq!(
613            json_schema["properties"]["author"]["description"],
614            "The name of the author"
615        );
616    }
617
618    #[test]
619    fn test_build_schema_with_default() {
620        let mut fields = HashMap::new();
621        let mut schema = FieldSchema::new(
622            "Field with default".to_string(),
623            "A field with a default value".to_string(),
624        );
625        schema.r#type = Some("str".to_string());
626        schema.default = Some(QuillValue::from_json(json!("default value")));
627        // When default is present, field should be optional regardless of required flag
628        fields.insert("with_default".to_string(), schema);
629
630        build_schema_from_fields(&fields).unwrap();
631    }
632
633    #[test]
634    fn test_build_schema_date_types() {
635        let mut fields = HashMap::new();
636
637        let mut date_schema =
638            FieldSchema::new("Date field".to_string(), "A field for dates".to_string());
639        date_schema.r#type = Some("date".to_string());
640        fields.insert("date_field".to_string(), date_schema);
641
642        let mut datetime_schema = FieldSchema::new(
643            "DateTime field".to_string(),
644            "A field for date and time".to_string(),
645        );
646        datetime_schema.r#type = Some("datetime".to_string());
647        fields.insert("datetime_field".to_string(), datetime_schema);
648
649        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
650        assert_eq!(json_schema["properties"]["date_field"]["type"], "string");
651        assert_eq!(json_schema["properties"]["date_field"]["format"], "date");
652        assert_eq!(
653            json_schema["properties"]["datetime_field"]["type"],
654            "string"
655        );
656        assert_eq!(
657            json_schema["properties"]["datetime_field"]["format"],
658            "date-time"
659        );
660    }
661
662    #[test]
663    fn test_validate_document_success() {
664        let schema = json!({
665            "$schema": "https://json-schema.org/draft/2019-09/schema",
666            "type": "object",
667            "properties": {
668                "title": {"type": "string"},
669                "count": {"type": "number"}
670            },
671            "required": ["title"],
672            "additionalProperties": true
673        });
674
675        let mut fields = HashMap::new();
676        fields.insert(
677            "title".to_string(),
678            QuillValue::from_json(json!("Test Title")),
679        );
680        fields.insert("count".to_string(), QuillValue::from_json(json!(42)));
681
682        let result = validate_document(&QuillValue::from_json(schema), &fields);
683        assert!(result.is_ok());
684    }
685
686    #[test]
687    fn test_validate_document_missing_required() {
688        let schema = json!({
689            "$schema": "https://json-schema.org/draft/2019-09/schema",
690            "type": "object",
691            "properties": {
692                "title": {"type": "string"}
693            },
694            "required": ["title"],
695            "additionalProperties": true
696        });
697
698        let fields = HashMap::new(); // empty, missing required field
699
700        let result = validate_document(&QuillValue::from_json(schema), &fields);
701        assert!(result.is_err());
702        let errors = result.unwrap_err();
703        assert!(!errors.is_empty());
704    }
705
706    #[test]
707    fn test_validate_document_wrong_type() {
708        let schema = json!({
709            "$schema": "https://json-schema.org/draft/2019-09/schema",
710            "type": "object",
711            "properties": {
712                "count": {"type": "number"}
713            },
714            "additionalProperties": true
715        });
716
717        let mut fields = HashMap::new();
718        fields.insert(
719            "count".to_string(),
720            QuillValue::from_json(json!("not a number")),
721        );
722
723        let result = validate_document(&QuillValue::from_json(schema), &fields);
724        assert!(result.is_err());
725    }
726
727    #[test]
728    fn test_validate_document_allows_extra_fields() {
729        let schema = json!({
730            "$schema": "https://json-schema.org/draft/2019-09/schema",
731            "type": "object",
732            "properties": {
733                "title": {"type": "string"}
734            },
735            "required": ["title"],
736            "additionalProperties": true
737        });
738
739        let mut fields = HashMap::new();
740        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
741        fields.insert("extra".to_string(), QuillValue::from_json(json!("allowed")));
742
743        let result = validate_document(&QuillValue::from_json(schema), &fields);
744        assert!(result.is_ok());
745    }
746
747    #[test]
748    fn test_build_schema_with_example() {
749        let mut fields = HashMap::new();
750        let mut schema = FieldSchema::new(
751            "memo_for".to_string(),
752            "List of recipient organization symbols".to_string(),
753        );
754        schema.r#type = Some("array".to_string());
755        schema.examples = Some(QuillValue::from_json(json!([[
756            "ORG1/SYMBOL",
757            "ORG2/SYMBOL"
758        ]])));
759        fields.insert("memo_for".to_string(), schema);
760
761        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
762
763        // Verify that examples field is present in the schema
764        assert!(json_schema["properties"]["memo_for"]
765            .as_object()
766            .unwrap()
767            .contains_key("examples"));
768
769        let example_value = &json_schema["properties"]["memo_for"]["examples"][0];
770        assert_eq!(example_value, &json!(["ORG1/SYMBOL", "ORG2/SYMBOL"]));
771    }
772
773    #[test]
774    fn test_build_schema_includes_default_in_properties() {
775        let mut fields = HashMap::new();
776        let mut schema = FieldSchema::new(
777            "ice_cream".to_string(),
778            "favorite ice cream flavor".to_string(),
779        );
780        schema.r#type = Some("string".to_string());
781        schema.default = Some(QuillValue::from_json(json!("taro")));
782        fields.insert("ice_cream".to_string(), schema);
783
784        let json_schema = build_schema_from_fields(&fields).unwrap().as_json().clone();
785
786        // Verify that default field is present in the schema
787        assert!(json_schema["properties"]["ice_cream"]
788            .as_object()
789            .unwrap()
790            .contains_key("default"));
791
792        let default_value = &json_schema["properties"]["ice_cream"]["default"];
793        assert_eq!(default_value, &json!("taro"));
794
795        // Verify that field with default is not required
796        let required_fields = json_schema["required"].as_array().unwrap();
797        assert!(!required_fields.contains(&json!("ice_cream")));
798    }
799
800    #[test]
801    fn test_extract_defaults_from_schema() {
802        // Create a JSON schema with defaults
803        let schema = json!({
804            "$schema": "https://json-schema.org/draft/2019-09/schema",
805            "type": "object",
806            "properties": {
807                "title": {
808                    "type": "string",
809                    "description": "Document title"
810                },
811                "author": {
812                    "type": "string",
813                    "description": "Document author",
814                    "default": "Anonymous"
815                },
816                "status": {
817                    "type": "string",
818                    "description": "Document status",
819                    "default": "draft"
820                },
821                "count": {
822                    "type": "number",
823                    "default": 42
824                }
825            },
826            "required": ["title"]
827        });
828
829        let defaults = extract_defaults_from_schema(&QuillValue::from_json(schema));
830
831        // Verify that only fields with defaults are extracted
832        assert_eq!(defaults.len(), 3);
833        assert!(!defaults.contains_key("title")); // no default
834        assert!(defaults.contains_key("author"));
835        assert!(defaults.contains_key("status"));
836        assert!(defaults.contains_key("count"));
837
838        // Verify the default values
839        assert_eq!(defaults.get("author").unwrap().as_str(), Some("Anonymous"));
840        assert_eq!(defaults.get("status").unwrap().as_str(), Some("draft"));
841        assert_eq!(defaults.get("count").unwrap().as_json().as_i64(), Some(42));
842    }
843
844    #[test]
845    fn test_extract_defaults_from_schema_empty() {
846        // Schema with no defaults
847        let schema = json!({
848            "$schema": "https://json-schema.org/draft/2019-09/schema",
849            "type": "object",
850            "properties": {
851                "title": {"type": "string"},
852                "author": {"type": "string"}
853            },
854            "required": ["title"]
855        });
856
857        let defaults = extract_defaults_from_schema(&QuillValue::from_json(schema));
858        assert_eq!(defaults.len(), 0);
859    }
860
861    #[test]
862    fn test_extract_defaults_from_schema_no_properties() {
863        // Schema without properties field
864        let schema = json!({
865            "$schema": "https://json-schema.org/draft/2019-09/schema",
866            "type": "object"
867        });
868
869        let defaults = extract_defaults_from_schema(&QuillValue::from_json(schema));
870        assert_eq!(defaults.len(), 0);
871    }
872
873    #[test]
874    fn test_extract_examples_from_schema() {
875        // Create a JSON schema with examples
876        let schema = json!({
877            "$schema": "https://json-schema.org/draft/2019-09/schema",
878            "type": "object",
879            "properties": {
880                "title": {
881                    "type": "string",
882                    "description": "Document title"
883                },
884                "memo_for": {
885                    "type": "array",
886                    "description": "List of recipients",
887                    "examples": [
888                        ["ORG1/SYMBOL", "ORG2/SYMBOL"],
889                        ["DEPT/OFFICE"]
890                    ]
891                },
892                "author": {
893                    "type": "string",
894                    "description": "Document author",
895                    "examples": ["John Doe", "Jane Smith"]
896                },
897                "status": {
898                    "type": "string",
899                    "description": "Document status"
900                }
901            }
902        });
903
904        let examples = extract_examples_from_schema(&QuillValue::from_json(schema));
905
906        // Verify that only fields with examples are extracted
907        assert_eq!(examples.len(), 2);
908        assert!(!examples.contains_key("title")); // no examples
909        assert!(examples.contains_key("memo_for"));
910        assert!(examples.contains_key("author"));
911        assert!(!examples.contains_key("status")); // no examples
912
913        // Verify the example values for memo_for
914        let memo_for_examples = examples.get("memo_for").unwrap();
915        assert_eq!(memo_for_examples.len(), 2);
916        assert_eq!(
917            memo_for_examples[0].as_json(),
918            &json!(["ORG1/SYMBOL", "ORG2/SYMBOL"])
919        );
920        assert_eq!(memo_for_examples[1].as_json(), &json!(["DEPT/OFFICE"]));
921
922        // Verify the example values for author
923        let author_examples = examples.get("author").unwrap();
924        assert_eq!(author_examples.len(), 2);
925        assert_eq!(author_examples[0].as_str(), Some("John Doe"));
926        assert_eq!(author_examples[1].as_str(), Some("Jane Smith"));
927    }
928
929    #[test]
930    fn test_extract_examples_from_schema_empty() {
931        // Schema with no examples
932        let schema = json!({
933            "$schema": "https://json-schema.org/draft/2019-09/schema",
934            "type": "object",
935            "properties": {
936                "title": {"type": "string"},
937                "author": {"type": "string"}
938            }
939        });
940
941        let examples = extract_examples_from_schema(&QuillValue::from_json(schema));
942        assert_eq!(examples.len(), 0);
943    }
944
945    #[test]
946    fn test_extract_examples_from_schema_no_properties() {
947        // Schema without properties field
948        let schema = json!({
949            "$schema": "https://json-schema.org/draft/2019-09/schema",
950            "type": "object"
951        });
952
953        let examples = extract_examples_from_schema(&QuillValue::from_json(schema));
954        assert_eq!(examples.len(), 0);
955    }
956
957    #[test]
958    fn test_coerce_singular_to_array() {
959        let schema = json!({
960            "$schema": "https://json-schema.org/draft/2019-09/schema",
961            "type": "object",
962            "properties": {
963                "tags": {"type": "array"}
964            }
965        });
966
967        let mut fields = HashMap::new();
968        fields.insert(
969            "tags".to_string(),
970            QuillValue::from_json(json!("single-tag")),
971        );
972
973        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
974
975        let tags = coerced.get("tags").unwrap();
976        assert!(tags.as_array().is_some());
977        let tags_array = tags.as_array().unwrap();
978        assert_eq!(tags_array.len(), 1);
979        assert_eq!(tags_array[0].as_str().unwrap(), "single-tag");
980    }
981
982    #[test]
983    fn test_coerce_array_unchanged() {
984        let schema = json!({
985            "$schema": "https://json-schema.org/draft/2019-09/schema",
986            "type": "object",
987            "properties": {
988                "tags": {"type": "array"}
989            }
990        });
991
992        let mut fields = HashMap::new();
993        fields.insert(
994            "tags".to_string(),
995            QuillValue::from_json(json!(["tag1", "tag2"])),
996        );
997
998        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
999
1000        let tags = coerced.get("tags").unwrap();
1001        let tags_array = tags.as_array().unwrap();
1002        assert_eq!(tags_array.len(), 2);
1003    }
1004
1005    #[test]
1006    fn test_coerce_string_to_boolean() {
1007        let schema = json!({
1008            "$schema": "https://json-schema.org/draft/2019-09/schema",
1009            "type": "object",
1010            "properties": {
1011                "active": {"type": "boolean"},
1012                "enabled": {"type": "boolean"}
1013            }
1014        });
1015
1016        let mut fields = HashMap::new();
1017        fields.insert("active".to_string(), QuillValue::from_json(json!("true")));
1018        fields.insert("enabled".to_string(), QuillValue::from_json(json!("FALSE")));
1019
1020        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1021
1022        assert_eq!(coerced.get("active").unwrap().as_bool().unwrap(), true);
1023        assert_eq!(coerced.get("enabled").unwrap().as_bool().unwrap(), false);
1024    }
1025
1026    #[test]
1027    fn test_coerce_number_to_boolean() {
1028        let schema = json!({
1029            "$schema": "https://json-schema.org/draft/2019-09/schema",
1030            "type": "object",
1031            "properties": {
1032                "flag1": {"type": "boolean"},
1033                "flag2": {"type": "boolean"},
1034                "flag3": {"type": "boolean"}
1035            }
1036        });
1037
1038        let mut fields = HashMap::new();
1039        fields.insert("flag1".to_string(), QuillValue::from_json(json!(0)));
1040        fields.insert("flag2".to_string(), QuillValue::from_json(json!(1)));
1041        fields.insert("flag3".to_string(), QuillValue::from_json(json!(42)));
1042
1043        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1044
1045        assert_eq!(coerced.get("flag1").unwrap().as_bool().unwrap(), false);
1046        assert_eq!(coerced.get("flag2").unwrap().as_bool().unwrap(), true);
1047        assert_eq!(coerced.get("flag3").unwrap().as_bool().unwrap(), true);
1048    }
1049
1050    #[test]
1051    fn test_coerce_float_to_boolean() {
1052        let schema = json!({
1053            "$schema": "https://json-schema.org/draft/2019-09/schema",
1054            "type": "object",
1055            "properties": {
1056                "flag1": {"type": "boolean"},
1057                "flag2": {"type": "boolean"},
1058                "flag3": {"type": "boolean"},
1059                "flag4": {"type": "boolean"}
1060            }
1061        });
1062
1063        let mut fields = HashMap::new();
1064        fields.insert("flag1".to_string(), QuillValue::from_json(json!(0.0)));
1065        fields.insert("flag2".to_string(), QuillValue::from_json(json!(0.5)));
1066        fields.insert("flag3".to_string(), QuillValue::from_json(json!(-1.5)));
1067        // Very small number below epsilon - should be considered false
1068        fields.insert("flag4".to_string(), QuillValue::from_json(json!(1e-100)));
1069
1070        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1071
1072        assert_eq!(coerced.get("flag1").unwrap().as_bool().unwrap(), false);
1073        assert_eq!(coerced.get("flag2").unwrap().as_bool().unwrap(), true);
1074        assert_eq!(coerced.get("flag3").unwrap().as_bool().unwrap(), true);
1075        // Very small numbers are considered false due to epsilon comparison
1076        assert_eq!(coerced.get("flag4").unwrap().as_bool().unwrap(), false);
1077    }
1078
1079    #[test]
1080    fn test_coerce_string_to_number() {
1081        let schema = json!({
1082            "$schema": "https://json-schema.org/draft/2019-09/schema",
1083            "type": "object",
1084            "properties": {
1085                "count": {"type": "number"},
1086                "price": {"type": "number"}
1087            }
1088        });
1089
1090        let mut fields = HashMap::new();
1091        fields.insert("count".to_string(), QuillValue::from_json(json!("42")));
1092        fields.insert("price".to_string(), QuillValue::from_json(json!("19.99")));
1093
1094        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1095
1096        assert_eq!(coerced.get("count").unwrap().as_i64().unwrap(), 42);
1097        assert_eq!(coerced.get("price").unwrap().as_f64().unwrap(), 19.99);
1098    }
1099
1100    #[test]
1101    fn test_coerce_boolean_to_number() {
1102        let schema = json!({
1103            "$schema": "https://json-schema.org/draft/2019-09/schema",
1104            "type": "object",
1105            "properties": {
1106                "active": {"type": "number"},
1107                "disabled": {"type": "number"}
1108            }
1109        });
1110
1111        let mut fields = HashMap::new();
1112        fields.insert("active".to_string(), QuillValue::from_json(json!(true)));
1113        fields.insert("disabled".to_string(), QuillValue::from_json(json!(false)));
1114
1115        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1116
1117        assert_eq!(coerced.get("active").unwrap().as_i64().unwrap(), 1);
1118        assert_eq!(coerced.get("disabled").unwrap().as_i64().unwrap(), 0);
1119    }
1120
1121    #[test]
1122    fn test_coerce_no_schema_properties() {
1123        let schema = json!({
1124            "$schema": "https://json-schema.org/draft/2019-09/schema",
1125            "type": "object"
1126        });
1127
1128        let mut fields = HashMap::new();
1129        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
1130
1131        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1132
1133        // Fields should remain unchanged
1134        assert_eq!(coerced.get("title").unwrap().as_str().unwrap(), "Test");
1135    }
1136
1137    #[test]
1138    fn test_coerce_field_without_type() {
1139        let schema = json!({
1140            "$schema": "https://json-schema.org/draft/2019-09/schema",
1141            "type": "object",
1142            "properties": {
1143                "title": {"description": "A title field"}
1144            }
1145        });
1146
1147        let mut fields = HashMap::new();
1148        fields.insert("title".to_string(), QuillValue::from_json(json!("Test")));
1149
1150        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1151
1152        // Field should remain unchanged when no type is specified
1153        assert_eq!(coerced.get("title").unwrap().as_str().unwrap(), "Test");
1154    }
1155
1156    #[test]
1157    fn test_coerce_mixed_fields() {
1158        let schema = json!({
1159            "$schema": "https://json-schema.org/draft/2019-09/schema",
1160            "type": "object",
1161            "properties": {
1162                "tags": {"type": "array"},
1163                "active": {"type": "boolean"},
1164                "count": {"type": "number"},
1165                "title": {"type": "string"}
1166            }
1167        });
1168
1169        let mut fields = HashMap::new();
1170        fields.insert("tags".to_string(), QuillValue::from_json(json!("single")));
1171        fields.insert("active".to_string(), QuillValue::from_json(json!("true")));
1172        fields.insert("count".to_string(), QuillValue::from_json(json!("42")));
1173        fields.insert(
1174            "title".to_string(),
1175            QuillValue::from_json(json!("Test Title")),
1176        );
1177
1178        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1179
1180        // Verify coercions
1181        assert_eq!(coerced.get("tags").unwrap().as_array().unwrap().len(), 1);
1182        assert_eq!(coerced.get("active").unwrap().as_bool().unwrap(), true);
1183        assert_eq!(coerced.get("count").unwrap().as_i64().unwrap(), 42);
1184        assert_eq!(
1185            coerced.get("title").unwrap().as_str().unwrap(),
1186            "Test Title"
1187        );
1188    }
1189
1190    #[test]
1191    fn test_coerce_invalid_string_to_number() {
1192        let schema = json!({
1193            "$schema": "https://json-schema.org/draft/2019-09/schema",
1194            "type": "object",
1195            "properties": {
1196                "count": {"type": "number"}
1197            }
1198        });
1199
1200        let mut fields = HashMap::new();
1201        fields.insert(
1202            "count".to_string(),
1203            QuillValue::from_json(json!("not-a-number")),
1204        );
1205
1206        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1207
1208        // Should remain unchanged when coercion fails
1209        assert_eq!(
1210            coerced.get("count").unwrap().as_str().unwrap(),
1211            "not-a-number"
1212        );
1213    }
1214
1215    #[test]
1216    fn test_coerce_object_to_array() {
1217        let schema = json!({
1218            "$schema": "https://json-schema.org/draft/2019-09/schema",
1219            "type": "object",
1220            "properties": {
1221                "items": {"type": "array"}
1222            }
1223        });
1224
1225        let mut fields = HashMap::new();
1226        fields.insert(
1227            "items".to_string(),
1228            QuillValue::from_json(json!({"key": "value"})),
1229        );
1230
1231        let coerced = coerce_document(&QuillValue::from_json(schema), &fields);
1232
1233        // Object should be wrapped in an array
1234        let items = coerced.get("items").unwrap();
1235        assert!(items.as_array().is_some());
1236        let items_array = items.as_array().unwrap();
1237        assert_eq!(items_array.len(), 1);
1238        assert!(items_array[0].as_object().is_some());
1239    }
1240
1241    #[test]
1242    fn test_schema_card_in_defs() {
1243        // Test that cards are generated in $defs with discriminator
1244        use crate::quill::CardSchema;
1245
1246        let fields = HashMap::new();
1247        let mut cards = HashMap::new();
1248
1249        let mut name_schema = FieldSchema::new("name".to_string(), "Name field".to_string());
1250        name_schema.r#type = Some("string".to_string());
1251
1252        let mut card_fields = HashMap::new();
1253        card_fields.insert("name".to_string(), name_schema);
1254
1255        let card = CardSchema {
1256            name: "endorsements".to_string(),
1257            title: Some("Endorsements".to_string()),
1258            description: "Chain of endorsements".to_string(),
1259            ui: None,
1260            fields: card_fields,
1261        };
1262        cards.insert("endorsements".to_string(), card);
1263
1264        let json_schema = build_schema(&fields, &cards).unwrap().as_json().clone();
1265
1266        // Verify $defs exists
1267        assert!(json_schema["$defs"].is_object());
1268        assert!(json_schema["$defs"]["endorsements_card"].is_object());
1269
1270        // Verify card in $defs has correct structure
1271        let card_def = &json_schema["$defs"]["endorsements_card"];
1272        assert_eq!(card_def["type"], "object");
1273        assert_eq!(card_def["title"], "Endorsements");
1274        assert_eq!(card_def["description"], "Chain of endorsements");
1275
1276        // Verify CARD discriminator const
1277        assert_eq!(card_def["properties"]["CARD"]["const"], "endorsements");
1278
1279        // Verify field is in properties
1280        assert!(card_def["properties"]["name"].is_object());
1281        assert_eq!(card_def["properties"]["name"]["type"], "string");
1282
1283        // Verify CARD is required
1284        let required = card_def["required"].as_array().unwrap();
1285        assert!(required.contains(&json!("CARD")));
1286    }
1287
1288    #[test]
1289    fn test_schema_cards_array() {
1290        // Test that CARDS array is generated with oneOf but without x-discriminator
1291        use crate::quill::CardSchema;
1292
1293        let fields = HashMap::new();
1294        let mut cards = HashMap::new();
1295
1296        let mut name_schema = FieldSchema::new("name".to_string(), "Endorser name".to_string());
1297        name_schema.r#type = Some("string".to_string());
1298        name_schema.required = true;
1299
1300        let mut org_schema = FieldSchema::new("org".to_string(), "Organization".to_string());
1301        org_schema.r#type = Some("string".to_string());
1302        org_schema.default = Some(QuillValue::from_json(json!("Unknown")));
1303
1304        let mut card_fields = HashMap::new();
1305        card_fields.insert("name".to_string(), name_schema);
1306        card_fields.insert("org".to_string(), org_schema);
1307
1308        let card = CardSchema {
1309            name: "endorsements".to_string(),
1310            title: Some("Endorsements".to_string()),
1311            description: "Chain of endorsements".to_string(),
1312            ui: None,
1313            fields: card_fields,
1314        };
1315        cards.insert("endorsements".to_string(), card);
1316
1317        let json_schema = build_schema(&fields, &cards).unwrap().as_json().clone();
1318
1319        // Verify CARDS array property exists
1320        let cards_prop = &json_schema["properties"]["CARDS"];
1321        assert_eq!(cards_prop["type"], "array");
1322
1323        // Verify items has oneOf with $ref
1324        let items = &cards_prop["items"];
1325        assert!(items["oneOf"].is_array());
1326        let one_of = items["oneOf"].as_array().unwrap();
1327        assert!(!one_of.is_empty());
1328        assert_eq!(one_of[0]["$ref"], "#/$defs/endorsements_card");
1329
1330        // Verify x-discriminator is NOT present
1331        assert!(items.get("x-discriminator").is_none());
1332
1333        // Verify card field properties in $defs
1334        let card_def = &json_schema["$defs"]["endorsements_card"];
1335        assert_eq!(card_def["properties"]["name"]["type"], "string");
1336        assert_eq!(card_def["properties"]["org"]["default"], "Unknown");
1337
1338        // Verify required includes name (marked as required) and CARD
1339        let required = card_def["required"].as_array().unwrap();
1340        assert!(required.contains(&json!("CARD")));
1341        assert!(required.contains(&json!("name")));
1342        assert!(!required.contains(&json!("org")));
1343
1344        // Verify additionalProperties is set
1345        assert_eq!(card_def["additionalProperties"], true);
1346    }
1347
1348    #[test]
1349    fn test_extract_card_item_defaults() {
1350        // Create a JSON schema with card items that have defaults
1351        let schema = json!({
1352            "$schema": "https://json-schema.org/draft/2019-09/schema",
1353            "type": "object",
1354            "properties": {
1355                "endorsements": {
1356                    "type": "array",
1357                    "items": {
1358                        "type": "object",
1359                        "properties": {
1360                            "name": { "type": "string" },
1361                            "org": { "type": "string", "default": "Unknown Org" },
1362                            "rank": { "type": "string", "default": "N/A" }
1363                        }
1364                    }
1365                },
1366                "title": { "type": "string" }
1367            }
1368        });
1369
1370        let card_defaults = extract_card_item_defaults(&QuillValue::from_json(schema));
1371
1372        // Should have one card field with defaults
1373        assert_eq!(card_defaults.len(), 1);
1374        assert!(card_defaults.contains_key("endorsements"));
1375
1376        let endorsements_defaults = card_defaults.get("endorsements").unwrap();
1377        assert_eq!(endorsements_defaults.len(), 2); // org and rank have defaults
1378        assert!(!endorsements_defaults.contains_key("name")); // name has no default
1379        assert_eq!(
1380            endorsements_defaults.get("org").unwrap().as_str(),
1381            Some("Unknown Org")
1382        );
1383        assert_eq!(
1384            endorsements_defaults.get("rank").unwrap().as_str(),
1385            Some("N/A")
1386        );
1387    }
1388
1389    #[test]
1390    fn test_extract_card_item_defaults_empty() {
1391        // Schema with no card fields
1392        let schema = json!({
1393            "type": "object",
1394            "properties": {
1395                "title": { "type": "string" }
1396            }
1397        });
1398
1399        let card_defaults = extract_card_item_defaults(&QuillValue::from_json(schema));
1400        assert!(card_defaults.is_empty());
1401    }
1402
1403    #[test]
1404    fn test_extract_card_item_defaults_no_item_defaults() {
1405        // Schema with card field but no item defaults
1406        let schema = json!({
1407            "type": "object",
1408            "properties": {
1409                "endorsements": {
1410                    "type": "array",
1411                    "items": {
1412                        "type": "object",
1413                        "properties": {
1414                            "name": { "type": "string" },
1415                            "org": { "type": "string" }
1416                        }
1417                    }
1418                }
1419            }
1420        });
1421
1422        let card_defaults = extract_card_item_defaults(&QuillValue::from_json(schema));
1423        assert!(card_defaults.is_empty()); // No defaults defined
1424    }
1425
1426    #[test]
1427    fn test_apply_card_item_defaults() {
1428        // Set up card defaults
1429        let mut item_defaults = HashMap::new();
1430        item_defaults.insert(
1431            "org".to_string(),
1432            QuillValue::from_json(json!("Default Org")),
1433        );
1434
1435        let mut card_defaults = HashMap::new();
1436        card_defaults.insert("endorsements".to_string(), item_defaults);
1437
1438        // Set up document fields with card items missing the 'org' field
1439        let mut fields = HashMap::new();
1440        fields.insert(
1441            "endorsements".to_string(),
1442            QuillValue::from_json(json!([
1443                { "name": "John Doe" },
1444                { "name": "Jane Smith", "org": "Custom Org" }
1445            ])),
1446        );
1447
1448        let result = apply_card_item_defaults(&fields, &card_defaults);
1449
1450        // Verify defaults were applied
1451        let endorsements = result.get("endorsements").unwrap().as_array().unwrap();
1452        assert_eq!(endorsements.len(), 2);
1453
1454        // First item should have default applied
1455        assert_eq!(endorsements[0]["name"], "John Doe");
1456        assert_eq!(endorsements[0]["org"], "Default Org");
1457
1458        // Second item should preserve existing value
1459        assert_eq!(endorsements[1]["name"], "Jane Smith");
1460        assert_eq!(endorsements[1]["org"], "Custom Org");
1461    }
1462
1463    #[test]
1464    fn test_apply_card_item_defaults_empty_card() {
1465        let mut item_defaults = HashMap::new();
1466        item_defaults.insert(
1467            "org".to_string(),
1468            QuillValue::from_json(json!("Default Org")),
1469        );
1470
1471        let mut card_defaults = HashMap::new();
1472        card_defaults.insert("endorsements".to_string(), item_defaults);
1473
1474        // Empty endorsements array
1475        let mut fields = HashMap::new();
1476        fields.insert("endorsements".to_string(), QuillValue::from_json(json!([])));
1477
1478        let result = apply_card_item_defaults(&fields, &card_defaults);
1479
1480        // Should still be empty array
1481        let endorsements = result.get("endorsements").unwrap().as_array().unwrap();
1482        assert!(endorsements.is_empty());
1483    }
1484
1485    #[test]
1486    fn test_apply_card_item_defaults_no_matching_card() {
1487        let mut item_defaults = HashMap::new();
1488        item_defaults.insert(
1489            "org".to_string(),
1490            QuillValue::from_json(json!("Default Org")),
1491        );
1492
1493        let mut card_defaults = HashMap::new();
1494        card_defaults.insert("endorsements".to_string(), item_defaults);
1495
1496        // Document has different card field
1497        let mut fields = HashMap::new();
1498        fields.insert(
1499            "reviews".to_string(),
1500            QuillValue::from_json(json!([{ "author": "Bob" }])),
1501        );
1502
1503        let result = apply_card_item_defaults(&fields, &card_defaults);
1504
1505        // reviews should be unchanged
1506        let reviews = result.get("reviews").unwrap().as_array().unwrap();
1507        assert_eq!(reviews.len(), 1);
1508        assert_eq!(reviews[0]["author"], "Bob");
1509        assert!(reviews[0].get("org").is_none());
1510    }
1511
1512    #[test]
1513    fn test_card_validation_with_required_fields() {
1514        // Test that JSON Schema validation rejects card items missing required fields
1515        let schema = json!({
1516            "$schema": "https://json-schema.org/draft/2019-09/schema",
1517            "type": "object",
1518            "properties": {
1519                "endorsements": {
1520                    "type": "array",
1521                    "items": {
1522                        "type": "object",
1523                        "properties": {
1524                            "name": { "type": "string" },
1525                            "org": { "type": "string", "default": "Unknown" }
1526                        },
1527                        "required": ["name"]
1528                    }
1529                }
1530            }
1531        });
1532
1533        // Valid: has required 'name' field
1534        let mut valid_fields = HashMap::new();
1535        valid_fields.insert(
1536            "endorsements".to_string(),
1537            QuillValue::from_json(json!([{ "name": "John" }])),
1538        );
1539
1540        let result = validate_document(&QuillValue::from_json(schema.clone()), &valid_fields);
1541        assert!(result.is_ok());
1542
1543        // Invalid: missing required 'name' field
1544        let mut invalid_fields = HashMap::new();
1545        invalid_fields.insert(
1546            "endorsements".to_string(),
1547            QuillValue::from_json(json!([{ "org": "SomeOrg" }])),
1548        );
1549
1550        let result = validate_document(&QuillValue::from_json(schema), &invalid_fields);
1551        assert!(result.is_err());
1552    }
1553}