Skip to main content

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 base_schema = convert_record_properties(ctx, eure, metadata.clone())?;
525
526    // If no flatten targets, return the base schema directly
527    if eure.flatten.is_empty() {
528        return Ok(base_schema);
529    }
530
531    // With flatten targets, use allOf to combine base schema with flattened schemas
532    let mut all_of_schemas = vec![base_schema];
533
534    for &flatten_id in &eure.flatten {
535        let flatten_schema = convert_node(ctx, flatten_id)?;
536        all_of_schemas.push(flatten_schema);
537    }
538
539    Ok(JsonSchema::AllOf(AllOfSchema {
540        schemas: all_of_schemas,
541        metadata,
542    }))
543}
544
545/// Convert record properties to a JSON Schema object, excluding flatten targets.
546///
547/// This helper extracts the direct properties of a record schema into a JSON Schema
548/// object with `properties`, `required`, and `additionalProperties` fields.
549///
550/// Used by `convert_record_schema` to separate the base record schema from
551/// flatten targets. When a record has flatten targets, the result of this function
552/// is combined with the flatten target schemas using `allOf`.
553fn convert_record_properties(
554    ctx: &mut ConversionContext,
555    eure: &RecordSchema,
556    metadata: SchemaMetadata,
557) -> Result<JsonSchema, ConversionError> {
558    let mut properties = IndexMap::new();
559    let mut required = Vec::new();
560
561    for (field_name, field) in &eure.properties {
562        let is_optional = field.optional;
563        let field_schema = convert_node(ctx, field.schema)?;
564
565        properties.insert(field_name.clone(), field_schema);
566
567        // If field is not optional, add to required
568        if !is_optional {
569            required.push(field_name.clone());
570        }
571    }
572
573    let additional_properties = match &eure.unknown_fields {
574        UnknownFieldsPolicy::Deny => Some(AdditionalProperties::Bool(false)),
575        UnknownFieldsPolicy::Allow => Some(AdditionalProperties::Bool(true)),
576        UnknownFieldsPolicy::Schema(node_id) => {
577            let schema = convert_node(ctx, *node_id)?;
578            Some(AdditionalProperties::Schema(Box::new(schema)))
579        }
580    };
581
582    let properties = if properties.is_empty() {
583        None
584    } else {
585        Some(properties)
586    };
587
588    let required = if required.is_empty() {
589        None
590    } else {
591        Some(required)
592    };
593
594    Ok(JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
595        properties,
596        required,
597        additional_properties,
598        metadata,
599    })))
600}
601
602/// Convert Eure Tuple schema to JSON Schema
603///
604/// JSON Schema supports tuple validation via array with items as an array of schemas
605/// However, this is less well-supported, so we note this as a potential limitation
606fn convert_tuple_schema(
607    ctx: &mut ConversionContext,
608    eure: &TupleSchema,
609    metadata: SchemaMetadata,
610) -> Result<JsonSchema, ConversionError> {
611    // Convert each element schema to JSON Schema
612    let prefix_items: Vec<JsonSchema> = eure
613        .elements
614        .iter()
615        .map(|node_id| convert_node(ctx, *node_id))
616        .collect::<Result<Vec<_>, _>>()?;
617
618    // Use prefixItems (JSON Schema 2020-12) for tuple validation
619    // Also set items: false to disallow additional elements
620    Ok(JsonSchema::Typed(TypedSchema::Array(ArraySchema {
621        items: Some(Box::new(JsonSchema::Boolean(false))),
622        prefix_items: if prefix_items.is_empty() {
623            None
624        } else {
625            Some(prefix_items)
626        },
627        min_items: Some(eure.elements.len() as u32),
628        max_items: Some(eure.elements.len() as u32),
629        unique_items: None,
630        contains: None,
631        metadata,
632    })))
633}
634
635/// Convert Eure Union to JSON Schema
636///
637/// The conversion strategy depends on the variant representation:
638/// - External: oneOf with object schemas (each with a single property)
639/// - Internal: oneOf with allOf to merge tag and content
640/// - Adjacent: oneOf with schemas having tag and content properties
641/// - Untagged: oneOf with just the variant schemas (no tagging)
642fn convert_union_schema(
643    ctx: &mut ConversionContext,
644    eure: &UnionSchema,
645    metadata: SchemaMetadata,
646) -> Result<JsonSchema, ConversionError> {
647    match &eure.repr {
648        VariantRepr::External => convert_external_variant(ctx, eure, metadata),
649        VariantRepr::Internal { tag } => convert_internal_variant(ctx, eure, tag, metadata),
650        VariantRepr::Adjacent { tag, content } => {
651            convert_adjacent_variant(ctx, eure, tag, content, metadata)
652        }
653        VariantRepr::Untagged => convert_untagged_variant(ctx, eure, metadata),
654    }
655}
656
657/// Convert external variant representation
658fn convert_external_variant(
659    ctx: &mut ConversionContext,
660    eure: &UnionSchema,
661    metadata: SchemaMetadata,
662) -> Result<JsonSchema, ConversionError> {
663    let mut schemas = Vec::new();
664
665    for (variant_name, node_id) in &eure.variants {
666        let variant_schema = convert_node(ctx, *node_id)?;
667
668        // External: { "variant-name": <schema> }
669        let mut properties = IndexMap::new();
670        properties.insert(variant_name.clone(), variant_schema);
671
672        let obj = JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
673            properties: Some(properties),
674            required: Some(vec![variant_name.clone()]),
675            additional_properties: Some(AdditionalProperties::Bool(false)),
676            metadata: SchemaMetadata::default(),
677        }));
678
679        schemas.push(obj);
680    }
681
682    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
683}
684
685/// Convert internal variant representation
686fn convert_internal_variant(
687    ctx: &mut ConversionContext,
688    eure: &UnionSchema,
689    tag: &str,
690    metadata: SchemaMetadata,
691) -> Result<JsonSchema, ConversionError> {
692    let mut schemas = Vec::new();
693
694    for (variant_name, node_id) in &eure.variants {
695        let variant_schema = convert_node(ctx, *node_id)?;
696
697        // Internal: allOf with tag constraint and content schema
698        let tag_schema = JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
699            properties: Some({
700                let mut props = IndexMap::new();
701                props.insert(
702                    tag.to_string(),
703                    JsonSchema::Const(ConstSchema {
704                        value: serde_json::Value::String(variant_name.clone()),
705                        metadata: SchemaMetadata::default(),
706                    }),
707                );
708                props
709            }),
710            required: Some(vec![tag.to_string()]),
711            additional_properties: None,
712            metadata: SchemaMetadata::default(),
713        }));
714
715        let combined = JsonSchema::AllOf(AllOfSchema {
716            schemas: vec![tag_schema, variant_schema],
717            metadata: SchemaMetadata::default(),
718        });
719
720        schemas.push(combined);
721    }
722
723    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
724}
725
726/// Convert adjacent variant representation
727fn convert_adjacent_variant(
728    ctx: &mut ConversionContext,
729    eure: &UnionSchema,
730    tag: &str,
731    content: &str,
732    metadata: SchemaMetadata,
733) -> Result<JsonSchema, ConversionError> {
734    let mut schemas = Vec::new();
735
736    for (variant_name, node_id) in &eure.variants {
737        let variant_schema = convert_node(ctx, *node_id)?;
738
739        // Adjacent: { "tag": "variant-name", "content": <schema> }
740        let mut properties = IndexMap::new();
741        properties.insert(
742            tag.to_string(),
743            JsonSchema::Const(ConstSchema {
744                value: serde_json::Value::String(variant_name.clone()),
745                metadata: SchemaMetadata::default(),
746            }),
747        );
748        properties.insert(content.to_string(), variant_schema);
749
750        let obj = JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
751            properties: Some(properties),
752            required: Some(vec![tag.to_string(), content.to_string()]),
753            additional_properties: Some(AdditionalProperties::Bool(false)),
754            metadata: SchemaMetadata::default(),
755        }));
756
757        schemas.push(obj);
758    }
759
760    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
761}
762
763/// Convert untagged variant representation
764fn convert_untagged_variant(
765    ctx: &mut ConversionContext,
766    eure: &UnionSchema,
767    metadata: SchemaMetadata,
768) -> Result<JsonSchema, ConversionError> {
769    let mut schemas = Vec::new();
770
771    for node_id in eure.variants.values() {
772        let variant_schema = convert_node(ctx, *node_id)?;
773        schemas.push(variant_schema);
774    }
775
776    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782    use eure_document::data_model::VariantRepr;
783    use eure_schema::{
784        Bound, IntegerSchema as EureIntegerSchema, RecordFieldSchema, RecordSchema, SchemaDocument,
785        SchemaNodeContent, UnknownFieldsPolicy,
786    };
787
788    #[test]
789    fn test_convert_simple_text() {
790        let mut doc = SchemaDocument::new();
791        doc.root = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
792
793        let result = eure_to_json_schema(&doc).unwrap();
794        assert!(matches!(result, JsonSchema::Typed(TypedSchema::String(_))));
795    }
796
797    #[test]
798    fn test_convert_text_with_language() {
799        // Text with language (e.g., code) should still convert to JSON Schema string
800        let mut doc = SchemaDocument::new();
801        doc.root = doc.create_node(SchemaNodeContent::Text(TextSchema {
802            language: Some("rust".to_string()),
803            ..Default::default()
804        }));
805
806        let result = eure_to_json_schema(&doc).unwrap();
807        assert!(matches!(result, JsonSchema::Typed(TypedSchema::String(_))));
808    }
809
810    #[test]
811    fn test_convert_integer_with_bounds() {
812        let mut doc = SchemaDocument::new();
813        doc.root = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema {
814            min: Bound::Inclusive(0.into()),
815            max: Bound::Exclusive(100.into()),
816            multiple_of: None,
817        }));
818
819        let result = eure_to_json_schema(&doc).unwrap();
820        match result {
821            JsonSchema::Typed(TypedSchema::Integer(schema)) => {
822                assert_eq!(schema.minimum, Some(0));
823                assert_eq!(schema.exclusive_maximum, Some(100));
824            }
825            _ => panic!("Expected Integer schema"),
826        }
827    }
828
829    #[test]
830    fn test_convert_record_to_object() {
831        let mut doc = SchemaDocument::new();
832
833        let text_id = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
834        let int_id = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema::default()));
835
836        let mut properties = IndexMap::new();
837        properties.insert(
838            "name".to_string(),
839            RecordFieldSchema {
840                schema: text_id,
841                optional: false,
842                binding_style: None,
843            },
844        );
845        properties.insert(
846            "age".to_string(),
847            RecordFieldSchema {
848                schema: int_id,
849                optional: false,
850                binding_style: None,
851            },
852        );
853
854        doc.root = doc.create_node(SchemaNodeContent::Record(RecordSchema {
855            properties,
856            flatten: vec![],
857            unknown_fields: UnknownFieldsPolicy::Deny,
858        }));
859
860        let result = eure_to_json_schema(&doc).unwrap();
861        match result {
862            JsonSchema::Typed(TypedSchema::Object(schema)) => {
863                assert!(schema.properties.is_some());
864                let props = schema.properties.unwrap();
865                assert_eq!(props.len(), 2);
866                assert!(props.contains_key("name"));
867                assert!(props.contains_key("age"));
868            }
869            _ => panic!("Expected Object schema"),
870        }
871    }
872
873    #[test]
874    fn test_convert_untagged_union_to_oneof() {
875        let mut doc = SchemaDocument::new();
876
877        let text_id = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
878        let int_id = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema::default()));
879
880        let mut variants = IndexMap::new();
881        variants.insert("TextVariant".to_string(), text_id);
882        variants.insert("IntVariant".to_string(), int_id);
883
884        doc.root = doc.create_node(SchemaNodeContent::Union(UnionSchema {
885            variants,
886            unambiguous: Default::default(),
887            repr: VariantRepr::Untagged,
888            deny_untagged: Default::default(),
889        }));
890
891        let result = eure_to_json_schema(&doc).unwrap();
892        match result {
893            JsonSchema::OneOf(schema) => {
894                assert_eq!(schema.schemas.len(), 2);
895            }
896            _ => panic!("Expected OneOf schema for untagged union"),
897        }
898    }
899
900    #[test]
901    fn test_convert_external_union_to_oneof() {
902        let mut doc = SchemaDocument::new();
903
904        let text_id = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
905        let int_id = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema::default()));
906
907        let mut variants = IndexMap::new();
908        variants.insert("A".to_string(), text_id);
909        variants.insert("B".to_string(), int_id);
910
911        doc.root = doc.create_node(SchemaNodeContent::Union(UnionSchema {
912            variants,
913            unambiguous: Default::default(),
914            repr: VariantRepr::External,
915            deny_untagged: Default::default(),
916        }));
917
918        let result = eure_to_json_schema(&doc).unwrap();
919        match result {
920            JsonSchema::OneOf(schema) => {
921                assert_eq!(schema.schemas.len(), 2);
922                // Each variant should be wrapped in an object with a single property
923            }
924            _ => panic!("Expected OneOf schema for external union"),
925        }
926    }
927}