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