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