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.
709pub fn rust_type_str_qualified(expr: &IrTypeExpr) -> String {
710    match expr {
711        IrTypeExpr::Named(name) => format!("crate::models::{}", name.to_pascal_case()),
712        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str_qualified(inner)),
713        IrTypeExpr::Map(inner) => format!(
714            "std::collections::HashMap<String, {}>",
715            rust_type_str_qualified(inner)
716        ),
717        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str_qualified(inner)),
718        other => rust_type_str(other),
719    }
720}
721
722/// Map an IR type for use within model files (sibling references use `super::`).
723fn rust_type_str_model(expr: &IrTypeExpr) -> String {
724    match expr {
725        IrTypeExpr::Named(name) => format!("super::{}", name.to_pascal_case()),
726        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str_model(inner)),
727        IrTypeExpr::Map(inner) => format!(
728            "std::collections::HashMap<String, {}>",
729            rust_type_str_model(inner)
730        ),
731        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str_model(inner)),
732        other => rust_type_str(other),
733    }
734}
735
736fn rust_primitive(p: &IrPrimitive) -> &'static str {
737    match p {
738        IrPrimitive::String
739        | IrPrimitive::Date
740        | IrPrimitive::DateTime
741        | IrPrimitive::Uuid
742        | IrPrimitive::StringWithFormat(_) => "String",
743        IrPrimitive::Binary => "Vec<u8>",
744        IrPrimitive::Integer => "i64",
745        IrPrimitive::IntegerWithFormat(format) => match format.as_str() {
746            "int32" => "i32",
747            "int64" => "i64",
748            "uint32" => "u32",
749            "uint64" => "u64",
750            _ => "i64",
751        },
752        IrPrimitive::Number => "f64",
753        IrPrimitive::NumberWithFormat(format) => match format.as_str() {
754            "float" => "f32",
755            _ => "f64",
756        },
757        IrPrimitive::Boolean => "bool",
758    }
759}
760
761fn escape_str(s: &str) -> String {
762    s.replace('\\', "\\\\").replace('"', "\\\"")
763}
764
765fn escape_rust_keyword(name: &str) -> String {
766    const KEYWORDS: &[&str] = &[
767        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
768        "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",
769        "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",
770        "true", "type", "union", "unsafe", "use", "where", "while", "yield",
771    ];
772    if KEYWORDS.contains(&name) {
773        format!("r#{name}")
774    } else {
775        name.to_string()
776    }
777}
778
779/// Collect schema names referenced by `IrTypeExpr::Named` in positions that require
780/// a standalone struct to exist (object fields, tuple variants, union members, etc.).
781fn collect_named_type_refs(schema: &IrSchema) -> Vec<&str> {
782    let mut refs = Vec::new();
783    match &schema.kind {
784        IrSchemaKind::Object(obj) => {
785            for prop in obj.properties.values() {
786                collect_named_from_expr(&prop.type_expr, &mut refs);
787            }
788            if let Some(ap) = &obj.additional_properties {
789                collect_named_from_expr(ap, &mut refs);
790            }
791        }
792        IrSchemaKind::TaggedUnion(tu) => {
793            let uses_tuple_variants = matches!(tu.tagging, TaggingStyle::External);
794            if uses_tuple_variants {
795                for v in &tu.variants {
796                    collect_named_from_expr(&v.content_type, &mut refs);
797                }
798            }
799        }
800        IrSchemaKind::Union(u) => {
801            for member in &u.members {
802                collect_named_from_expr(member, &mut refs);
803            }
804        }
805        IrSchemaKind::Alias(expr) => {
806            collect_named_from_expr(expr, &mut refs);
807        }
808        IrSchemaKind::Intersection(inter) => {
809            for member in &inter.members {
810                collect_named_from_expr(member, &mut refs);
811            }
812        }
813        IrSchemaKind::Enum(_) => {}
814    }
815    refs
816}
817
818fn collect_named_from_expr<'a>(expr: &'a IrTypeExpr, refs: &mut Vec<&'a str>) {
819    match expr {
820        IrTypeExpr::Named(name) => refs.push(name.as_str()),
821        IrTypeExpr::Array(inner) | IrTypeExpr::Map(inner) | IrTypeExpr::Nullable(inner) => {
822            collect_named_from_expr(inner, refs);
823        }
824        IrTypeExpr::Union(members) => {
825            for m in members {
826                collect_named_from_expr(m, refs);
827            }
828        }
829        _ => {}
830    }
831}