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        $attr(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                    #[serde(rename = $S(json_name))]
230                }
231                $if(!prop.required || prop.nullable) {
232                    #[serde(skip_serializing_if = "Option::is_none", default)]
233                    pub $L("@{escape_rust_keyword(&json_name.to_snake_case())}: Option<@{rust_type_str_model(&prop.type_expr)}>,")
234                } $else {
235                    pub $L("@{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                pub $L("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        $attr(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                    #[serde(rename = $S(wire))]
291                }
292                $N(variant.as_str()),
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        $attr(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                $N(variant_name.as_str()) = $L(n.to_string()),
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            $attr(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        $attr(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                $N(variant_name.as_str())($L(rust_type.as_str())),
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!("serde(tag = \"{}\")", escape_str(&tu.discriminator_field))
538        }
539        TaggingStyle::Adjacent { content_field } => {
540            format!(
541                "serde(tag = \"{}\", content = \"{}\")",
542                escape_str(&tu.discriminator_field),
543                escape_str(content_field)
544            )
545        }
546        TaggingStyle::External => String::new(),
547    };
548
549    let inline_fields = matches!(
550        tu.tagging,
551        TaggingStyle::Internal | TaggingStyle::Adjacent { .. }
552    );
553
554    let mut variant_blocks: Vec<CodeBlock> = Vec::new();
555    for variant in &tu.variants {
556        let variant_name = variant.discriminator_value.to_pascal_case();
557
558        let rename_attr = if variant_name != variant.discriminator_value {
559            format!(
560                "serde(rename = \"{}\")",
561                escape_str(&variant.discriminator_value)
562            )
563        } else {
564            String::new()
565        };
566
567        let variant_block = if inline_fields {
568            if let Some(obj) = resolve_object(&variant.content_type, ir) {
569                let non_tag_fields: Vec<_> = obj
570                    .properties
571                    .iter()
572                    .filter(|(json_name, _)| *json_name != &tu.discriminator_field)
573                    .collect();
574                let has_additional = obj.additional_properties.is_some();
575
576                if non_tag_fields.is_empty() && !has_additional {
577                    sigil_quote!(RustLang {
578                        $if(!rename_attr.is_empty()) {
579                            $attr(rename_attr.as_str())
580                        }
581                        $N(variant_name.as_str()),
582                    })
583                    .ok()?
584                } else {
585                    let field_blocks: Vec<CodeBlock> = non_tag_fields
586                        .iter()
587                        .map(|(json_name, prop)| {
588                            let field_name = escape_rust_keyword(&json_name.to_snake_case());
589                            let ty = rust_type_str_model(&prop.type_expr);
590                            let needs_rename = field_name != **json_name;
591                            let optional = !prop.required || prop.nullable;
592                            sigil_quote!(RustLang {
593                                $if(needs_rename) {
594                                    #[serde(rename = $S(json_name.as_str()))]
595                                }
596                                $if(optional) {
597                                    #[serde(skip_serializing_if = "Option::is_none", default)]
598                                    $L(format!("{field_name}: Option<{ty}>,"))
599                                }
600                                $if(!optional) {
601                                    $L(format!("{field_name}: {ty},"))
602                                }
603                            })
604                            .ok()
605                        })
606                        .collect::<Option<_>>()?;
607
608                    let additional_blocks: Vec<CodeBlock> = if let Some(ap) =
609                        &obj.additional_properties
610                    {
611                        let ty = rust_type_str_model(ap);
612                        vec![sigil_quote!(RustLang {
613                                #[serde(flatten)]
614                                $L(format!("additional_properties: std::collections::HashMap<String, {ty}>,"))
615                            })
616                            .ok()?]
617                    } else {
618                        vec![]
619                    };
620
621                    let mut cb = CodeBlock::builder();
622                    if !rename_attr.is_empty() {
623                        cb.add(&format!("#[{rename_attr}]"), ());
624                        cb.add_line();
625                    }
626                    cb.add(&format!("{variant_name} {{\n%>"), ());
627                    for fb in &field_blocks {
628                        cb.add_code(fb.clone());
629                    }
630                    for ab in &additional_blocks {
631                        cb.add_code(ab.clone());
632                    }
633                    cb.add("%<},", ());
634                    cb.build().ok()?
635                }
636            } else {
637                let content_ty = rust_type_str_model(&variant.content_type);
638                sigil_quote!(RustLang {
639                    $if(!rename_attr.is_empty()) {
640                        $attr(rename_attr.as_str())
641                    }
642                    $N(variant_name.as_str())($L(content_ty.as_str())),
643                })
644                .ok()?
645            }
646        } else {
647            let content_ty = rust_type_str_model(&variant.content_type);
648            sigil_quote!(RustLang {
649                $if(!rename_attr.is_empty()) {
650                    $attr(rename_attr.as_str())
651                }
652                $N(variant_name.as_str())($L(content_ty.as_str())),
653            })
654            .ok()?
655        };
656
657        variant_blocks.push(variant_block);
658    }
659
660    let body = sigil_quote!(RustLang {
661        $if(schema.description.is_some()) {
662            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
663        }
664        $attr(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
665        $if(!serde_tag_attr.is_empty()) {
666            $attr(serde_tag_attr.as_str())
667        }
668        pub enum $N(name.as_str()) {
669            $C_each(variant_blocks);
670        }
671    })
672    .ok()?;
673
674    fsb = fsb.add_code(body);
675
676    if utoipa_enabled {
677        let variant_types: Vec<String> = tu
678            .variants
679            .iter()
680            .map(|v| rust_type_str_model(&v.content_type))
681            .collect();
682        let impl_block = build_utoipa_one_of_impl(&name, &variant_types)?;
683        fsb = fsb.add_code(impl_block);
684    }
685
686    fsb.build().ok()
687}
688
689// ---------------------------------------------------------------------------
690// Intersection -> flattened struct
691// ---------------------------------------------------------------------------
692
693fn emit_intersection(
694    schema: &IrSchema,
695    inter: &IrIntersection,
696    extra: Option<&ExtraDeriveConfig>,
697) -> Option<FileSpec> {
698    let name = schema.name.to_pascal_case();
699    let stem = schema.name.to_snake_case();
700
701    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
702    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
703    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
704
705    let fields: Vec<(String, String)> = inter
706        .members
707        .iter()
708        .enumerate()
709        .map(|(i, member)| {
710            let raw_name = match member {
711                IrTypeExpr::Named(n) => n.to_snake_case(),
712                _ => format!("member_{i}"),
713            };
714            let field_name = escape_rust_keyword(&raw_name);
715            let rust_type = rust_type_str_model(member);
716            (field_name, rust_type)
717        })
718        .collect();
719
720    let body = sigil_quote!(RustLang {
721        $if(schema.description.is_some()) {
722            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
723        }
724        $attr(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
725        pub struct $N(name.as_str()) {
726            $for((field_name, rust_type) in fields.iter()) {
727                #[serde(flatten)]
728                pub $N(field_name.as_str()): $L(rust_type.as_str()),
729            }
730        }
731    })
732    .ok()?;
733
734    fsb = fsb.add_code(body);
735    fsb.build().ok()
736}
737
738// ---------------------------------------------------------------------------
739// Utoipa impl helpers
740// ---------------------------------------------------------------------------
741
742fn build_utoipa_one_of_impl(name: &str, variant_types: &[String]) -> Option<CodeBlock> {
743    let items: Vec<String> = variant_types
744        .iter()
745        .map(|rt| format!("    .item(<{rt} as utoipa::PartialSchema>::schema())"))
746        .collect();
747
748    sigil_quote!(RustLang {
749        impl utoipa::PartialSchema for $N(name) {
750            fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::schema::Schema> {
751                $L("utoipa::openapi::schema::Schema::OneOf(utoipa::openapi::schema::OneOfBuilder::new()")
752                $for(item in items.iter()) {
753                    $L(item.as_str())
754                }
755                $L("    .build()).into()")
756            }
757        }
758
759        impl utoipa::ToSchema for $N(name) {
760            fn name() -> std::borrow::Cow<'static, str> {
761                std::borrow::Cow::Borrowed($S(name))
762            }
763        }
764    })
765    .ok()
766}
767
768// ---------------------------------------------------------------------------
769// Display impl helpers
770// ---------------------------------------------------------------------------
771
772fn build_integer_enum_display(name: &str) -> Option<CodeBlock> {
773    sigil_quote!(RustLang {
774        impl std::fmt::Display for $N(name) {
775            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
776                write!(f, "{}", *self as i64)
777            }
778        }
779    })
780    .ok()
781}
782
783fn build_string_enum_display(name: &str, variants: &[(String, String)]) -> Option<CodeBlock> {
784    sigil_quote!(RustLang {
785        impl std::fmt::Display for $N(name) {
786            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
787                match self {
788                    $for((variant, wire_value) in variants.iter()) {
789                        $N(name)::$N(variant.as_str()) => write!(f, $S(wire_value)),
790                    }
791                }
792            }
793        }
794    })
795    .ok()
796}
797
798// ---------------------------------------------------------------------------
799// Doc comment helpers
800// ---------------------------------------------------------------------------
801
802/// Build a doc-comment string for use with $L interpolation.
803fn doc_comment_block(doc: &str) -> String {
804    let mut out = String::new();
805    for line in doc.lines() {
806        if line.is_empty() {
807            out.push_str("///\n");
808        } else {
809            out.push_str(&format!("/// {line}\n"));
810        }
811    }
812    out
813}
814
815// ---------------------------------------------------------------------------
816// Schema resolution helper
817// ---------------------------------------------------------------------------
818
819fn resolve_object<'a>(expr: &IrTypeExpr, ir: &'a IrSpec) -> Option<&'a IrObject> {
820    if let IrTypeExpr::Named(name) = expr
821        && let Some(schema) = ir.schemas.get(name)
822        && let IrSchemaKind::Object(obj) = &schema.kind
823    {
824        return Some(obj);
825    }
826    None
827}
828
829// ---------------------------------------------------------------------------
830// Type mapping helpers
831// ---------------------------------------------------------------------------
832
833pub fn rust_type_str(expr: &IrTypeExpr) -> String {
834    match expr {
835        IrTypeExpr::Named(name) => name.to_pascal_case(),
836        IrTypeExpr::Primitive(p) => rust_primitive(p).to_string(),
837        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str(inner)),
838        IrTypeExpr::Map(inner) => format!(
839            "std::collections::HashMap<String, {}>",
840            rust_type_str(inner)
841        ),
842        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str(inner)),
843        IrTypeExpr::StringLiteral(_) | IrTypeExpr::StringEnum(_) => "String".to_string(),
844        IrTypeExpr::Union(_) | IrTypeExpr::Any => "serde_json::Value".to_string(),
845    }
846}
847
848/// Map an IR type to a Rust type string, qualified for use from API modules.
849/// Named references to suppressed primitive aliases are resolved to the primitive type.
850pub fn rust_type_str_qualified(expr: &IrTypeExpr, ir: &IrSpec) -> String {
851    match expr {
852        IrTypeExpr::Named(name) => {
853            if let Some(schema) = ir.schemas.get(name)
854                && let IrSchemaKind::Alias(inner) = &schema.kind
855                && name.to_pascal_case() == rust_type_str_model(inner)
856            {
857                return rust_type_str(inner);
858            }
859            format!("crate::models::{}", name.to_pascal_case())
860        }
861        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str_qualified(inner, ir)),
862        IrTypeExpr::Map(inner) => format!(
863            "std::collections::HashMap<String, {}>",
864            rust_type_str_qualified(inner, ir)
865        ),
866        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str_qualified(inner, ir)),
867        other => rust_type_str(other),
868    }
869}
870
871/// Map an IR type for use within model files (sibling references use `super::`).
872fn rust_type_str_model(expr: &IrTypeExpr) -> String {
873    match expr {
874        IrTypeExpr::Named(name) => format!("super::{}", name.to_pascal_case()),
875        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str_model(inner)),
876        IrTypeExpr::Map(inner) => format!(
877            "std::collections::HashMap<String, {}>",
878            rust_type_str_model(inner)
879        ),
880        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str_model(inner)),
881        other => rust_type_str(other),
882    }
883}
884
885fn rust_primitive(p: &IrPrimitive) -> &'static str {
886    match p {
887        IrPrimitive::String
888        | IrPrimitive::Date
889        | IrPrimitive::DateTime
890        | IrPrimitive::Uuid
891        | IrPrimitive::StringWithFormat(_) => "String",
892        IrPrimitive::Binary => "Vec<u8>",
893        IrPrimitive::Integer => "i64",
894        IrPrimitive::IntegerWithFormat(format) => match format.as_str() {
895            "int32" => "i32",
896            "int64" => "i64",
897            "uint32" => "u32",
898            "uint64" => "u64",
899            _ => "i64",
900        },
901        IrPrimitive::Number => "f64",
902        IrPrimitive::NumberWithFormat(format) => match format.as_str() {
903            "float" => "f32",
904            _ => "f64",
905        },
906        IrPrimitive::Boolean => "bool",
907    }
908}
909
910fn escape_str(s: &str) -> String {
911    s.replace('\\', "\\\\").replace('"', "\\\"")
912}
913
914fn escape_rust_keyword(name: &str) -> String {
915    const KEYWORDS: &[&str] = &[
916        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
917        "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",
918        "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",
919        "true", "type", "union", "unsafe", "use", "where", "while", "yield",
920    ];
921    if KEYWORDS.contains(&name) {
922        format!("r#{name}")
923    } else {
924        name.to_string()
925    }
926}
927
928/// Collect schema names referenced by `IrTypeExpr::Named` in positions that require
929/// a standalone struct to exist (object fields, tuple variants, union members, etc.).
930fn collect_named_type_refs(schema: &IrSchema) -> Vec<&str> {
931    let mut refs = Vec::new();
932    match &schema.kind {
933        IrSchemaKind::Object(obj) => {
934            for prop in obj.properties.values() {
935                collect_named_from_expr(&prop.type_expr, &mut refs);
936            }
937            if let Some(ap) = &obj.additional_properties {
938                collect_named_from_expr(ap, &mut refs);
939            }
940        }
941        IrSchemaKind::TaggedUnion(tu) => {
942            let uses_tuple_variants = matches!(tu.tagging, TaggingStyle::External);
943            if uses_tuple_variants {
944                for v in &tu.variants {
945                    collect_named_from_expr(&v.content_type, &mut refs);
946                }
947            }
948        }
949        IrSchemaKind::Union(u) => {
950            for member in &u.members {
951                collect_named_from_expr(member, &mut refs);
952            }
953        }
954        IrSchemaKind::Alias(expr) => {
955            collect_named_from_expr(expr, &mut refs);
956        }
957        IrSchemaKind::Intersection(inter) => {
958            for member in &inter.members {
959                collect_named_from_expr(member, &mut refs);
960            }
961        }
962        IrSchemaKind::Enum(_) => {}
963    }
964    refs
965}
966
967fn collect_named_from_expr<'a>(expr: &'a IrTypeExpr, refs: &mut Vec<&'a str>) {
968    match expr {
969        IrTypeExpr::Named(name) => refs.push(name.as_str()),
970        IrTypeExpr::Array(inner) | IrTypeExpr::Map(inner) | IrTypeExpr::Nullable(inner) => {
971            collect_named_from_expr(inner, refs);
972        }
973        IrTypeExpr::Union(members) => {
974            for m in members {
975                collect_named_from_expr(m, refs);
976            }
977        }
978        _ => {}
979    }
980}