Skip to main content

mx20022_codegen/ir/
lower.rs

1//! XSD AST → IR lowering pass.
2//!
3//! The single public entry point is [`lower`], which converts a parsed
4//! [`xsd::Schema`] into a [`TypeGraph`].
5//!
6//! # Name-conversion rules
7//!
8//! | Source | Rule | Example |
9//! |---|---|---|
10//! | XSD type name | kept verbatim (already PascalCase) | `BusinessApplicationHeaderV04` |
11//! | Sequence element name → struct field | [`xml_to_snake_case`] | `BizMsgIdr` → `biz_msg_idr` |
12//! | Choice element name → variant name | kept verbatim | `OrgId` → `OrgId` |
13//! | Enumeration value → code variant | [`code_to_pascal_case`] | `"ADDR"` → `Addr` |
14//! | Attribute name → field name | [`xml_to_snake_case`] | `Ccy` → `ccy` |
15
16use indexmap::IndexMap;
17
18use crate::xsd::{self, ComplexContent, Facet, MaxOccurs, Restriction, Schema, SequenceElement};
19
20use super::types::{
21    AttrDef, Cardinality, CodeEnumDef, CodeValue, Constraint, EnumDef, FieldDef, NewtypeDef,
22    OpaqueDef, RootElement, RustType, StructDef, TypeDef, TypeGraph, TypeRef, ValueWithAttrDef,
23    VariantDef,
24};
25
26// ── Error type ────────────────────────────────────────────────────────────────
27
28/// Errors that can occur during the lowering pass.
29#[derive(Debug, thiserror::Error)]
30pub enum LowerError {
31    /// A `xs:simpleType` restriction base was not a recognized built-in XSD
32    /// type and did not refer to a known user-defined type.
33    #[error("unknown base type '{base}' in simple type '{context}'")]
34    UnknownBase { base: String, context: String },
35}
36
37// ── Public entry point ────────────────────────────────────────────────────────
38
39/// Lower an XSD [`Schema`] into a [`TypeGraph`].
40///
41/// # Errors
42///
43/// Returns [`LowerError`] if an unresolvable type reference is encountered.
44pub fn lower(schema: &Schema) -> Result<TypeGraph, LowerError> {
45    let mut types: IndexMap<String, TypeDef> = IndexMap::new();
46
47    for st in &schema.simple_types {
48        let def = lower_simple_type(st)?;
49        let name = normalize_type_name(&st.name);
50        types.insert(name, def);
51    }
52
53    for ct in &schema.complex_types {
54        let def = lower_complex_type(ct);
55        let name = normalize_type_name(&ct.name);
56        types.insert(name, def);
57    }
58
59    let root_elements = schema
60        .elements
61        .iter()
62        .map(|e| RootElement {
63            xml_name: e.name.clone(),
64            type_name: e.type_name.clone(),
65        })
66        .collect();
67
68    Ok(TypeGraph {
69        namespace: schema.target_namespace.clone(),
70        root_elements,
71        types,
72    })
73}
74
75// ── Simple type lowering ──────────────────────────────────────────────────────
76
77fn lower_simple_type(st: &xsd::SimpleType) -> Result<TypeDef, LowerError> {
78    let restriction = &st.restriction;
79
80    // If every facet is an Enumeration, produce a CodeEnum.
81    let all_enums = !restriction.facets.is_empty()
82        && restriction
83            .facets
84            .iter()
85            .all(|f| matches!(f, Facet::Enumeration(_)));
86
87    if all_enums {
88        return Ok(TypeDef::CodeEnum(lower_code_enum(&st.name, restriction)));
89    }
90
91    // Otherwise produce a Newtype with constraints.
92    lower_newtype(&st.name, restriction).map(TypeDef::Newtype)
93}
94
95fn lower_code_enum(name: &str, restriction: &Restriction) -> CodeEnumDef {
96    let codes = restriction
97        .facets
98        .iter()
99        .filter_map(|f| {
100            if let Facet::Enumeration(v) = f {
101                Some(CodeValue {
102                    xml_value: v.clone(),
103                    rust_name: code_to_pascal_case(v),
104                })
105            } else {
106                None
107            }
108        })
109        .collect();
110
111    CodeEnumDef {
112        name: normalize_type_name(name),
113        codes,
114    }
115}
116
117fn lower_newtype(name: &str, restriction: &Restriction) -> Result<NewtypeDef, LowerError> {
118    let inner = map_builtin(&restriction.base).ok_or_else(|| LowerError::UnknownBase {
119        base: restriction.base.clone(),
120        context: name.to_owned(),
121    })?;
122
123    let constraints = restriction.facets.iter().filter_map(lower_facet).collect();
124
125    Ok(NewtypeDef {
126        name: normalize_type_name(name),
127        inner,
128        constraints,
129    })
130}
131
132fn lower_facet(facet: &Facet) -> Option<Constraint> {
133    match facet {
134        Facet::Enumeration(_) => None, // handled separately
135        Facet::Pattern(v) => Some(Constraint::Pattern(v.clone())),
136        Facet::MinLength(v) => Some(Constraint::MinLength(*v)),
137        Facet::MaxLength(v) => Some(Constraint::MaxLength(*v)),
138        Facet::MinInclusive(v) => Some(Constraint::MinInclusive(v.clone())),
139        Facet::MaxInclusive(v) => Some(Constraint::MaxInclusive(v.clone())),
140        Facet::TotalDigits(v) => Some(Constraint::TotalDigits(*v)),
141        Facet::FractionDigits(v) => Some(Constraint::FractionDigits(*v)),
142    }
143}
144
145// ── Complex type lowering ─────────────────────────────────────────────────────
146
147fn lower_complex_type(ct: &xsd::ComplexType) -> TypeDef {
148    match &ct.content {
149        ComplexContent::Sequence(elements) => TypeDef::Struct(lower_struct(&ct.name, elements)),
150        ComplexContent::Choice(variants) => TypeDef::Enum(lower_enum(&ct.name, variants)),
151        ComplexContent::SimpleContent { base, attributes } => {
152            TypeDef::ValueWithAttr(lower_value_with_attr(&ct.name, base, attributes))
153        }
154        ComplexContent::Any { namespace } => TypeDef::Opaque(OpaqueDef {
155            name: normalize_type_name(&ct.name),
156            namespace: namespace.clone(),
157        }),
158    }
159}
160
161fn lower_struct(name: &str, elements: &[SequenceElement]) -> StructDef {
162    let fields = elements.iter().map(lower_field).collect();
163    StructDef {
164        name: normalize_type_name(name),
165        fields,
166    }
167}
168
169fn lower_field(el: &SequenceElement) -> FieldDef {
170    let type_ref = resolve_type_ref(&el.type_name);
171    let cardinality = lower_cardinality(el.min_occurs, &el.max_occurs);
172
173    FieldDef {
174        xml_name: el.name.clone(),
175        rust_name: xml_to_snake_case(&el.name),
176        type_ref,
177        cardinality,
178    }
179}
180
181fn lower_cardinality(min_occurs: u32, max_occurs: &MaxOccurs) -> Cardinality {
182    match (min_occurs, max_occurs) {
183        (_, MaxOccurs::Unbounded) => Cardinality::Vec,
184        (_, MaxOccurs::Bounded(n)) if *n > 1 => Cardinality::BoundedVec(*n),
185        (0, MaxOccurs::Bounded(1)) => Cardinality::Optional,
186        _ => Cardinality::Required,
187    }
188}
189
190fn lower_enum(name: &str, variants: &[xsd::ChoiceVariant]) -> EnumDef {
191    let variants = variants
192        .iter()
193        .map(|v| VariantDef {
194            xml_name: v.name.clone(),
195            // Variant names are kept as-is; they are already PascalCase in
196            // ISO 20022 XSD files.
197            rust_name: v.name.clone(),
198            type_ref: resolve_type_ref(&v.type_name),
199        })
200        .collect();
201
202    EnumDef {
203        name: normalize_type_name(name),
204        variants,
205    }
206}
207
208fn lower_value_with_attr(
209    name: &str,
210    base: &str,
211    attributes: &[xsd::Attribute],
212) -> ValueWithAttrDef {
213    let value_type = resolve_type_ref(base);
214
215    let attrs = attributes
216        .iter()
217        .map(|a| AttrDef {
218            xml_name: a.name.clone(),
219            rust_name: xml_to_snake_case(&a.name),
220            type_ref: resolve_type_ref(&a.type_name),
221            required: a.required,
222        })
223        .collect();
224
225    ValueWithAttrDef {
226        name: normalize_type_name(name),
227        value_type,
228        attributes: attrs,
229    }
230}
231
232// ── Type-reference resolution ─────────────────────────────────────────────────
233
234/// Normalize an XSD type name to a valid Rust `PascalCase` identifier.
235///
236/// ISO 20022 type names are already `PascalCase` with one exception: types like
237/// `ActiveCurrencyAndAmount_SimpleType` contain underscores, which trigger
238/// `non_camel_case_types` warnings.  This function strips underscores and joins
239/// the segments.
240fn normalize_type_name(name: &str) -> String {
241    if name.contains('_') {
242        name.split('_')
243            .map(|seg| {
244                let mut c = seg.chars();
245                match c.next() {
246                    None => String::new(),
247                    Some(first) => {
248                        let mut s = first.to_uppercase().to_string();
249                        s.extend(c);
250                        s
251                    }
252                }
253            })
254            .collect()
255    } else {
256        name.to_owned()
257    }
258}
259
260/// Resolve an XSD type name to a [`TypeRef`].
261///
262/// If the name is a known XSD built-in it becomes a [`TypeRef::Builtin`];
263/// otherwise it becomes a [`TypeRef::Named`] referencing another type in the
264/// graph.
265fn resolve_type_ref(type_name: &str) -> TypeRef {
266    if let Some(rt) = map_builtin(type_name) {
267        TypeRef::Builtin(rt)
268    } else {
269        TypeRef::Named(normalize_type_name(type_name))
270    }
271}
272
273/// Map an XSD built-in type name to a [`RustType`].
274///
275/// Returns `None` for user-defined type names.
276fn map_builtin(name: &str) -> Option<RustType> {
277    // Strip namespace prefix if present (e.g. "xs:string" or "xsd:string").
278    let local = name.split(':').next_back().unwrap_or(name);
279
280    match local {
281        "string" | "normalizedString" | "token" | "ID" | "IDREF" | "NMTOKEN" | "anyURI"
282        | "language" | "Name" | "NCName" | "base64Binary" | "hexBinary" | "time" | "gYear"
283        | "gYearMonth" | "gMonth" | "gMonthDay" | "gDay" | "duration" => Some(RustType::String),
284        "boolean" => Some(RustType::Bool),
285        "decimal" | "integer" | "int" | "long" | "short" | "byte" | "nonNegativeInteger"
286        | "positiveInteger" | "unsignedInt" | "unsignedLong" | "unsignedShort" | "unsignedByte"
287        | "nonPositiveInteger" | "negativeInteger" | "float" | "double" => Some(RustType::Decimal),
288        "date" => Some(RustType::Date),
289        "dateTime" => Some(RustType::DateTime),
290        _ => None,
291    }
292}
293
294// ── Name conversion utilities ─────────────────────────────────────────────────
295
296/// Convert an ISO 20022 XML element name (`PascalCase` / mixed-case / all-caps)
297/// to a `snake_case` Rust identifier.
298///
299/// # Algorithm
300///
301/// The function inserts an underscore before each uppercase letter that
302/// immediately follows a lowercase letter, or before an uppercase letter that
303/// is immediately followed by a lowercase letter when the preceding character
304/// is also uppercase (to handle runs of capitals such as `"BICFI"` or the
305/// transition in `"FIToFI"`).
306///
307/// # Examples
308///
309/// ```
310/// use mx20022_codegen::ir::lower::xml_to_snake_case;
311///
312/// assert_eq!(xml_to_snake_case("BizMsgIdr"),      "biz_msg_idr");
313/// assert_eq!(xml_to_snake_case("FIToFICstmrCdtTrf"), "fi_to_fi_cstmr_cdt_trf");
314/// assert_eq!(xml_to_snake_case("BICFI"),           "bicfi");
315/// assert_eq!(xml_to_snake_case("LEI"),             "lei");
316/// assert_eq!(xml_to_snake_case("Id"),              "id");
317/// assert_eq!(xml_to_snake_case("URLAdr"),          "url_adr");
318/// assert_eq!(xml_to_snake_case("CreDt"),           "cre_dt");
319/// ```
320pub fn xml_to_snake_case(name: &str) -> String {
321    if name.is_empty() {
322        return String::new();
323    }
324
325    let chars: Vec<char> = name.chars().collect();
326    let n = chars.len();
327    let mut out = String::with_capacity(n + 4);
328
329    for i in 0..n {
330        let c = chars[i];
331        if c.is_uppercase() {
332            let prev_lower = i > 0 && chars[i - 1].is_lowercase();
333            let next_lower = i + 1 < n && chars[i + 1].is_lowercase();
334            let prev_upper = i > 0 && chars[i - 1].is_uppercase();
335
336            // Insert underscore when:
337            //  1. preceded by a lowercase letter  (e.g. "Msg|I|dr")
338            //  2. preceded by uppercase AND followed by lowercase, meaning we
339            //     are at the start of a new word within an all-caps run
340            //     (e.g. "BICF|I|" — no, but "URL|A|dr" yes: prev=L upper, next=d lower)
341            if i > 0 && (prev_lower || (prev_upper && next_lower)) {
342                out.push('_');
343            }
344            out.push(c.to_lowercase().next().unwrap_or(c));
345        } else {
346            out.push(c);
347        }
348    }
349
350    out
351}
352
353/// Convert an XSD enumeration value (typically all-caps) to a `PascalCase` Rust
354/// variant name.
355///
356/// # Rules
357///
358/// - If the value is already mixed-case, preserve it verbatim (the XSD author
359///   chose the casing intentionally).
360/// - If the value is all-uppercase (optionally with digits/hyphens), convert
361///   the first character to uppercase and the rest to lowercase, splitting on
362///   hyphens if present.
363///
364/// # Examples
365///
366/// ```
367/// use mx20022_codegen::ir::lower::code_to_pascal_case;
368///
369/// assert_eq!(code_to_pascal_case("ADDR"),  "Addr");
370/// assert_eq!(code_to_pascal_case("CODU"),  "Codu");
371/// assert_eq!(code_to_pascal_case("DUPL"),  "Dupl");
372/// assert_eq!(code_to_pascal_case("HIGH"),  "High");
373/// assert_eq!(code_to_pascal_case("NORM"),  "Norm");
374/// assert_eq!(code_to_pascal_case("TEST-VALUE"), "TestValue");
375/// ```
376pub fn code_to_pascal_case(value: &str) -> String {
377    // Split on hyphens and process each segment.
378    value
379        .split('-')
380        .map(|segment| {
381            let mut chars = segment.chars();
382            match chars.next() {
383                None => String::new(),
384                Some(first) => {
385                    let upper = first.to_uppercase().to_string();
386                    let rest: String = chars.collect::<String>().to_lowercase();
387                    upper + &rest
388                }
389            }
390        })
391        .collect()
392}
393
394// ── Tests ─────────────────────────────────────────────────────────────────────
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::ir::types::{Cardinality, CodeValue, Constraint, RustType, TypeDef, TypeRef};
400    use crate::xsd::{
401        Attribute, ChoiceVariant, ComplexContent, ComplexType, Element, Facet, MaxOccurs,
402        Restriction, Schema, SequenceElement, SimpleType,
403    };
404
405    // ── Helper builders ───────────────────────────────────────────────────────
406
407    fn make_schema(
408        elements: Vec<Element>,
409        simple_types: Vec<SimpleType>,
410        complex_types: Vec<ComplexType>,
411    ) -> Schema {
412        Schema {
413            target_namespace: "urn:test".to_owned(),
414            elements,
415            simple_types,
416            complex_types,
417        }
418    }
419
420    fn seq_el(name: &str, type_name: &str, min: u32, max: MaxOccurs) -> SequenceElement {
421        SequenceElement {
422            name: name.to_owned(),
423            type_name: type_name.to_owned(),
424            min_occurs: min,
425            max_occurs: max,
426        }
427    }
428
429    fn restriction(base: &str, facets: Vec<Facet>) -> Restriction {
430        Restriction {
431            base: base.to_owned(),
432            facets,
433        }
434    }
435
436    // ── Snake-case conversion ─────────────────────────────────────────────────
437
438    #[test]
439    fn snake_case_basic_pascal() {
440        assert_eq!(xml_to_snake_case("BizMsgIdr"), "biz_msg_idr");
441    }
442
443    #[test]
444    fn snake_case_fi_to_fi() {
445        assert_eq!(
446            xml_to_snake_case("FIToFICstmrCdtTrf"),
447            "fi_to_fi_cstmr_cdt_trf"
448        );
449    }
450
451    #[test]
452    fn snake_case_all_caps() {
453        assert_eq!(xml_to_snake_case("BICFI"), "bicfi");
454        assert_eq!(xml_to_snake_case("LEI"), "lei");
455    }
456
457    #[test]
458    fn snake_case_two_chars() {
459        assert_eq!(xml_to_snake_case("Id"), "id");
460    }
461
462    #[test]
463    fn snake_case_url_prefix() {
464        assert_eq!(xml_to_snake_case("URLAdr"), "url_adr");
465    }
466
467    #[test]
468    fn snake_case_cre_dt() {
469        assert_eq!(xml_to_snake_case("CreDt"), "cre_dt");
470    }
471
472    #[test]
473    fn snake_case_single_lower() {
474        assert_eq!(xml_to_snake_case("a"), "a");
475    }
476
477    #[test]
478    fn snake_case_empty() {
479        assert_eq!(xml_to_snake_case(""), "");
480    }
481
482    // ── Code-to-PascalCase ────────────────────────────────────────────────────
483
484    #[test]
485    fn code_pascal_addr() {
486        assert_eq!(code_to_pascal_case("ADDR"), "Addr");
487    }
488
489    #[test]
490    fn code_pascal_codu() {
491        assert_eq!(code_to_pascal_case("CODU"), "Codu");
492    }
493
494    #[test]
495    fn code_pascal_hyphen() {
496        assert_eq!(code_to_pascal_case("TEST-VALUE"), "TestValue");
497    }
498
499    // ── CodeEnum lowering ─────────────────────────────────────────────────────
500
501    #[test]
502    fn lower_code_enum_basic() {
503        let schema = make_schema(
504            vec![],
505            vec![SimpleType {
506                name: "AddressType2Code".to_owned(),
507                restriction: restriction(
508                    "xs:string",
509                    vec![
510                        Facet::Enumeration("ADDR".to_owned()),
511                        Facet::Enumeration("PBOX".to_owned()),
512                        Facet::Enumeration("HOME".to_owned()),
513                    ],
514                ),
515            }],
516            vec![],
517        );
518
519        let graph = lower(&schema).unwrap();
520        let def = graph.types.get("AddressType2Code").unwrap();
521        let TypeDef::CodeEnum(ce) = def else {
522            panic!("expected CodeEnum, got {:?}", def);
523        };
524
525        assert_eq!(ce.name, "AddressType2Code");
526        assert_eq!(ce.codes.len(), 3);
527        assert_eq!(
528            ce.codes[0],
529            CodeValue {
530                xml_value: "ADDR".to_owned(),
531                rust_name: "Addr".to_owned(),
532            }
533        );
534        assert_eq!(
535            ce.codes[1],
536            CodeValue {
537                xml_value: "PBOX".to_owned(),
538                rust_name: "Pbox".to_owned(),
539            }
540        );
541    }
542
543    // ── Newtype lowering ──────────────────────────────────────────────────────
544
545    #[test]
546    fn lower_newtype_with_pattern() {
547        let schema = make_schema(
548            vec![],
549            vec![SimpleType {
550                name: "BICFIDec2014Identifier".to_owned(),
551                restriction: restriction(
552                    "xs:string",
553                    vec![Facet::Pattern(
554                        "[A-Z0-9]{4,4}[A-Z]{2,2}[A-Z0-9]{2,2}([A-Z0-9]{3,3}){0,1}".to_owned(),
555                    )],
556                ),
557            }],
558            vec![],
559        );
560
561        let graph = lower(&schema).unwrap();
562        let def = graph.types.get("BICFIDec2014Identifier").unwrap();
563        let TypeDef::Newtype(nt) = def else {
564            panic!("expected Newtype");
565        };
566
567        assert_eq!(nt.inner, RustType::String);
568        assert_eq!(nt.constraints.len(), 1);
569        assert!(matches!(&nt.constraints[0], Constraint::Pattern(_)));
570    }
571
572    #[test]
573    fn lower_newtype_with_length_bounds() {
574        let schema = make_schema(
575            vec![],
576            vec![SimpleType {
577                name: "Max35Text".to_owned(),
578                restriction: restriction(
579                    "xs:string",
580                    vec![Facet::MinLength(1), Facet::MaxLength(35)],
581                ),
582            }],
583            vec![],
584        );
585
586        let graph = lower(&schema).unwrap();
587        let def = graph.types.get("Max35Text").unwrap();
588        let TypeDef::Newtype(nt) = def else {
589            panic!("expected Newtype");
590        };
591
592        assert_eq!(nt.constraints.len(), 2);
593        assert_eq!(nt.constraints[0], Constraint::MinLength(1));
594        assert_eq!(nt.constraints[1], Constraint::MaxLength(35));
595    }
596
597    #[test]
598    fn lower_newtype_no_facets() {
599        // A simpleType with no facets at all — e.g. BusinessMessagePriorityCode.
600        let schema = make_schema(
601            vec![],
602            vec![SimpleType {
603                name: "BusinessMessagePriorityCode".to_owned(),
604                restriction: restriction("xs:string", vec![]),
605            }],
606            vec![],
607        );
608
609        let graph = lower(&schema).unwrap();
610        let def = graph.types.get("BusinessMessagePriorityCode").unwrap();
611        let TypeDef::Newtype(nt) = def else {
612            panic!("expected Newtype");
613        };
614        assert_eq!(nt.inner, RustType::String);
615        assert!(nt.constraints.is_empty());
616    }
617
618    // ── Struct lowering ───────────────────────────────────────────────────────
619
620    #[test]
621    fn lower_struct_basic() {
622        let schema = make_schema(
623            vec![],
624            vec![],
625            vec![ComplexType {
626                name: "BusinessApplicationHeaderV04".to_owned(),
627                content: ComplexContent::Sequence(vec![
628                    seq_el("BizMsgIdr", "Max35Text", 1, MaxOccurs::Bounded(1)),
629                    seq_el("Fr", "Party51Choice", 1, MaxOccurs::Bounded(1)),
630                    seq_el(
631                        "Rltd",
632                        "BusinessApplicationHeader8",
633                        0,
634                        MaxOccurs::Unbounded,
635                    ),
636                ]),
637            }],
638        );
639
640        let graph = lower(&schema).unwrap();
641        let def = graph.types.get("BusinessApplicationHeaderV04").unwrap();
642        let TypeDef::Struct(sd) = def else {
643            panic!("expected Struct");
644        };
645
646        assert_eq!(sd.name, "BusinessApplicationHeaderV04");
647        assert_eq!(sd.fields.len(), 3);
648
649        let f0 = &sd.fields[0];
650        assert_eq!(f0.xml_name, "BizMsgIdr");
651        assert_eq!(f0.rust_name, "biz_msg_idr");
652        assert_eq!(f0.type_ref, TypeRef::Named("Max35Text".to_owned()));
653        assert_eq!(f0.cardinality, Cardinality::Required);
654
655        let f2 = &sd.fields[2];
656        assert_eq!(f2.cardinality, Cardinality::Vec);
657    }
658
659    // ── Cardinality mapping ───────────────────────────────────────────────────
660
661    #[test]
662    fn cardinality_required() {
663        let c = lower_cardinality(1, &MaxOccurs::Bounded(1));
664        assert_eq!(c, Cardinality::Required);
665    }
666
667    #[test]
668    fn cardinality_optional() {
669        let c = lower_cardinality(0, &MaxOccurs::Bounded(1));
670        assert_eq!(c, Cardinality::Optional);
671    }
672
673    #[test]
674    fn cardinality_vec_unbounded() {
675        let c = lower_cardinality(0, &MaxOccurs::Unbounded);
676        assert_eq!(c, Cardinality::Vec);
677    }
678
679    #[test]
680    fn cardinality_bounded_vec() {
681        let c = lower_cardinality(0, &MaxOccurs::Bounded(5));
682        assert_eq!(c, Cardinality::BoundedVec(5));
683    }
684
685    #[test]
686    fn cardinality_unbounded_required_min() {
687        // min=1, max=unbounded → Vec (we don't differentiate non-empty vecs in
688        // the IR; that can be a constraint in the validate crate).
689        let c = lower_cardinality(1, &MaxOccurs::Unbounded);
690        assert_eq!(c, Cardinality::Vec);
691    }
692
693    // ── Enum (choice) lowering ────────────────────────────────────────────────
694
695    #[test]
696    fn lower_choice_enum() {
697        let schema = make_schema(
698            vec![],
699            vec![],
700            vec![ComplexType {
701                name: "AddressType3Choice".to_owned(),
702                content: ComplexContent::Choice(vec![
703                    ChoiceVariant {
704                        name: "Cd".to_owned(),
705                        type_name: "AddressType2Code".to_owned(),
706                    },
707                    ChoiceVariant {
708                        name: "Prtry".to_owned(),
709                        type_name: "GenericIdentification30".to_owned(),
710                    },
711                ]),
712            }],
713        );
714
715        let graph = lower(&schema).unwrap();
716        let def = graph.types.get("AddressType3Choice").unwrap();
717        let TypeDef::Enum(ed) = def else {
718            panic!("expected Enum");
719        };
720
721        assert_eq!(ed.name, "AddressType3Choice");
722        assert_eq!(ed.variants.len(), 2);
723        assert_eq!(ed.variants[0].xml_name, "Cd");
724        assert_eq!(ed.variants[0].rust_name, "Cd");
725        assert_eq!(
726            ed.variants[0].type_ref,
727            TypeRef::Named("AddressType2Code".to_owned())
728        );
729    }
730
731    // ── SimpleContent → ValueWithAttr ─────────────────────────────────────────
732
733    #[test]
734    fn lower_simple_content() {
735        let schema = make_schema(
736            vec![],
737            vec![],
738            vec![ComplexType {
739                name: "ActiveCurrencyAndAmount".to_owned(),
740                content: ComplexContent::SimpleContent {
741                    base: "xs:decimal".to_owned(),
742                    attributes: vec![Attribute {
743                        name: "Ccy".to_owned(),
744                        type_name: "ActiveCurrencyCode".to_owned(),
745                        required: true,
746                    }],
747                },
748            }],
749        );
750
751        let graph = lower(&schema).unwrap();
752        let def = graph.types.get("ActiveCurrencyAndAmount").unwrap();
753        let TypeDef::ValueWithAttr(vwa) = def else {
754            panic!("expected ValueWithAttr");
755        };
756
757        assert_eq!(vwa.name, "ActiveCurrencyAndAmount");
758        assert_eq!(vwa.value_type, TypeRef::Builtin(RustType::Decimal));
759        assert_eq!(vwa.attributes.len(), 1);
760        assert_eq!(vwa.attributes[0].xml_name, "Ccy");
761        assert_eq!(vwa.attributes[0].rust_name, "ccy");
762        assert_eq!(
763            vwa.attributes[0].type_ref,
764            TypeRef::Named("ActiveCurrencyCode".to_owned())
765        );
766        assert!(vwa.attributes[0].required);
767    }
768
769    // ── xs:any → Opaque ───────────────────────────────────────────────────────
770
771    #[test]
772    fn lower_any_opaque() {
773        let schema = make_schema(
774            vec![],
775            vec![],
776            vec![ComplexType {
777                name: "SignatureEnvelope".to_owned(),
778                content: ComplexContent::Any {
779                    namespace: Some("##other".to_owned()),
780                },
781            }],
782        );
783
784        let graph = lower(&schema).unwrap();
785        let def = graph.types.get("SignatureEnvelope").unwrap();
786        let TypeDef::Opaque(op) = def else {
787            panic!("expected Opaque");
788        };
789
790        assert_eq!(op.name, "SignatureEnvelope");
791        assert_eq!(op.namespace, Some("##other".to_owned()));
792    }
793
794    #[test]
795    fn lower_any_opaque_no_namespace() {
796        let schema = make_schema(
797            vec![],
798            vec![],
799            vec![ComplexType {
800                name: "AnyContent".to_owned(),
801                content: ComplexContent::Any { namespace: None },
802            }],
803        );
804
805        let graph = lower(&schema).unwrap();
806        let def = graph.types.get("AnyContent").unwrap();
807        let TypeDef::Opaque(op) = def else {
808            panic!("expected Opaque");
809        };
810        assert!(op.namespace.is_none());
811    }
812
813    // ── Built-in type mapping ─────────────────────────────────────────────────
814
815    #[test]
816    fn builtin_mapping_string() {
817        assert_eq!(map_builtin("xs:string"), Some(RustType::String));
818        assert_eq!(map_builtin("string"), Some(RustType::String));
819    }
820
821    #[test]
822    fn builtin_mapping_boolean() {
823        assert_eq!(map_builtin("xs:boolean"), Some(RustType::Bool));
824    }
825
826    #[test]
827    fn builtin_mapping_decimal() {
828        assert_eq!(map_builtin("xs:decimal"), Some(RustType::Decimal));
829    }
830
831    #[test]
832    fn builtin_mapping_date() {
833        assert_eq!(map_builtin("xs:date"), Some(RustType::Date));
834    }
835
836    #[test]
837    fn builtin_mapping_datetime() {
838        assert_eq!(map_builtin("xs:dateTime"), Some(RustType::DateTime));
839    }
840
841    #[test]
842    fn builtin_mapping_unknown() {
843        assert_eq!(map_builtin("Max35Text"), None);
844    }
845
846    // ── Root elements ─────────────────────────────────────────────────────────
847
848    #[test]
849    fn lower_root_elements() {
850        let schema = make_schema(
851            vec![Element {
852                name: "AppHdr".to_owned(),
853                type_name: "BusinessApplicationHeaderV04".to_owned(),
854            }],
855            vec![],
856            vec![],
857        );
858
859        let graph = lower(&schema).unwrap();
860        assert_eq!(graph.root_elements.len(), 1);
861        assert_eq!(graph.root_elements[0].xml_name, "AppHdr");
862        assert_eq!(
863            graph.root_elements[0].type_name,
864            "BusinessApplicationHeaderV04"
865        );
866    }
867
868    // ── Integration: real head.001.001.04 schema ──────────────────────────────
869
870    #[test]
871    fn integration_lower_head_001() {
872        let xsd_path = concat!(
873            env!("CARGO_MANIFEST_DIR"),
874            "/../../schemas/head/head.001.001.04.xsd"
875        );
876
877        let file = std::fs::File::open(xsd_path).expect("head.001.001.04.xsd not found");
878        let schema = crate::xsd::parse(std::io::BufReader::new(file)).expect("XSD parse failed");
879
880        let graph = lower(&schema).unwrap();
881
882        // The schema has one root element (AppHdr).
883        assert_eq!(graph.root_elements.len(), 1, "expected 1 root element");
884        assert_eq!(graph.root_elements[0].xml_name, "AppHdr");
885
886        // There should be a reasonable number of types (the schema has ~50+).
887        assert!(
888            graph.types.len() >= 30,
889            "expected at least 30 types, got {}",
890            graph.types.len()
891        );
892
893        // The root type should be a Struct.
894        let root_type_name = &graph.root_elements[0].type_name;
895        let root = graph.types.get(root_type_name).expect("root type missing");
896        assert!(
897            matches!(root, TypeDef::Struct(_)),
898            "root type should be a Struct"
899        );
900
901        // CodeEnum: AddressType2Code should have 6 variants.
902        let addr_code = graph
903            .types
904            .get("AddressType2Code")
905            .expect("AddressType2Code missing");
906        let TypeDef::CodeEnum(ce) = addr_code else {
907            panic!("AddressType2Code should be CodeEnum");
908        };
909        assert_eq!(ce.codes.len(), 6);
910
911        // Newtype: BICFIDec2014Identifier should have a pattern constraint.
912        let bic = graph
913            .types
914            .get("BICFIDec2014Identifier")
915            .expect("BICFIDec2014Identifier missing");
916        let TypeDef::Newtype(nt) = bic else {
917            panic!("BICFIDec2014Identifier should be Newtype");
918        };
919        assert!(nt
920            .constraints
921            .iter()
922            .any(|c| matches!(c, Constraint::Pattern(_))));
923
924        // Enum (choice): AddressType3Choice.
925        let choice = graph
926            .types
927            .get("AddressType3Choice")
928            .expect("AddressType3Choice missing");
929        assert!(matches!(choice, TypeDef::Enum(_)));
930
931        // Opaque: SignatureEnvelope (xs:any).
932        let sig = graph
933            .types
934            .get("SignatureEnvelope")
935            .expect("SignatureEnvelope missing");
936        assert!(matches!(sig, TypeDef::Opaque(_)));
937    }
938}