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::document::EureDocument;
9use eure_json::Config as JsonConfig;
10use eure_schema::interop::VariantRepr;
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    let repr = eure
648        .interop
649        .variant_repr
650        .as_ref()
651        .unwrap_or(&VariantRepr::External);
652    match repr {
653        VariantRepr::External => convert_external_variant(ctx, eure, metadata),
654        VariantRepr::Internal { tag } => convert_internal_variant(ctx, eure, tag, metadata),
655        VariantRepr::Adjacent { tag, content } => {
656            convert_adjacent_variant(ctx, eure, tag, content, metadata)
657        }
658        VariantRepr::Untagged => convert_untagged_variant(ctx, eure, metadata),
659    }
660}
661
662/// Convert external variant representation
663fn convert_external_variant(
664    ctx: &mut ConversionContext,
665    eure: &UnionSchema,
666    metadata: SchemaMetadata,
667) -> Result<JsonSchema, ConversionError> {
668    let mut schemas = Vec::new();
669
670    for (variant_name, node_id) in &eure.variants {
671        let variant_schema = convert_node(ctx, *node_id)?;
672
673        // External: { "variant-name": <schema> }
674        let mut properties = IndexMap::new();
675        properties.insert(variant_name.clone(), variant_schema);
676
677        let obj = JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
678            properties: Some(properties),
679            required: Some(vec![variant_name.clone()]),
680            additional_properties: Some(AdditionalProperties::Bool(false)),
681            metadata: SchemaMetadata::default(),
682        }));
683
684        schemas.push(obj);
685    }
686
687    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
688}
689
690/// Convert internal variant representation
691fn convert_internal_variant(
692    ctx: &mut ConversionContext,
693    eure: &UnionSchema,
694    tag: &str,
695    metadata: SchemaMetadata,
696) -> Result<JsonSchema, ConversionError> {
697    let mut schemas = Vec::new();
698
699    for (variant_name, node_id) in &eure.variants {
700        let variant_schema = convert_node(ctx, *node_id)?;
701
702        // Internal: allOf with tag constraint and content schema
703        let tag_schema = JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
704            properties: Some({
705                let mut props = IndexMap::new();
706                props.insert(
707                    tag.to_string(),
708                    JsonSchema::Const(ConstSchema {
709                        value: serde_json::Value::String(variant_name.clone()),
710                        metadata: SchemaMetadata::default(),
711                    }),
712                );
713                props
714            }),
715            required: Some(vec![tag.to_string()]),
716            additional_properties: None,
717            metadata: SchemaMetadata::default(),
718        }));
719
720        let combined = JsonSchema::AllOf(AllOfSchema {
721            schemas: vec![tag_schema, variant_schema],
722            metadata: SchemaMetadata::default(),
723        });
724
725        schemas.push(combined);
726    }
727
728    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
729}
730
731/// Convert adjacent variant representation
732fn convert_adjacent_variant(
733    ctx: &mut ConversionContext,
734    eure: &UnionSchema,
735    tag: &str,
736    content: &str,
737    metadata: SchemaMetadata,
738) -> Result<JsonSchema, ConversionError> {
739    let mut schemas = Vec::new();
740
741    for (variant_name, node_id) in &eure.variants {
742        let variant_schema = convert_node(ctx, *node_id)?;
743
744        // Adjacent: { "tag": "variant-name", "content": <schema> }
745        let mut properties = IndexMap::new();
746        properties.insert(
747            tag.to_string(),
748            JsonSchema::Const(ConstSchema {
749                value: serde_json::Value::String(variant_name.clone()),
750                metadata: SchemaMetadata::default(),
751            }),
752        );
753        properties.insert(content.to_string(), variant_schema);
754
755        let obj = JsonSchema::Typed(TypedSchema::Object(ObjectSchema {
756            properties: Some(properties),
757            required: Some(vec![tag.to_string(), content.to_string()]),
758            additional_properties: Some(AdditionalProperties::Bool(false)),
759            metadata: SchemaMetadata::default(),
760        }));
761
762        schemas.push(obj);
763    }
764
765    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
766}
767
768/// Convert untagged variant representation
769fn convert_untagged_variant(
770    ctx: &mut ConversionContext,
771    eure: &UnionSchema,
772    metadata: SchemaMetadata,
773) -> Result<JsonSchema, ConversionError> {
774    let mut schemas = Vec::new();
775
776    for node_id in eure.variants.values() {
777        let variant_schema = convert_node(ctx, *node_id)?;
778        schemas.push(variant_schema);
779    }
780
781    Ok(JsonSchema::OneOf(OneOfSchema { schemas, metadata }))
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use eure_schema::interop::{UnionInterop, VariantRepr};
788    use eure_schema::{
789        Bound, IntegerSchema as EureIntegerSchema, RecordFieldSchema, RecordSchema, SchemaDocument,
790        SchemaNodeContent, UnknownFieldsPolicy,
791    };
792
793    #[test]
794    fn test_convert_simple_text() {
795        let mut doc = SchemaDocument::new();
796        doc.root = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
797
798        let result = eure_to_json_schema(&doc).unwrap();
799        assert!(matches!(result, JsonSchema::Typed(TypedSchema::String(_))));
800    }
801
802    #[test]
803    fn test_convert_text_with_language() {
804        // Text with language (e.g., code) should still convert to JSON Schema string
805        let mut doc = SchemaDocument::new();
806        doc.root = doc.create_node(SchemaNodeContent::Text(TextSchema {
807            language: Some("rust".to_string()),
808            ..Default::default()
809        }));
810
811        let result = eure_to_json_schema(&doc).unwrap();
812        assert!(matches!(result, JsonSchema::Typed(TypedSchema::String(_))));
813    }
814
815    #[test]
816    fn test_convert_integer_with_bounds() {
817        let mut doc = SchemaDocument::new();
818        doc.root = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema {
819            min: Bound::Inclusive(0.into()),
820            max: Bound::Exclusive(100.into()),
821            multiple_of: None,
822        }));
823
824        let result = eure_to_json_schema(&doc).unwrap();
825        match result {
826            JsonSchema::Typed(TypedSchema::Integer(schema)) => {
827                assert_eq!(schema.minimum, Some(0));
828                assert_eq!(schema.exclusive_maximum, Some(100));
829            }
830            _ => panic!("Expected Integer schema"),
831        }
832    }
833
834    #[test]
835    fn test_convert_record_to_object() {
836        let mut doc = SchemaDocument::new();
837
838        let text_id = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
839        let int_id = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema::default()));
840
841        let mut properties = IndexMap::new();
842        properties.insert(
843            "name".to_string(),
844            RecordFieldSchema {
845                schema: text_id,
846                optional: false,
847                binding_style: None,
848                field_codegen: Default::default(),
849            },
850        );
851        properties.insert(
852            "age".to_string(),
853            RecordFieldSchema {
854                schema: int_id,
855                optional: false,
856                binding_style: None,
857                field_codegen: Default::default(),
858            },
859        );
860
861        doc.root = doc.create_node(SchemaNodeContent::Record(RecordSchema {
862            properties,
863            flatten: vec![],
864            unknown_fields: UnknownFieldsPolicy::Deny,
865        }));
866
867        let result = eure_to_json_schema(&doc).unwrap();
868        match result {
869            JsonSchema::Typed(TypedSchema::Object(schema)) => {
870                assert!(schema.properties.is_some());
871                let props = schema.properties.unwrap();
872                assert_eq!(props.len(), 2);
873                assert!(props.contains_key("name"));
874                assert!(props.contains_key("age"));
875            }
876            _ => panic!("Expected Object schema"),
877        }
878    }
879
880    #[test]
881    fn test_convert_untagged_union_to_oneof() {
882        let mut doc = SchemaDocument::new();
883
884        let text_id = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
885        let int_id = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema::default()));
886
887        let mut variants = IndexMap::new();
888        variants.insert("TextVariant".to_string(), text_id);
889        variants.insert("IntVariant".to_string(), int_id);
890
891        doc.root = doc.create_node(SchemaNodeContent::Union(UnionSchema {
892            variants,
893            unambiguous: Default::default(),
894            interop: UnionInterop {
895                variant_repr: Some(VariantRepr::Untagged),
896            },
897            deny_untagged: Default::default(),
898        }));
899
900        let result = eure_to_json_schema(&doc).unwrap();
901        match result {
902            JsonSchema::OneOf(schema) => {
903                assert_eq!(schema.schemas.len(), 2);
904            }
905            _ => panic!("Expected OneOf schema for untagged union"),
906        }
907    }
908
909    #[test]
910    fn test_convert_external_union_to_oneof() {
911        let mut doc = SchemaDocument::new();
912
913        let text_id = doc.create_node(SchemaNodeContent::Text(TextSchema::default()));
914        let int_id = doc.create_node(SchemaNodeContent::Integer(EureIntegerSchema::default()));
915
916        let mut variants = IndexMap::new();
917        variants.insert("A".to_string(), text_id);
918        variants.insert("B".to_string(), int_id);
919
920        doc.root = doc.create_node(SchemaNodeContent::Union(UnionSchema {
921            variants,
922            unambiguous: Default::default(),
923            interop: UnionInterop {
924                variant_repr: Some(VariantRepr::External),
925            },
926            deny_untagged: Default::default(),
927        }));
928
929        let result = eure_to_json_schema(&doc).unwrap();
930        match result {
931            JsonSchema::OneOf(schema) => {
932                assert_eq!(schema.schemas.len(), 2);
933                // Each variant should be wrapped in an object with a single property
934            }
935            _ => panic!("Expected OneOf schema for external union"),
936        }
937    }
938}