eure_json_schema/
eure_to_json_schema.rs

1//! Conversion from Eure Schema to JSON Schema (2020-12)
2//!
3//! This module provides functionality to convert Eure Schema documents to JSON Schema format.
4//! Since Eure Schema is a superset of JSON Schema with additional features, some constructs
5//! cannot be represented in JSON Schema and will result in conversion errors.
6
7use crate::json_schema::*;
8use eure_document::data_model::VariantRepr;
9use eure_document::document::EureDocument;
10use eure_json::Config as JsonConfig;
11use eure_schema::{
12    ArraySchema as EureArraySchema, Bound, Description, FloatSchema,
13    IntegerSchema as EureIntegerSchema, MapSchema, RecordSchema, SchemaDocument,
14    SchemaMetadata as EureMetadata, SchemaNode, SchemaNodeContent, SchemaNodeId, TextSchema,
15    TupleSchema, UnionSchema, UnknownFieldsPolicy,
16};
17use indexmap::IndexMap;
18use num_traits::ToPrimitive;
19
20/// Convert an EureDocument to a JSON value
21fn document_to_json(doc: &EureDocument) -> Result<serde_json::Value, ConversionError> {
22    Ok(eure_json::document_to_value(doc, &JsonConfig::default())?)
23}
24
25/// Errors that can occur during Eure Schema to JSON Schema conversion
26#[derive(Debug, Clone, PartialEq, thiserror::Error)]
27pub enum ConversionError {
28    /// Eure Hole type cannot be represented in JSON Schema
29    #[error("Eure Hole type cannot be represented in JSON Schema")]
30    HoleNotSupported,
31
32    /// Eure Hole in literal value cannot be represented in JSON Schema
33    #[error("Eure Hole in literal value cannot be represented in JSON Schema")]
34    HoleInLiteral,
35
36    /// Eure Map type with non-string keys cannot be represented in JSON Schema
37    #[error("Eure Map with non-string keys cannot be represented in JSON Schema")]
38    NonStringMapKeysNotSupported,
39
40    /// BigInt value is too large to fit in i64 for JSON Schema
41    #[error("BigInt value {0} is out of range for JSON Schema i64")]
42    BigIntOutOfRange(String),
43
44    /// Float value (NaN or Infinity) cannot be represented in JSON Schema
45    #[error("Invalid float value: {0}")]
46    InvalidFloatValue(String),
47
48    /// Invalid schema node reference
49    #[error("Invalid schema node reference: {0}")]
50    InvalidNodeReference(usize),
51
52    /// Circular reference detected (not supported in JSON Schema)
53    #[error("Circular reference detected: {0}")]
54    CircularReference(String),
55
56    /// JSON conversion error (from eure-json)
57    #[error(transparent)]
58    JsonConversion(#[from] eure_json::EureToJsonError),
59
60    /// Invalid default value type
61    #[error("Invalid default value: expected {expected}, got {actual}")]
62    InvalidDefaultValue {
63        expected: &'static str,
64        actual: String,
65    },
66}
67
68/// Conversion context to track state during conversion
69struct ConversionContext<'a> {
70    /// The source Eure schema document
71    document: &'a SchemaDocument,
72    /// Track visited nodes to detect circular references
73    visiting: Vec<SchemaNodeId>,
74}
75
76impl<'a> ConversionContext<'a> {
77    fn new(document: &'a SchemaDocument) -> Self {
78        Self {
79            document,
80            visiting: Vec::new(),
81        }
82    }
83
84    /// Get a node from the document
85    fn get_node(&self, id: SchemaNodeId) -> Result<&SchemaNode, ConversionError> {
86        self.document
87            .nodes
88            .get(id.0)
89            .ok_or(ConversionError::InvalidNodeReference(id.0))
90    }
91
92    /// Mark a node as being visited (for cycle detection)
93    fn push_visiting(&mut self, id: SchemaNodeId) -> Result<(), ConversionError> {
94        if self.visiting.contains(&id) {
95            return Err(ConversionError::CircularReference(format!(
96                "Node {} creates a cycle",
97                id.0
98            )));
99        }
100        self.visiting.push(id);
101        Ok(())
102    }
103
104    /// Unmark a node as being visited
105    fn pop_visiting(&mut self) {
106        self.visiting.pop();
107    }
108}
109
110/// Convert an Eure SchemaDocument to JSON Schema
111///
112/// The root schema will be converted, along with all referenced type definitions
113/// which will be placed in the `$defs` section of the JSON Schema.
114pub fn eure_to_json_schema(doc: &SchemaDocument) -> Result<JsonSchema, ConversionError> {
115    let mut ctx = ConversionContext::new(doc);
116
117    // Convert the root schema
118    let root_schema = convert_node(&mut ctx, doc.root)?;
119
120    // If there are named types, we need to wrap in a GenericSchema with $defs
121    if !doc.types.is_empty() {
122        let mut defs = IndexMap::new();
123
124        for (name, node_id) in &doc.types {
125            let converted = convert_node(&mut ctx, *node_id)?;
126            defs.insert(name.to_string(), converted);
127        }
128
129        // Wrap the root schema with definitions
130        Ok(wrap_with_definitions(root_schema, defs))
131    } else {
132        Ok(root_schema)
133    }
134}
135
136/// Wrap a schema with $defs
137fn wrap_with_definitions(root: JsonSchema, defs: IndexMap<String, JsonSchema>) -> JsonSchema {
138    // If root is already a Generic schema, we can add defs to it
139    if let JsonSchema::Generic(mut generic) = root {
140        generic.defs = Some(defs);
141        JsonSchema::Generic(generic)
142    } else {
143        // Otherwise, use allOf to combine root with a schema containing defs
144        JsonSchema::AllOf(AllOfSchema {
145            schemas: vec![
146                root,
147                JsonSchema::Generic(GenericSchema {
148                    defs: Some(defs),
149                    ..Default::default()
150                }),
151            ],
152            metadata: SchemaMetadata::default(),
153        })
154    }
155}
156
157/// Convert a single schema node to JSON Schema
158fn convert_node(
159    ctx: &mut ConversionContext,
160    id: SchemaNodeId,
161) -> Result<JsonSchema, ConversionError> {
162    ctx.push_visiting(id)?;
163
164    // Clone the content and metadata to avoid borrow checker issues
165    let node = ctx.get_node(id)?;
166    let content = node.content.clone();
167    let metadata = node.metadata.clone();
168
169    let result = convert_schema_content(ctx, &content, &metadata)?;
170
171    ctx.pop_visiting();
172    Ok(result)
173}
174
175/// Convert schema content with metadata
176fn convert_schema_content(
177    ctx: &mut ConversionContext,
178    content: &SchemaNodeContent,
179    eure_meta: &EureMetadata,
180) -> Result<JsonSchema, ConversionError> {
181    let json_metadata = convert_metadata(eure_meta)?;
182
183    match content {
184        SchemaNodeContent::Any => Ok(JsonSchema::Generic(GenericSchema {
185            metadata: json_metadata,
186            ..Default::default()
187        })),
188
189        SchemaNodeContent::Text(t) => convert_text_schema(t, eure_meta, json_metadata),
190
191        SchemaNodeContent::Integer(i) => convert_integer_schema(i, eure_meta, json_metadata),
192
193        SchemaNodeContent::Float(f) => convert_float_schema(f, eure_meta, json_metadata),
194
195        SchemaNodeContent::Boolean => convert_boolean_schema(eure_meta, json_metadata),
196
197        SchemaNodeContent::Null => Ok(JsonSchema::Typed(TypedSchema::Null(NullSchema {
198            metadata: json_metadata,
199        }))),
200
201        SchemaNodeContent::Array(a) => convert_array_schema(ctx, a, json_metadata),
202
203        SchemaNodeContent::Map(m) => convert_map_schema(ctx, m, json_metadata),
204
205        SchemaNodeContent::Record(r) => convert_record_schema(ctx, r, json_metadata),
206
207        SchemaNodeContent::Tuple(t) => convert_tuple_schema(ctx, t, json_metadata),
208
209        SchemaNodeContent::Union(u) => convert_union_schema(ctx, u, json_metadata),
210
211        SchemaNodeContent::Reference(ref_type) => {
212            // Convert to JSON Schema $ref
213            Ok(JsonSchema::Reference(ReferenceSchema {
214                reference: format!("#/$defs/{}", ref_type.name),
215                metadata: json_metadata,
216            }))
217        }
218
219        SchemaNodeContent::Literal(val) => Ok(JsonSchema::Const(ConstSchema {
220            value: document_to_json(val)?,
221            metadata: json_metadata,
222        })),
223    }
224}
225
226/// Convert Eure metadata to JSON Schema metadata
227fn convert_metadata(eure_meta: &EureMetadata) -> Result<SchemaMetadata, ConversionError> {
228    let examples = eure_meta
229        .examples
230        .as_ref()
231        .map(|examples| {
232            examples
233                .iter()
234                .map(document_to_json)
235                .collect::<Result<Vec<_>, _>>()
236        })
237        .transpose()?;
238
239    Ok(SchemaMetadata {
240        title: None, // Eure doesn't have title
241        description: eure_meta.description.as_ref().map(|d| match d {
242            Description::String(s) => s.clone(),
243            Description::Markdown(s) => s.clone(),
244        }),
245        deprecated: if eure_meta.deprecated {
246            Some(true)
247        } else {
248            None
249        },
250        examples,
251    })
252}
253
254/// Known JSON Schema format names (Draft 2020-12)
255const JSON_SCHEMA_FORMATS: &[&str] = &[
256    "date-time",
257    "date",
258    "time",
259    "duration",
260    "email",
261    "idn-email",
262    "hostname",
263    "idn-hostname",
264    "ipv4",
265    "ipv6",
266    "uri",
267    "uri-reference",
268    "iri",
269    "iri-reference",
270    "uuid",
271    "uri-template",
272    "json-pointer",
273    "relative-json-pointer",
274    "regex",
275];
276
277/// Convert Eure Text schema to JSON Schema
278///
279/// Text (which unifies the old String and Code types) maps to JSON Schema string type.
280/// If the language matches a known JSON Schema format, it's mapped to the format field.
281fn convert_text_schema(
282    eure: &TextSchema,
283    eure_meta: &EureMetadata,
284    metadata: SchemaMetadata,
285) -> Result<JsonSchema, ConversionError> {
286    // Map language to format if it's a known JSON Schema format
287    let format = eure.language.as_ref().and_then(|lang| {
288        if JSON_SCHEMA_FORMATS.contains(&lang.as_str()) {
289            Some(lang.clone())
290        } else {
291            None
292        }
293    });
294
295    // Convert default value if present
296    let default = eure_meta
297        .default
298        .as_ref()
299        .map(|doc| {
300            let json_val = document_to_json(doc)?;
301            match json_val {
302                serde_json::Value::String(s) => Ok(s),
303                other => Err(ConversionError::InvalidDefaultValue {
304                    expected: "string",
305                    actual: format!("{:?}", other),
306                }),
307            }
308        })
309        .transpose()?;
310
311    Ok(JsonSchema::Typed(TypedSchema::String(StringSchema {
312        min_length: eure.min_length,
313        max_length: eure.max_length,
314        pattern: eure.pattern.as_ref().map(|r| r.as_str().to_string()),
315        format,
316        default,
317        metadata,
318    })))
319}
320
321/// Convert Eure Integer schema to JSON Schema
322fn convert_integer_schema(
323    eure: &EureIntegerSchema,
324    eure_meta: &EureMetadata,
325    metadata: SchemaMetadata,
326) -> Result<JsonSchema, ConversionError> {
327    // Convert bounds
328    let (minimum, exclusive_minimum) = match &eure.min {
329        Bound::Unbounded => (None, None),
330        Bound::Inclusive(val) => (Some(bigint_to_i64(val)?), None),
331        Bound::Exclusive(val) => (None, Some(bigint_to_i64(val)?)),
332    };
333
334    let (maximum, exclusive_maximum) = match &eure.max {
335        Bound::Unbounded => (None, None),
336        Bound::Inclusive(val) => (Some(bigint_to_i64(val)?), None),
337        Bound::Exclusive(val) => (None, Some(bigint_to_i64(val)?)),
338    };
339
340    let multiple_of = eure.multiple_of.as_ref().map(bigint_to_i64).transpose()?;
341
342    // Convert default value if present
343    let default = eure_meta
344        .default
345        .as_ref()
346        .map(|doc| {
347            let json_val = document_to_json(doc)?;
348            match json_val {
349                serde_json::Value::Number(n) if n.is_i64() => Ok(n.as_i64().unwrap()),
350                other => Err(ConversionError::InvalidDefaultValue {
351                    expected: "integer",
352                    actual: format!("{:?}", other),
353                }),
354            }
355        })
356        .transpose()?;
357
358    Ok(JsonSchema::Typed(TypedSchema::Integer(IntegerSchema {
359        minimum,
360        maximum,
361        exclusive_minimum,
362        exclusive_maximum,
363        multiple_of,
364        default,
365        metadata,
366    })))
367}
368
369/// Convert BigInt to i64, returning error if out of range
370fn bigint_to_i64(val: &num_bigint::BigInt) -> Result<i64, ConversionError> {
371    val.to_i64()
372        .ok_or_else(|| ConversionError::BigIntOutOfRange(val.to_string()))
373}
374
375/// Convert Eure Float schema to JSON Schema
376fn convert_float_schema(
377    eure: &FloatSchema,
378    eure_meta: &EureMetadata,
379    metadata: SchemaMetadata,
380) -> Result<JsonSchema, ConversionError> {
381    // Validate float values (no NaN or Infinity)
382    let validate_float = |f: f64| -> Result<f64, ConversionError> {
383        if f.is_nan() || f.is_infinite() {
384            Err(ConversionError::InvalidFloatValue(f.to_string()))
385        } else {
386            Ok(f)
387        }
388    };
389
390    // Convert bounds
391    let (minimum, exclusive_minimum) = match &eure.min {
392        Bound::Unbounded => (None, None),
393        Bound::Inclusive(val) => (Some(validate_float(*val)?), None),
394        Bound::Exclusive(val) => (None, Some(validate_float(*val)?)),
395    };
396
397    let (maximum, exclusive_maximum) = match &eure.max {
398        Bound::Unbounded => (None, None),
399        Bound::Inclusive(val) => (Some(validate_float(*val)?), None),
400        Bound::Exclusive(val) => (None, Some(validate_float(*val)?)),
401    };
402
403    let multiple_of = eure.multiple_of.map(validate_float).transpose()?;
404
405    // Convert default value if present
406    let default = eure_meta
407        .default
408        .as_ref()
409        .map(|doc| {
410            let json_val = document_to_json(doc)?;
411            match json_val {
412                serde_json::Value::Number(n) => n
413                    .as_f64()
414                    .ok_or_else(|| ConversionError::InvalidDefaultValue {
415                        expected: "number",
416                        actual: format!("{:?}", n),
417                    })
418                    .and_then(validate_float),
419                other => Err(ConversionError::InvalidDefaultValue {
420                    expected: "number",
421                    actual: format!("{:?}", other),
422                }),
423            }
424        })
425        .transpose()?;
426
427    Ok(JsonSchema::Typed(TypedSchema::Number(NumberSchema {
428        minimum,
429        maximum,
430        exclusive_minimum,
431        exclusive_maximum,
432        multiple_of,
433        default,
434        metadata,
435    })))
436}
437
438/// Convert Eure Boolean schema to JSON Schema
439fn convert_boolean_schema(
440    eure_meta: &EureMetadata,
441    metadata: SchemaMetadata,
442) -> Result<JsonSchema, ConversionError> {
443    // Convert default value if present
444    let default = eure_meta
445        .default
446        .as_ref()
447        .map(|doc| {
448            let json_val = document_to_json(doc)?;
449            match json_val {
450                serde_json::Value::Bool(b) => Ok(b),
451                other => Err(ConversionError::InvalidDefaultValue {
452                    expected: "boolean",
453                    actual: format!("{:?}", other),
454                }),
455            }
456        })
457        .transpose()?;
458
459    Ok(JsonSchema::Typed(TypedSchema::Boolean(BooleanSchema {
460        default,
461        metadata,
462    })))
463}
464
465/// Convert Eure Array schema to JSON Schema
466fn convert_array_schema(
467    ctx: &mut ConversionContext,
468    eure: &EureArraySchema,
469    metadata: SchemaMetadata,
470) -> Result<JsonSchema, ConversionError> {
471    let items = Some(Box::new(convert_node(ctx, eure.item)?));
472
473    let contains = if let Some(contains_id) = &eure.contains {
474        // Contains is now a schema node reference
475        Some(Box::new(convert_node(ctx, *contains_id)?))
476    } else {
477        None
478    };
479
480    Ok(JsonSchema::Typed(TypedSchema::Array(ArraySchema {
481        items,
482        prefix_items: None, // Not used for regular arrays (only tuples use this)
483        min_items: eure.min_length,
484        max_items: eure.max_length,
485        unique_items: if eure.unique { Some(true) } else { None },
486        contains,
487        metadata,
488    })))
489}
490
491/// Convert Eure Map schema to JSON Schema
492///
493/// This is tricky because JSON Schema only supports string keys in objects.
494/// If the key type is not Text, we return an error.
495fn convert_map_schema(
496    ctx: &mut ConversionContext,
497    eure: &MapSchema,
498    metadata: SchemaMetadata,
499) -> Result<JsonSchema, ConversionError> {
500    // Check if key is text type (JSON Schema only supports string keys)
501    let key_node = ctx.get_node(eure.key)?;
502    if !matches!(key_node.content, SchemaNodeContent::Text(_)) {
503        return Err(ConversionError::NonStringMapKeysNotSupported);
504    }
505
506    // Convert value schema
507    let value_schema = convert_node(ctx, eure.value)?;
508
509    // Map becomes an object with additionalProperties
510    Ok(JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
511        properties: None,
512        required: None,
513        additional_properties: Some(AdditionalProperties::Schema(Box::new(value_schema))),
514        metadata,
515    })))
516}
517
518/// Convert Eure Record schema to JSON Schema object
519fn convert_record_schema(
520    ctx: &mut ConversionContext,
521    eure: &RecordSchema,
522    metadata: SchemaMetadata,
523) -> Result<JsonSchema, ConversionError> {
524    let mut properties = IndexMap::new();
525    let mut required = Vec::new();
526
527    for (field_name, field) in &eure.properties {
528        let is_optional = field.optional;
529        let field_schema = convert_node(ctx, field.schema)?;
530
531        properties.insert(field_name.clone(), field_schema);
532
533        // If field is not optional, add to required
534        if !is_optional {
535            required.push(field_name.clone());
536        }
537    }
538
539    let additional_properties = match &eure.unknown_fields {
540        UnknownFieldsPolicy::Deny => Some(AdditionalProperties::Bool(false)),
541        UnknownFieldsPolicy::Allow => Some(AdditionalProperties::Bool(true)),
542        UnknownFieldsPolicy::Schema(node_id) => {
543            let schema = convert_node(ctx, *node_id)?;
544            Some(AdditionalProperties::Schema(Box::new(schema)))
545        }
546    };
547
548    let properties = if properties.is_empty() {
549        None
550    } else {
551        Some(properties)
552    };
553
554    let required = if required.is_empty() {
555        None
556    } else {
557        Some(required)
558    };
559
560    Ok(JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
561        properties,
562        required,
563        additional_properties,
564        metadata,
565    })))
566}
567
568/// Convert Eure Tuple schema to JSON Schema
569///
570/// JSON Schema supports tuple validation via array with items as an array of schemas
571/// However, this is less well-supported, so we note this as a potential limitation
572fn convert_tuple_schema(
573    ctx: &mut ConversionContext,
574    eure: &TupleSchema,
575    metadata: SchemaMetadata,
576) -> Result<JsonSchema, ConversionError> {
577    // Convert each element schema to JSON Schema
578    let prefix_items: Vec<JsonSchema> = eure
579        .elements
580        .iter()
581        .map(|node_id| convert_node(ctx, *node_id))
582        .collect::<Result<Vec<_>, _>>()?;
583
584    // Use prefixItems (JSON Schema 2020-12) for tuple validation
585    // Also set items: false to disallow additional elements
586    Ok(JsonSchema::Typed(TypedSchema::Array(ArraySchema {
587        items: Some(Box::new(JsonSchema::Boolean(false))),
588        prefix_items: if prefix_items.is_empty() {
589            None
590        } else {
591            Some(prefix_items)
592        },
593        min_items: Some(eure.elements.len() as u32),
594        max_items: Some(eure.elements.len() as u32),
595        unique_items: None,
596        contains: None,
597        metadata,
598    })))
599}
600
601/// Convert Eure Union to JSON Schema
602///
603/// The conversion strategy depends on the variant representation:
604/// - External: oneOf with object schemas (each with a single property)
605/// - Internal: oneOf with allOf to merge tag and content
606/// - Adjacent: oneOf with schemas having tag and content properties
607/// - Untagged: oneOf with just the variant schemas (no tagging)
608fn convert_union_schema(
609    ctx: &mut ConversionContext,
610    eure: &UnionSchema,
611    metadata: SchemaMetadata,
612) -> Result<JsonSchema, ConversionError> {
613    match &eure.repr {
614        VariantRepr::External => convert_external_variant(ctx, eure, metadata),
615        VariantRepr::Internal { tag } => convert_internal_variant(ctx, eure, tag, metadata),
616        VariantRepr::Adjacent { tag, content } => {
617            convert_adjacent_variant(ctx, eure, tag, content, metadata)
618        }
619        VariantRepr::Untagged => convert_untagged_variant(ctx, eure, metadata),
620    }
621}
622
623/// Convert external variant representation
624fn convert_external_variant(
625    ctx: &mut ConversionContext,
626    eure: &UnionSchema,
627    metadata: SchemaMetadata,
628) -> Result<JsonSchema, ConversionError> {
629    let mut schemas = Vec::new();
630
631    for (variant_name, node_id) in &eure.variants {
632        let variant_schema = convert_node(ctx, *node_id)?;
633
634        // External: { "variant-name": <schema> }
635        let mut properties = IndexMap::new();
636        properties.insert(variant_name.clone(), variant_schema);
637
638        let obj = JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
639            properties: Some(properties),
640            required: Some(vec![variant_name.clone()]),
641            additional_properties: Some(AdditionalProperties::Bool(false)),
642            metadata: SchemaMetadata::default(),
643        }));
644
645        schemas.push(obj);
646    }
647
648    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
649}
650
651/// Convert internal variant representation
652fn convert_internal_variant(
653    ctx: &mut ConversionContext,
654    eure: &UnionSchema,
655    tag: &str,
656    metadata: SchemaMetadata,
657) -> Result<JsonSchema, ConversionError> {
658    let mut schemas = Vec::new();
659
660    for (variant_name, node_id) in &eure.variants {
661        let variant_schema = convert_node(ctx, *node_id)?;
662
663        // Internal: allOf with tag constraint and content schema
664        let tag_schema = JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
665            properties: Some({
666                let mut props = IndexMap::new();
667                props.insert(
668                    tag.to_string(),
669                    JsonSchema::Const(ConstSchema {
670                        value: serde_json::Value::String(variant_name.clone()),
671                        metadata: SchemaMetadata::default(),
672                    }),
673                );
674                props
675            }),
676            required: Some(vec![tag.to_string()]),
677            additional_properties: None,
678            metadata: SchemaMetadata::default(),
679        }));
680
681        let combined = JsonSchema::AllOf(AllOfSchema {
682            schemas: vec![tag_schema, variant_schema],
683            metadata: SchemaMetadata::default(),
684        });
685
686        schemas.push(combined);
687    }
688
689    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
690}
691
692/// Convert adjacent variant representation
693fn convert_adjacent_variant(
694    ctx: &mut ConversionContext,
695    eure: &UnionSchema,
696    tag: &str,
697    content: &str,
698    metadata: SchemaMetadata,
699) -> Result<JsonSchema, ConversionError> {
700    let mut schemas = Vec::new();
701
702    for (variant_name, node_id) in &eure.variants {
703        let variant_schema = convert_node(ctx, *node_id)?;
704
705        // Adjacent: { "tag": "variant-name", "content": <schema> }
706        let mut properties = IndexMap::new();
707        properties.insert(
708            tag.to_string(),
709            JsonSchema::Const(ConstSchema {
710                value: serde_json::Value::String(variant_name.clone()),
711                metadata: SchemaMetadata::default(),
712            }),
713        );
714        properties.insert(content.to_string(), variant_schema);
715
716        let obj = JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
717            properties: Some(properties),
718            required: Some(vec![tag.to_string(), content.to_string()]),
719            additional_properties: Some(AdditionalProperties::Bool(false)),
720            metadata: SchemaMetadata::default(),
721        }));
722
723        schemas.push(obj);
724    }
725
726    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
727}
728
729/// Convert untagged variant representation
730fn convert_untagged_variant(
731    ctx: &mut ConversionContext,
732    eure: &UnionSchema,
733    metadata: SchemaMetadata,
734) -> Result<JsonSchema, ConversionError> {
735    let mut schemas = Vec::new();
736
737    for node_id in eure.variants.values() {
738        let variant_schema = convert_node(ctx, *node_id)?;
739        schemas.push(variant_schema);
740    }
741
742    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use eure_document::data_model::VariantRepr;
749    use eure_schema::{
750        Bound, IntegerSchema as EureIntegerSchema, RecordFieldSchema, RecordSchema, SchemaDocument,
751        SchemaNodeContent, UnknownFieldsPolicy,
752    };
753
754    #[test]
755    fn test_convert_simple_text() {
756        let mut doc = SchemaDocument::new();
757        doc.root = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
758
759        let result = eure_to_json_schema(&doc).unwrap();
760        assert!(matches!(result, JsonSchema::Typed(TypedSchema::String(_))));
761    }
762
763    #[test]
764    fn test_convert_text_with_language() {
765        // Text with language (e.g., code) should still convert to JSON Schema string
766        let mut doc = SchemaDocument::new();
767        doc.root = doc.create_node(SchemaNodeContent::Text(TextSchema {
768            language: Some("rust".to_string()),
769            ..Default::default()
770        }));
771
772        let result = eure_to_json_schema(&doc).unwrap();
773        assert!(matches!(result, JsonSchema::Typed(TypedSchema::String(_))));
774    }
775
776    #[test]
777    fn test_convert_integer_with_bounds() {
778        let mut doc = SchemaDocument::new();
779        doc.root = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema {
780            min: Bound::Inclusive(0.into()),
781            max: Bound::Exclusive(100.into()),
782            multiple_of: None,
783        }));
784
785        let result = eure_to_json_schema(&doc).unwrap();
786        match result {
787            JsonSchema::Typed(TypedSchema::Integer(schema)) => {
788                assert_eq!(schema.minimum, Some(0));
789                assert_eq!(schema.exclusive_maximum, Some(100));
790            }
791            _ => panic!("Expected Integer schema"),
792        }
793    }
794
795    #[test]
796    fn test_convert_record_to_object() {
797        let mut doc = SchemaDocument::new();
798
799        let text_id = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
800        let int_id = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema::default()));
801
802        let mut properties = IndexMap::new();
803        properties.insert(
804            "name".to_string(),
805            RecordFieldSchema {
806                schema: text_id,
807                optional: false,
808                binding_style: None,
809            },
810        );
811        properties.insert(
812            "age".to_string(),
813            RecordFieldSchema {
814                schema: int_id,
815                optional: false,
816                binding_style: None,
817            },
818        );
819
820        doc.root = doc.create_node(SchemaNodeContent::Record(RecordSchema {
821            properties,
822            unknown_fields: UnknownFieldsPolicy::Deny,
823        }));
824
825        let result = eure_to_json_schema(&doc).unwrap();
826        match result {
827            JsonSchema::Typed(TypedSchema::Object(schema)) => {
828                assert!(schema.properties.is_some());
829                let props = schema.properties.unwrap();
830                assert_eq!(props.len(), 2);
831                assert!(props.contains_key("name"));
832                assert!(props.contains_key("age"));
833            }
834            _ => panic!("Expected Object schema"),
835        }
836    }
837
838    #[test]
839    fn test_convert_untagged_union_to_oneof() {
840        let mut doc = SchemaDocument::new();
841
842        let text_id = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
843        let int_id = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema::default()));
844
845        let mut variants = IndexMap::new();
846        variants.insert("TextVariant".to_string(), text_id);
847        variants.insert("IntVariant".to_string(), int_id);
848
849        doc.root = doc.create_node(SchemaNodeContent::Union(UnionSchema {
850            variants,
851            unambiguous: Default::default(),
852            repr: VariantRepr::Untagged,
853            deny_untagged: Default::default(),
854        }));
855
856        let result = eure_to_json_schema(&doc).unwrap();
857        match result {
858            JsonSchema::OneOf(schema) => {
859                assert_eq!(schema.schemas.len(), 2);
860            }
861            _ => panic!("Expected OneOf schema for untagged union"),
862        }
863    }
864
865    #[test]
866    fn test_convert_external_union_to_oneof() {
867        let mut doc = SchemaDocument::new();
868
869        let text_id = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
870        let int_id = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema::default()));
871
872        let mut variants = IndexMap::new();
873        variants.insert("A".to_string(), text_id);
874        variants.insert("B".to_string(), int_id);
875
876        doc.root = doc.create_node(SchemaNodeContent::Union(UnionSchema {
877            variants,
878            unambiguous: Default::default(),
879            repr: VariantRepr::External,
880            deny_untagged: Default::default(),
881        }));
882
883        let result = eure_to_json_schema(&doc).unwrap();
884        match result {
885            JsonSchema::OneOf(schema) => {
886                assert_eq!(schema.schemas.len(), 2);
887                // Each variant should be wrapped in an object with a single property
888            }
889            _ => panic!("Expected OneOf schema for external union"),
890        }
891    }
892}