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