Skip to main content

openapi_nexus/generators/rust/common/
emit_models.rs

1//! Sigil-stitch emit for IR schemas (Rust models).
2//!
3//! Each supported `IrSchemaKind` maps to one `models/<name>.rs` file.
4//!
5//! Coverage:
6//! - `Object` — struct with serde derives and `#[serde(rename)]` tags.
7//! - `Enum` — Rust enum with serde rename per variant.
8//! - `Alias` — `pub type X = Y;`
9//! - `Union` — `#[serde(untagged)]` enum.
10//! - `Intersection` — struct with `#[serde(flatten)]` fields.
11//! - `TaggedUnion` — serde-tagged enum (internal/adjacent/external).
12
13use std::collections::HashSet;
14
15use crate::codegen::traits::file_writer::FileInfo;
16use crate::ir::types::{
17    IrEnum, IrEnumValueType, IrIntersection, IrObject, IrPrimitive, IrSchema, IrSchemaKind, IrSpec,
18    IrTaggedUnion, IrTypeExpr, IrUnion, TaggingStyle,
19};
20use heck::{ToPascalCase, ToSnakeCase};
21use sigil_stitch::prelude::{CodeBlock, sigil_quote};
22use sigil_stitch::spec::file_spec::FileSpec;
23use sigil_stitch::spec::import_spec::ImportSpec;
24use sigil_stitch::type_name::TypeName;
25
26use super::config::{ExtraDeriveConfig, RustGeneratorConfig};
27
28/// Generate every model file from the IR.
29pub fn generate_model_files(
30    ir: &IrSpec,
31    header: &str,
32    config: &RustGeneratorConfig,
33) -> Result<Vec<FileInfo>, String> {
34    let mut files = Vec::new();
35    let mut mod_entries = Vec::new();
36
37    // Schemas inlined into Internal/Adjacent tagged unions can be skipped as standalone files,
38    // BUT only if no other schema references them by name (e.g. External/Untagged tuple variants).
39    let inlined_candidates: HashSet<&str> = ir
40        .schemas
41        .values()
42        .filter_map(|s| match &s.kind {
43            IrSchemaKind::TaggedUnion(tu)
44                if matches!(
45                    tu.tagging,
46                    TaggingStyle::Internal | TaggingStyle::Adjacent { .. }
47                ) =>
48            {
49                Some(tu.variants.iter().filter_map(|v| {
50                    if let IrTypeExpr::Named(name) = &v.content_type
51                        && ir
52                            .schemas
53                            .get(name)
54                            .is_some_and(|s| matches!(s.kind, IrSchemaKind::Object(_)))
55                    {
56                        return Some(name.as_str());
57                    }
58                    None
59                }))
60            }
61            _ => None,
62        })
63        .flatten()
64        .collect();
65
66    let referenced_by_name: HashSet<&str> = ir
67        .schemas
68        .values()
69        .flat_map(|s| collect_named_type_refs(s))
70        .collect();
71
72    let inlined_schemas: HashSet<&str> = inlined_candidates
73        .difference(&referenced_by_name)
74        .copied()
75        .collect();
76
77    for (_name, schema) in &ir.schemas {
78        if inlined_schemas.contains(schema.name.as_str()) {
79            continue;
80        }
81        let Some(file_spec) = emit_model_file(schema, config, ir) else {
82            continue;
83        };
84        let stem = schema.name.to_snake_case();
85        let filename = format!("{stem}.rs");
86        mod_entries.push(stem);
87
88        let rendered = file_spec
89            .render(100)
90            .map_err(|e| format!("render error for {}: {e}", schema.name))?;
91
92        let mut content = String::with_capacity(header.len() + rendered.len());
93        content.push_str(header);
94        content.push_str(&rendered);
95        files.push(FileInfo::model(filename, content));
96    }
97
98    // mod.rs that re-exports all model modules
99    let mut mod_content = String::from(header);
100    for entry in &mod_entries {
101        mod_content.push_str(&format!("mod {entry};\npub use {entry}::*;\n"));
102    }
103    files.push(FileInfo::model("mod.rs".to_string(), mod_content));
104
105    Ok(files)
106}
107
108fn emit_model_file(
109    schema: &IrSchema,
110    config: &RustGeneratorConfig,
111    ir: &IrSpec,
112) -> Option<FileSpec> {
113    let extra = config.extra_derives.as_ref();
114    let per_type_cfg = extra
115        .and_then(|e| e.per_type.as_ref())
116        .and_then(|m| m.get(&schema.name));
117    match &schema.kind {
118        IrSchemaKind::Object(obj) => {
119            let derives = per_type_cfg.or_else(|| extra.and_then(|e| e.structs.as_ref()));
120            emit_object(schema, obj, derives)
121        }
122        IrSchemaKind::Enum(en) => {
123            let derives = per_type_cfg.or_else(|| extra.and_then(|e| e.enums.as_ref()));
124            emit_enum(schema, en, derives)
125        }
126        IrSchemaKind::Alias(expr) => {
127            let derives = per_type_cfg.or_else(|| extra.and_then(|e| e.structs.as_ref()));
128            emit_alias(schema, expr, derives)
129        }
130        IrSchemaKind::Union(u) => emit_union(schema, u, per_type_cfg),
131        IrSchemaKind::Intersection(i) => {
132            let derives = per_type_cfg.or_else(|| extra.and_then(|e| e.structs.as_ref()));
133            emit_intersection(schema, i, derives)
134        }
135        IrSchemaKind::TaggedUnion(tu) => {
136            let derives = per_type_cfg.or_else(|| extra.and_then(|e| e.unions.as_ref()));
137            emit_tagged_union(schema, tu, derives, ir)
138        }
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Derive attribute helper
144// ---------------------------------------------------------------------------
145
146fn derive_attr(base: &str, extra: Option<&ExtraDeriveConfig>) -> String {
147    match extra {
148        Some(cfg) if !cfg.derives.is_empty() => {
149            format!("#[derive({base}, {})]", cfg.derives.join(", "))
150        }
151        _ => format!("#[derive({base})]"),
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Object -> struct
157// ---------------------------------------------------------------------------
158
159fn emit_object(
160    schema: &IrSchema,
161    obj: &IrObject,
162    extra: Option<&ExtraDeriveConfig>,
163) -> Option<FileSpec> {
164    let name = schema.name.to_pascal_case();
165    let stem = schema.name.to_snake_case();
166
167    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
168    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
169    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
170
171    let body = sigil_quote!(RustLang {
172        $if(schema.description.is_some()) {
173            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
174        }
175        $L(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
176        pub struct $N(name.as_str()) {
177            $for((json_name, prop) in obj.properties.iter()) {
178                $if(prop.description.is_some()) {
179                    $L(doc_comment_block(prop.description.as_deref().unwrap()).trim_end())
180                }
181                $if(escape_rust_keyword(&json_name.to_snake_case()) != *json_name) {
182                    $L(format!("#[serde(rename = \"{json_name}\")]"))
183                }
184                $if(!prop.required || prop.nullable) {
185                    #[serde(skip_serializing_if = "Option::is_none", default)]
186                    $L(format!("pub {}: Option<{}>,", escape_rust_keyword(&json_name.to_snake_case()), rust_type_str_model(&prop.type_expr)))
187                } $else {
188                    $L(format!("pub {}: {},", escape_rust_keyword(&json_name.to_snake_case()), rust_type_str_model(&prop.type_expr)))
189                }
190            }
191            $if(obj.additional_properties.is_some()) {
192                #[serde(flatten)]
193                $L(format!("pub additional_properties: std::collections::HashMap<String, {}>,", rust_type_str_model(obj.additional_properties.as_ref().unwrap())))
194            }
195        }
196    })
197    .ok()?;
198
199    fsb = fsb.add_code(body);
200    fsb.build().ok()
201}
202
203// ---------------------------------------------------------------------------
204// Enum
205// ---------------------------------------------------------------------------
206
207fn emit_enum(
208    schema: &IrSchema,
209    en: &IrEnum,
210    extra: Option<&ExtraDeriveConfig>,
211) -> Option<FileSpec> {
212    let name = schema.name.to_pascal_case();
213
214    match en.value_type {
215        IrEnumValueType::Mixed | IrEnumValueType::Number => {
216            return emit_type_alias_file(schema, "serde_json::Value");
217        }
218        IrEnumValueType::Integer => {
219            return emit_integer_enum(schema, en, extra);
220        }
221        IrEnumValueType::String => {}
222    }
223
224    let stem = schema.name.to_snake_case();
225    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
226    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
227    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
228
229    let mut variants: Vec<(String, String)> = Vec::new();
230    for v in &en.values {
231        let s = v.value.as_str()?;
232        variants.push((s.to_pascal_case(), s.to_string()));
233    }
234
235    let body = sigil_quote!(RustLang {
236        $if(schema.description.is_some()) {
237            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
238        }
239        $L(derive_attr("Debug, Clone, PartialEq, Eq, Serialize, Deserialize", extra))
240        pub enum $N(name.as_str()) {
241            $for((variant, wire) in variants.iter()) {
242                $if(variant != wire) {
243                    $L(format!("#[serde(rename = \"{}\")]", escape_str(wire)))
244                }
245                $L(format!("{variant},"))
246            }
247        }
248    })
249    .ok()?;
250
251    fsb = fsb.add_code(body);
252
253    // Display impl via sigil_quote!
254    if let Some(display_block) = build_string_enum_display(&name, &variants) {
255        fsb = fsb.add_code(display_block);
256    }
257
258    fsb.build().ok()
259}
260
261fn emit_integer_enum(
262    schema: &IrSchema,
263    en: &IrEnum,
264    extra: Option<&ExtraDeriveConfig>,
265) -> Option<FileSpec> {
266    let name = schema.name.to_pascal_case();
267    let stem = schema.name.to_snake_case();
268
269    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
270    fsb = fsb.add_import(ImportSpec::named("serde_repr", "Deserialize_repr"));
271    fsb = fsb.add_import(ImportSpec::named("serde_repr", "Serialize_repr"));
272
273    let int_variants: Vec<(String, i64)> = en
274        .values
275        .iter()
276        .map(|v| {
277            let n = v.value.as_i64()?;
278            let variant_name = if n < 0 {
279                format!("Neg{}", n.unsigned_abs())
280            } else {
281                format!("N{n}")
282            };
283            Some((variant_name, n))
284        })
285        .collect::<Option<Vec<_>>>()?;
286
287    let body = sigil_quote!(RustLang {
288        $if(schema.description.is_some()) {
289            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
290        }
291        $L(derive_attr("Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr", extra))
292        #[repr(i64)]
293        pub enum $N(name.as_str()) {
294            $for((variant_name, n) in int_variants.iter()) {
295                $L(format!("{variant_name} = {n},"))
296            }
297        }
298    })
299    .ok()?;
300
301    fsb = fsb.add_code(body);
302
303    // Display impl via sigil_quote!
304    if let Some(display_block) = build_integer_enum_display(&name) {
305        fsb = fsb.add_code(display_block);
306    }
307
308    fsb.build().ok()
309}
310
311// ---------------------------------------------------------------------------
312// Alias -> pub type
313// ---------------------------------------------------------------------------
314
315fn emit_alias(
316    schema: &IrSchema,
317    expr: &IrTypeExpr,
318    extra: Option<&ExtraDeriveConfig>,
319) -> Option<FileSpec> {
320    if let IrTypeExpr::Named(n) = expr
321        && n.to_pascal_case() == schema.name.to_pascal_case()
322    {
323        return None;
324    }
325    // Skip trivial primitive aliases that shadow Rust builtins (e.g. schema "string" → pub type String = String)
326    if schema.name.to_pascal_case() == rust_type_str_model(expr) {
327        return None;
328    }
329
330    let name = schema.name.to_pascal_case();
331    let stem = schema.name.to_snake_case();
332    let rhs = rust_type_str_model(expr);
333
334    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
335
336    let has_extra = extra.is_some_and(|cfg| !cfg.derives.is_empty());
337
338    if has_extra {
339        fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
340        fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
341
342        let rhs_type = TypeName::raw(&rhs);
343        let block = sigil_quote!(RustLang {
344            $if(schema.description.is_some()) {
345                $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
346            }
347            $L(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
348            #[serde(transparent)]
349            pub struct $N(name.as_str())(pub $T(rhs_type));
350        })
351        .ok()?;
352        fsb = fsb.add_code(block);
353    } else {
354        let rhs_type = TypeName::raw(&rhs);
355        let block = sigil_quote!(RustLang {
356            $if(schema.description.is_some()) {
357                $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
358            }
359            pub type $N(name.as_str()) = $T(rhs_type);
360        })
361        .ok()?;
362        fsb = fsb.add_code(block);
363    }
364
365    fsb.build().ok()
366}
367
368fn emit_type_alias_file(schema: &IrSchema, rhs_str: &str) -> Option<FileSpec> {
369    let name = schema.name.to_pascal_case();
370    let stem = schema.name.to_snake_case();
371
372    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
373
374    let rhs_type = TypeName::raw(rhs_str);
375    let block = sigil_quote!(RustLang {
376        $if(schema.description.is_some()) {
377            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
378        }
379        pub type $N(name.as_str()) = $T(rhs_type);
380    })
381    .ok()?;
382    fsb = fsb.add_code(block);
383
384    fsb.build().ok()
385}
386
387// ---------------------------------------------------------------------------
388// Union -> #[serde(untagged)] enum
389// ---------------------------------------------------------------------------
390
391fn emit_union(
392    schema: &IrSchema,
393    union: &IrUnion,
394    extra: Option<&ExtraDeriveConfig>,
395) -> Option<FileSpec> {
396    let name = schema.name.to_pascal_case();
397    let stem = schema.name.to_snake_case();
398
399    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
400    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
401    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
402
403    let variants: Vec<(String, String)> = union
404        .members
405        .iter()
406        .enumerate()
407        .map(|(i, member)| {
408            let variant_name = union_variant_name(member, i);
409            let rust_type = rust_type_str_model(member);
410            (variant_name, rust_type)
411        })
412        .collect();
413
414    let body = sigil_quote!(RustLang {
415        $if(schema.description.is_some()) {
416            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
417        }
418        $L(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
419        #[serde(untagged)]
420        pub enum $N(name.as_str()) {
421            $for((variant_name, rust_type) in variants.iter()) {
422                $L(format!("{variant_name}({rust_type}),"))
423            }
424        }
425    })
426    .ok()?;
427
428    fsb = fsb.add_code(body);
429    fsb.build().ok()
430}
431
432fn union_variant_name(expr: &IrTypeExpr, index: usize) -> String {
433    match expr {
434        IrTypeExpr::Named(n) => n.to_pascal_case(),
435        IrTypeExpr::Primitive(p) => primitive_variant_name(p),
436        IrTypeExpr::Array(_) => format!("Array{index}"),
437        IrTypeExpr::Map(_) => format!("Map{index}"),
438        _ => format!("Variant{index}"),
439    }
440}
441
442fn primitive_variant_name(p: &IrPrimitive) -> String {
443    match p {
444        IrPrimitive::String | IrPrimitive::StringWithFormat(_) => "String".to_string(),
445        IrPrimitive::Integer | IrPrimitive::IntegerWithFormat(_) => "Integer".to_string(),
446        IrPrimitive::Number | IrPrimitive::NumberWithFormat(_) => "Number".to_string(),
447        IrPrimitive::Boolean => "Boolean".to_string(),
448        IrPrimitive::Binary => "Binary".to_string(),
449        IrPrimitive::Date => "Date".to_string(),
450        IrPrimitive::DateTime => "DateTime".to_string(),
451        IrPrimitive::Uuid => "Uuid".to_string(),
452    }
453}
454
455// ---------------------------------------------------------------------------
456// TaggedUnion -> serde-tagged enum
457// ---------------------------------------------------------------------------
458
459fn emit_tagged_union(
460    schema: &IrSchema,
461    tu: &IrTaggedUnion,
462    extra: Option<&ExtraDeriveConfig>,
463    ir: &IrSpec,
464) -> Option<FileSpec> {
465    if tu.variants.is_empty() {
466        return None;
467    }
468
469    let name = schema.name.to_pascal_case();
470    let stem = schema.name.to_snake_case();
471
472    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
473    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
474    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
475
476    let serde_tag_attr = match &tu.tagging {
477        TaggingStyle::Internal => {
478            format!(
479                "#[serde(tag = \"{}\")]",
480                escape_str(&tu.discriminator_field)
481            )
482        }
483        TaggingStyle::Adjacent { content_field } => {
484            format!(
485                "#[serde(tag = \"{}\", content = \"{}\")]",
486                escape_str(&tu.discriminator_field),
487                escape_str(content_field)
488            )
489        }
490        TaggingStyle::External => String::new(),
491    };
492
493    let inline_fields = matches!(
494        tu.tagging,
495        TaggingStyle::Internal | TaggingStyle::Adjacent { .. }
496    );
497
498    let mut variant_blocks: Vec<String> = Vec::new();
499    for variant in &tu.variants {
500        let variant_name = variant.discriminator_value.to_pascal_case();
501        let mut block = String::new();
502
503        if variant.discriminator_value.to_pascal_case() != variant.discriminator_value {
504            block.push_str(&format!(
505                "#[serde(rename = \"{}\")]\n",
506                escape_str(&variant.discriminator_value)
507            ));
508        }
509
510        if inline_fields {
511            if let Some(obj) = resolve_object(&variant.content_type, ir) {
512                block.push_str(&format!("{variant_name} {{"));
513                for (json_name, prop) in &obj.properties {
514                    let field_name = escape_rust_keyword(&json_name.to_snake_case());
515                    if field_name != *json_name {
516                        block.push_str(&format!("\n    #[serde(rename = \"{json_name}\")]"));
517                    }
518                    if !prop.required || prop.nullable {
519                        block.push_str(
520                            "\n    #[serde(skip_serializing_if = \"Option::is_none\", default)]",
521                        );
522                        block.push_str(&format!(
523                            "\n    {field_name}: Option<{}>,",
524                            rust_type_str_model(&prop.type_expr)
525                        ));
526                    } else {
527                        block.push_str(&format!(
528                            "\n    {field_name}: {},",
529                            rust_type_str_model(&prop.type_expr)
530                        ));
531                    }
532                }
533                if let Some(ap) = &obj.additional_properties {
534                    block.push_str("\n    #[serde(flatten)]");
535                    block.push_str(&format!(
536                        "\n    additional_properties: std::collections::HashMap<String, {}>,",
537                        rust_type_str_model(ap)
538                    ));
539                }
540                block.push_str("\n},");
541            } else {
542                block.push_str(&format!(
543                    "{}({}),",
544                    variant_name,
545                    rust_type_str_model(&variant.content_type)
546                ));
547            }
548        } else {
549            block.push_str(&format!(
550                "{}({}),",
551                variant_name,
552                rust_type_str_model(&variant.content_type)
553            ));
554        }
555
556        variant_blocks.push(block);
557    }
558
559    let body = sigil_quote!(RustLang {
560        $if(schema.description.is_some()) {
561            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
562        }
563        $L(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
564        $if(!serde_tag_attr.is_empty()) {
565            $L(serde_tag_attr.as_str())
566        }
567        pub enum $N(name.as_str()) {
568            $for(block in variant_blocks.iter()) {
569                $L(block.as_str())
570            }
571        }
572    })
573    .ok()?;
574
575    fsb = fsb.add_code(body);
576    fsb.build().ok()
577}
578
579// ---------------------------------------------------------------------------
580// Intersection -> flattened struct
581// ---------------------------------------------------------------------------
582
583fn emit_intersection(
584    schema: &IrSchema,
585    inter: &IrIntersection,
586    extra: Option<&ExtraDeriveConfig>,
587) -> Option<FileSpec> {
588    let name = schema.name.to_pascal_case();
589    let stem = schema.name.to_snake_case();
590
591    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
592    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
593    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
594
595    let fields: Vec<(String, String)> = inter
596        .members
597        .iter()
598        .enumerate()
599        .map(|(i, member)| {
600            let raw_name = match member {
601                IrTypeExpr::Named(n) => n.to_snake_case(),
602                _ => format!("member_{i}"),
603            };
604            let field_name = escape_rust_keyword(&raw_name);
605            let rust_type = rust_type_str_model(member);
606            (field_name, rust_type)
607        })
608        .collect();
609
610    let body = sigil_quote!(RustLang {
611        $if(schema.description.is_some()) {
612            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
613        }
614        $L(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
615        pub struct $N(name.as_str()) {
616            $for((field_name, rust_type) in fields.iter()) {
617                #[serde(flatten)]
618                $L(format!("pub {field_name}: {rust_type},"))
619            }
620        }
621    })
622    .ok()?;
623
624    fsb = fsb.add_code(body);
625    fsb.build().ok()
626}
627
628// ---------------------------------------------------------------------------
629// Display impl helpers
630// ---------------------------------------------------------------------------
631
632fn build_integer_enum_display(name: &str) -> Option<CodeBlock> {
633    sigil_quote!(RustLang {
634        impl std::fmt::Display for $N(name) {
635            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
636                write!(f, "{}", *self as i64)
637            }
638        }
639    })
640    .ok()
641}
642
643fn build_string_enum_display(name: &str, variants: &[(String, String)]) -> Option<CodeBlock> {
644    sigil_quote!(RustLang {
645        impl std::fmt::Display for $N(name) {
646            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647                match self {
648                    $for((variant, wire_value) in variants.iter()) {
649                        $L(format!("{name}::{variant} => write!(f, {wire_value:?}),"))
650                    }
651                }
652            }
653        }
654    })
655    .ok()
656}
657
658// ---------------------------------------------------------------------------
659// Doc comment helpers
660// ---------------------------------------------------------------------------
661
662/// Build a doc-comment string for use with $L interpolation.
663fn doc_comment_block(doc: &str) -> String {
664    let mut out = String::new();
665    for line in doc.lines() {
666        if line.is_empty() {
667            out.push_str("///\n");
668        } else {
669            out.push_str(&format!("/// {line}\n"));
670        }
671    }
672    out
673}
674
675// ---------------------------------------------------------------------------
676// Schema resolution helper
677// ---------------------------------------------------------------------------
678
679fn resolve_object<'a>(expr: &IrTypeExpr, ir: &'a IrSpec) -> Option<&'a IrObject> {
680    if let IrTypeExpr::Named(name) = expr
681        && let Some(schema) = ir.schemas.get(name)
682        && let IrSchemaKind::Object(obj) = &schema.kind
683    {
684        return Some(obj);
685    }
686    None
687}
688
689// ---------------------------------------------------------------------------
690// Type mapping helpers
691// ---------------------------------------------------------------------------
692
693pub fn rust_type_str(expr: &IrTypeExpr) -> String {
694    match expr {
695        IrTypeExpr::Named(name) => name.to_pascal_case(),
696        IrTypeExpr::Primitive(p) => rust_primitive(p).to_string(),
697        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str(inner)),
698        IrTypeExpr::Map(inner) => format!(
699            "std::collections::HashMap<String, {}>",
700            rust_type_str(inner)
701        ),
702        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str(inner)),
703        IrTypeExpr::StringLiteral(_) | IrTypeExpr::StringEnum(_) => "String".to_string(),
704        IrTypeExpr::Union(_) | IrTypeExpr::Any => "serde_json::Value".to_string(),
705    }
706}
707
708/// Map an IR type to a Rust type string, qualified for use from API modules.
709/// Named references to suppressed primitive aliases are resolved to the primitive type.
710pub fn rust_type_str_qualified(expr: &IrTypeExpr, ir: &IrSpec) -> String {
711    match expr {
712        IrTypeExpr::Named(name) => {
713            if let Some(schema) = ir.schemas.get(name)
714                && let IrSchemaKind::Alias(inner) = &schema.kind
715                && name.to_pascal_case() == rust_type_str_model(inner)
716            {
717                return rust_type_str(inner);
718            }
719            format!("crate::models::{}", name.to_pascal_case())
720        }
721        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str_qualified(inner, ir)),
722        IrTypeExpr::Map(inner) => format!(
723            "std::collections::HashMap<String, {}>",
724            rust_type_str_qualified(inner, ir)
725        ),
726        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str_qualified(inner, ir)),
727        other => rust_type_str(other),
728    }
729}
730
731/// Map an IR type for use within model files (sibling references use `super::`).
732fn rust_type_str_model(expr: &IrTypeExpr) -> String {
733    match expr {
734        IrTypeExpr::Named(name) => format!("super::{}", name.to_pascal_case()),
735        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str_model(inner)),
736        IrTypeExpr::Map(inner) => format!(
737            "std::collections::HashMap<String, {}>",
738            rust_type_str_model(inner)
739        ),
740        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str_model(inner)),
741        other => rust_type_str(other),
742    }
743}
744
745fn rust_primitive(p: &IrPrimitive) -> &'static str {
746    match p {
747        IrPrimitive::String
748        | IrPrimitive::Date
749        | IrPrimitive::DateTime
750        | IrPrimitive::Uuid
751        | IrPrimitive::StringWithFormat(_) => "String",
752        IrPrimitive::Binary => "Vec<u8>",
753        IrPrimitive::Integer => "i64",
754        IrPrimitive::IntegerWithFormat(format) => match format.as_str() {
755            "int32" => "i32",
756            "int64" => "i64",
757            "uint32" => "u32",
758            "uint64" => "u64",
759            _ => "i64",
760        },
761        IrPrimitive::Number => "f64",
762        IrPrimitive::NumberWithFormat(format) => match format.as_str() {
763            "float" => "f32",
764            _ => "f64",
765        },
766        IrPrimitive::Boolean => "bool",
767    }
768}
769
770fn escape_str(s: &str) -> String {
771    s.replace('\\', "\\\\").replace('"', "\\\"")
772}
773
774fn escape_rust_keyword(name: &str) -> String {
775    const KEYWORDS: &[&str] = &[
776        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
777        "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",
778        "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",
779        "true", "type", "union", "unsafe", "use", "where", "while", "yield",
780    ];
781    if KEYWORDS.contains(&name) {
782        format!("r#{name}")
783    } else {
784        name.to_string()
785    }
786}
787
788/// Collect schema names referenced by `IrTypeExpr::Named` in positions that require
789/// a standalone struct to exist (object fields, tuple variants, union members, etc.).
790fn collect_named_type_refs(schema: &IrSchema) -> Vec<&str> {
791    let mut refs = Vec::new();
792    match &schema.kind {
793        IrSchemaKind::Object(obj) => {
794            for prop in obj.properties.values() {
795                collect_named_from_expr(&prop.type_expr, &mut refs);
796            }
797            if let Some(ap) = &obj.additional_properties {
798                collect_named_from_expr(ap, &mut refs);
799            }
800        }
801        IrSchemaKind::TaggedUnion(tu) => {
802            let uses_tuple_variants = matches!(tu.tagging, TaggingStyle::External);
803            if uses_tuple_variants {
804                for v in &tu.variants {
805                    collect_named_from_expr(&v.content_type, &mut refs);
806                }
807            }
808        }
809        IrSchemaKind::Union(u) => {
810            for member in &u.members {
811                collect_named_from_expr(member, &mut refs);
812            }
813        }
814        IrSchemaKind::Alias(expr) => {
815            collect_named_from_expr(expr, &mut refs);
816        }
817        IrSchemaKind::Intersection(inter) => {
818            for member in &inter.members {
819                collect_named_from_expr(member, &mut refs);
820            }
821        }
822        IrSchemaKind::Enum(_) => {}
823    }
824    refs
825}
826
827fn collect_named_from_expr<'a>(expr: &'a IrTypeExpr, refs: &mut Vec<&'a str>) {
828    match expr {
829        IrTypeExpr::Named(name) => refs.push(name.as_str()),
830        IrTypeExpr::Array(inner) | IrTypeExpr::Map(inner) | IrTypeExpr::Nullable(inner) => {
831            collect_named_from_expr(inner, refs);
832        }
833        IrTypeExpr::Union(members) => {
834            for m in members {
835                collect_named_from_expr(m, refs);
836            }
837        }
838        _ => {}
839    }
840}