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 crate::codegen::traits::file_writer::FileInfo;
14use crate::ir::types::{
15    IrEnum, IrEnumValueType, IrIntersection, IrObject, IrPrimitive, IrSchema, IrSchemaKind, IrSpec,
16    IrTaggedUnion, IrTypeExpr, IrUnion, TaggingStyle,
17};
18use heck::{ToPascalCase, ToSnakeCase};
19use sigil_stitch::prelude::{CodeBlock, sigil_quote};
20use sigil_stitch::spec::file_spec::FileSpec;
21use sigil_stitch::spec::import_spec::ImportSpec;
22use sigil_stitch::type_name::TypeName;
23
24use super::config::{ExtraDeriveConfig, RustGeneratorConfig};
25
26/// Generate every model file from the IR.
27pub fn generate_model_files(
28    ir: &IrSpec,
29    header: &str,
30    config: &RustGeneratorConfig,
31) -> Result<Vec<FileInfo>, String> {
32    let mut files = Vec::new();
33    let mut mod_entries = Vec::new();
34
35    for (_name, schema) in &ir.schemas {
36        let Some(file_spec) = emit_model_file(schema, config) else {
37            return Err(format!(
38                "unsupported schema kind for {}: {:?}",
39                schema.name, schema.kind
40            ));
41        };
42        let stem = schema.name.to_snake_case();
43        let filename = format!("{stem}.rs");
44        mod_entries.push(stem);
45
46        let rendered = file_spec
47            .render(100)
48            .map_err(|e| format!("render error for {}: {e}", schema.name))?;
49
50        let mut content = String::with_capacity(header.len() + rendered.len());
51        content.push_str(header);
52        content.push_str(&rendered);
53        files.push(FileInfo::model(filename, content));
54    }
55
56    // mod.rs that re-exports all model modules
57    let mut mod_content = String::from(header);
58    for entry in &mod_entries {
59        mod_content.push_str(&format!("mod {entry};\npub use {entry}::*;\n"));
60    }
61    files.push(FileInfo::model("mod.rs".to_string(), mod_content));
62
63    Ok(files)
64}
65
66fn emit_model_file(schema: &IrSchema, config: &RustGeneratorConfig) -> Option<FileSpec> {
67    let extra = config.extra_derives.as_ref();
68    match &schema.kind {
69        IrSchemaKind::Object(obj) => {
70            emit_object(schema, obj, extra.and_then(|e| e.structs.as_ref()))
71        }
72        IrSchemaKind::Enum(en) => emit_enum(schema, en, extra.and_then(|e| e.enums.as_ref())),
73        IrSchemaKind::Alias(expr) => emit_alias(schema, expr),
74        IrSchemaKind::Union(u) => emit_union(schema, u, extra.and_then(|e| e.unions.as_ref())),
75        IrSchemaKind::Intersection(i) => {
76            emit_intersection(schema, i, extra.and_then(|e| e.structs.as_ref()))
77        }
78        IrSchemaKind::TaggedUnion(tu) => {
79            emit_tagged_union(schema, tu, extra.and_then(|e| e.unions.as_ref()))
80        }
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Derive attribute helper
86// ---------------------------------------------------------------------------
87
88fn derive_attr(base: &str, extra: Option<&ExtraDeriveConfig>) -> String {
89    match extra {
90        Some(cfg) if !cfg.derives.is_empty() => {
91            format!("#[derive({base}, {})]", cfg.derives.join(", "))
92        }
93        _ => format!("#[derive({base})]"),
94    }
95}
96
97// ---------------------------------------------------------------------------
98// Object -> struct
99// ---------------------------------------------------------------------------
100
101fn emit_object(
102    schema: &IrSchema,
103    obj: &IrObject,
104    extra: Option<&ExtraDeriveConfig>,
105) -> Option<FileSpec> {
106    let name = schema.name.to_pascal_case();
107    let stem = schema.name.to_snake_case();
108
109    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
110    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
111    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
112
113    let body = sigil_quote!(RustLang {
114        $if(schema.description.is_some()) {
115            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
116        }
117        $L(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
118        pub struct $N(name.as_str()) {
119            $for((json_name, prop) in obj.properties.iter()) {
120                $if(prop.description.is_some()) {
121                    $L(doc_comment_block(prop.description.as_deref().unwrap()).trim_end())
122                }
123                $if(escape_rust_keyword(&json_name.to_snake_case()) != *json_name) {
124                    $L(format!("#[serde(rename = \"{json_name}\")]"))
125                }
126                $if(!prop.required || prop.nullable) {
127                    #[serde(skip_serializing_if = "Option::is_none", default)]
128                    $L(format!("pub {}: Option<{}>,", escape_rust_keyword(&json_name.to_snake_case()), rust_type_str_model(&prop.type_expr)))
129                } $else {
130                    $L(format!("pub {}: {},", escape_rust_keyword(&json_name.to_snake_case()), rust_type_str_model(&prop.type_expr)))
131                }
132            }
133            $if(obj.additional_properties.is_some()) {
134                #[serde(flatten)]
135                $L(format!("pub additional_properties: std::collections::HashMap<String, {}>,", rust_type_str_model(obj.additional_properties.as_ref().unwrap())))
136            }
137        }
138    })
139    .ok()?;
140
141    fsb = fsb.add_code(body);
142    fsb.build().ok()
143}
144
145// ---------------------------------------------------------------------------
146// Enum
147// ---------------------------------------------------------------------------
148
149fn emit_enum(
150    schema: &IrSchema,
151    en: &IrEnum,
152    extra: Option<&ExtraDeriveConfig>,
153) -> Option<FileSpec> {
154    let name = schema.name.to_pascal_case();
155
156    match en.value_type {
157        IrEnumValueType::Mixed | IrEnumValueType::Number => {
158            return emit_type_alias_file(schema, "serde_json::Value");
159        }
160        IrEnumValueType::Integer => {
161            return emit_integer_enum(schema, en, extra);
162        }
163        IrEnumValueType::String => {}
164    }
165
166    let stem = schema.name.to_snake_case();
167    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
168    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
169    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
170
171    let mut variants: Vec<(String, String)> = Vec::new();
172    for v in &en.values {
173        let s = v.value.as_str()?;
174        variants.push((s.to_pascal_case(), s.to_string()));
175    }
176
177    let body = sigil_quote!(RustLang {
178        $if(schema.description.is_some()) {
179            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
180        }
181        $L(derive_attr("Debug, Clone, PartialEq, Eq, Serialize, Deserialize", extra))
182        pub enum $N(name.as_str()) {
183            $for((variant, wire) in variants.iter()) {
184                $if(variant != wire) {
185                    $L(format!("#[serde(rename = \"{}\")]", escape_str(wire)))
186                }
187                $L(format!("{variant},"))
188            }
189        }
190    })
191    .ok()?;
192
193    fsb = fsb.add_code(body);
194
195    // Display impl via sigil_quote!
196    if let Some(display_block) = build_string_enum_display(&name, &variants) {
197        fsb = fsb.add_code(display_block);
198    }
199
200    fsb.build().ok()
201}
202
203fn emit_integer_enum(
204    schema: &IrSchema,
205    en: &IrEnum,
206    extra: Option<&ExtraDeriveConfig>,
207) -> Option<FileSpec> {
208    let name = schema.name.to_pascal_case();
209    let stem = schema.name.to_snake_case();
210
211    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
212    fsb = fsb.add_import(ImportSpec::named("serde_repr", "Deserialize_repr"));
213    fsb = fsb.add_import(ImportSpec::named("serde_repr", "Serialize_repr"));
214
215    let int_variants: Vec<(String, i64)> = en
216        .values
217        .iter()
218        .map(|v| {
219            let n = v.value.as_i64()?;
220            let variant_name = if n < 0 {
221                format!("Neg{}", n.unsigned_abs())
222            } else {
223                format!("N{n}")
224            };
225            Some((variant_name, n))
226        })
227        .collect::<Option<Vec<_>>>()?;
228
229    let body = sigil_quote!(RustLang {
230        $if(schema.description.is_some()) {
231            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
232        }
233        $L(derive_attr("Debug, Clone, Copy, PartialEq, Eq, Serialize_repr, Deserialize_repr", extra))
234        #[repr(i64)]
235        pub enum $N(name.as_str()) {
236            $for((variant_name, n) in int_variants.iter()) {
237                $L(format!("{variant_name} = {n},"))
238            }
239        }
240    })
241    .ok()?;
242
243    fsb = fsb.add_code(body);
244
245    // Display impl via sigil_quote!
246    if let Some(display_block) = build_integer_enum_display(&name) {
247        fsb = fsb.add_code(display_block);
248    }
249
250    fsb.build().ok()
251}
252
253// ---------------------------------------------------------------------------
254// Alias -> pub type
255// ---------------------------------------------------------------------------
256
257fn emit_alias(schema: &IrSchema, expr: &IrTypeExpr) -> Option<FileSpec> {
258    let name = schema.name.to_pascal_case();
259    let stem = schema.name.to_snake_case();
260    let rhs = rust_type_str_model(expr);
261    let rhs_type = TypeName::raw(&rhs);
262
263    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
264
265    let block = sigil_quote!(RustLang {
266        $if(schema.description.is_some()) {
267            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
268        }
269        pub type $N(name.as_str()) = $T(rhs_type);
270    })
271    .ok()?;
272    fsb = fsb.add_code(block);
273
274    fsb.build().ok()
275}
276
277fn emit_type_alias_file(schema: &IrSchema, rhs_str: &str) -> Option<FileSpec> {
278    let name = schema.name.to_pascal_case();
279    let stem = schema.name.to_snake_case();
280
281    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
282
283    let rhs_type = TypeName::raw(rhs_str);
284    let block = sigil_quote!(RustLang {
285        $if(schema.description.is_some()) {
286            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
287        }
288        pub type $N(name.as_str()) = $T(rhs_type);
289    })
290    .ok()?;
291    fsb = fsb.add_code(block);
292
293    fsb.build().ok()
294}
295
296// ---------------------------------------------------------------------------
297// Union -> #[serde(untagged)] enum
298// ---------------------------------------------------------------------------
299
300fn emit_union(
301    schema: &IrSchema,
302    union: &IrUnion,
303    extra: Option<&ExtraDeriveConfig>,
304) -> Option<FileSpec> {
305    let name = schema.name.to_pascal_case();
306    let stem = schema.name.to_snake_case();
307
308    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
309    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
310    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
311
312    let variants: Vec<(String, String)> = union
313        .members
314        .iter()
315        .enumerate()
316        .map(|(i, member)| {
317            let variant_name = union_variant_name(member, i);
318            let rust_type = rust_type_str_model(member);
319            (variant_name, rust_type)
320        })
321        .collect();
322
323    let body = sigil_quote!(RustLang {
324        $if(schema.description.is_some()) {
325            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
326        }
327        $L(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
328        #[serde(untagged)]
329        pub enum $N(name.as_str()) {
330            $for((variant_name, rust_type) in variants.iter()) {
331                $L(format!("{variant_name}({rust_type}),"))
332            }
333        }
334    })
335    .ok()?;
336
337    fsb = fsb.add_code(body);
338    fsb.build().ok()
339}
340
341fn union_variant_name(expr: &IrTypeExpr, index: usize) -> String {
342    match expr {
343        IrTypeExpr::Named(n) => n.to_pascal_case(),
344        IrTypeExpr::Primitive(p) => primitive_variant_name(p),
345        IrTypeExpr::Array(_) => format!("Array{index}"),
346        IrTypeExpr::Map(_) => format!("Map{index}"),
347        _ => format!("Variant{index}"),
348    }
349}
350
351fn primitive_variant_name(p: &IrPrimitive) -> String {
352    match p {
353        IrPrimitive::String | IrPrimitive::StringWithFormat(_) => "String".to_string(),
354        IrPrimitive::Integer | IrPrimitive::IntegerWithFormat(_) => "Integer".to_string(),
355        IrPrimitive::Number | IrPrimitive::NumberWithFormat(_) => "Number".to_string(),
356        IrPrimitive::Boolean => "Boolean".to_string(),
357        IrPrimitive::Binary => "Binary".to_string(),
358        IrPrimitive::Date => "Date".to_string(),
359        IrPrimitive::DateTime => "DateTime".to_string(),
360        IrPrimitive::Uuid => "Uuid".to_string(),
361    }
362}
363
364// ---------------------------------------------------------------------------
365// TaggedUnion -> serde-tagged enum
366// ---------------------------------------------------------------------------
367
368fn emit_tagged_union(
369    schema: &IrSchema,
370    tu: &IrTaggedUnion,
371    extra: Option<&ExtraDeriveConfig>,
372) -> Option<FileSpec> {
373    if tu.variants.is_empty() {
374        return None;
375    }
376
377    let name = schema.name.to_pascal_case();
378    let stem = schema.name.to_snake_case();
379
380    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
381    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
382    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
383
384    let serde_tag_attr = match &tu.tagging {
385        TaggingStyle::Internal => {
386            format!(
387                "#[serde(tag = \"{}\")]",
388                escape_str(&tu.discriminator_field)
389            )
390        }
391        TaggingStyle::Adjacent { content_field } => {
392            format!(
393                "#[serde(tag = \"{}\", content = \"{}\")]",
394                escape_str(&tu.discriminator_field),
395                escape_str(content_field)
396            )
397        }
398        TaggingStyle::External => String::new(),
399    };
400
401    let body = sigil_quote!(RustLang {
402        $if(schema.description.is_some()) {
403            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
404        }
405        $L(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
406        $if(!serde_tag_attr.is_empty()) {
407            $L(serde_tag_attr.as_str())
408        }
409        pub enum $N(name.as_str()) {
410            $for(variant in tu.variants.iter()) {
411                $if(variant.discriminator_value.to_pascal_case() != variant.discriminator_value) {
412                    $L(format!("#[serde(rename = \"{}\")]", escape_str(&variant.discriminator_value)))
413                }
414                $L(format!("{}({}),", variant.discriminator_value.to_pascal_case(), rust_type_str_model(&variant.content_type)))
415            }
416        }
417    })
418    .ok()?;
419
420    fsb = fsb.add_code(body);
421    fsb.build().ok()
422}
423
424// ---------------------------------------------------------------------------
425// Intersection -> flattened struct
426// ---------------------------------------------------------------------------
427
428fn emit_intersection(
429    schema: &IrSchema,
430    inter: &IrIntersection,
431    extra: Option<&ExtraDeriveConfig>,
432) -> Option<FileSpec> {
433    let name = schema.name.to_pascal_case();
434    let stem = schema.name.to_snake_case();
435
436    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
437    fsb = fsb.add_import(ImportSpec::named("serde", "Deserialize"));
438    fsb = fsb.add_import(ImportSpec::named("serde", "Serialize"));
439
440    let fields: Vec<(String, String)> = inter
441        .members
442        .iter()
443        .enumerate()
444        .map(|(i, member)| {
445            let raw_name = match member {
446                IrTypeExpr::Named(n) => n.to_snake_case(),
447                _ => format!("member_{i}"),
448            };
449            let field_name = escape_rust_keyword(&raw_name);
450            let rust_type = rust_type_str_model(member);
451            (field_name, rust_type)
452        })
453        .collect();
454
455    let body = sigil_quote!(RustLang {
456        $if(schema.description.is_some()) {
457            $L(doc_comment_block(schema.description.as_deref().unwrap()).trim_end())
458        }
459        $L(derive_attr("Debug, Clone, Serialize, Deserialize", extra))
460        pub struct $N(name.as_str()) {
461            $for((field_name, rust_type) in fields.iter()) {
462                #[serde(flatten)]
463                $L(format!("pub {field_name}: {rust_type},"))
464            }
465        }
466    })
467    .ok()?;
468
469    fsb = fsb.add_code(body);
470    fsb.build().ok()
471}
472
473// ---------------------------------------------------------------------------
474// Display impl helpers
475// ---------------------------------------------------------------------------
476
477fn build_integer_enum_display(name: &str) -> Option<CodeBlock> {
478    sigil_quote!(RustLang {
479        impl std::fmt::Display for $N(name) {
480            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481                write!(f, "{}", *self as i64)
482            }
483        }
484    })
485    .ok()
486}
487
488fn build_string_enum_display(name: &str, variants: &[(String, String)]) -> Option<CodeBlock> {
489    sigil_quote!(RustLang {
490        impl std::fmt::Display for $N(name) {
491            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492                match self {
493                    $for((variant, wire_value) in variants.iter()) {
494                        $L(format!("{name}::{variant} => write!(f, {wire_value:?}),"))
495                    }
496                }
497            }
498        }
499    })
500    .ok()
501}
502
503// ---------------------------------------------------------------------------
504// Doc comment helpers
505// ---------------------------------------------------------------------------
506
507/// Build a doc-comment string for use with $L interpolation.
508fn doc_comment_block(doc: &str) -> String {
509    let mut out = String::new();
510    for line in doc.lines() {
511        out.push_str(&format!("/// {line}\n"));
512    }
513    out
514}
515
516// ---------------------------------------------------------------------------
517// Type mapping helpers
518// ---------------------------------------------------------------------------
519
520pub fn rust_type_str(expr: &IrTypeExpr) -> String {
521    match expr {
522        IrTypeExpr::Named(name) => name.to_pascal_case(),
523        IrTypeExpr::Primitive(p) => rust_primitive(p).to_string(),
524        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str(inner)),
525        IrTypeExpr::Map(inner) => format!(
526            "std::collections::HashMap<String, {}>",
527            rust_type_str(inner)
528        ),
529        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str(inner)),
530        IrTypeExpr::StringLiteral(_) | IrTypeExpr::StringEnum(_) => "String".to_string(),
531        IrTypeExpr::Union(_) | IrTypeExpr::Any => "serde_json::Value".to_string(),
532    }
533}
534
535/// Map an IR type to a Rust type string, qualified for use from API modules.
536pub fn rust_type_str_qualified(expr: &IrTypeExpr) -> String {
537    match expr {
538        IrTypeExpr::Named(name) => format!("crate::models::{}", name.to_pascal_case()),
539        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str_qualified(inner)),
540        IrTypeExpr::Map(inner) => format!(
541            "std::collections::HashMap<String, {}>",
542            rust_type_str_qualified(inner)
543        ),
544        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str_qualified(inner)),
545        other => rust_type_str(other),
546    }
547}
548
549/// Map an IR type for use within model files (sibling references use `super::`).
550fn rust_type_str_model(expr: &IrTypeExpr) -> String {
551    match expr {
552        IrTypeExpr::Named(name) => format!("super::{}", name.to_pascal_case()),
553        IrTypeExpr::Array(inner) => format!("Vec<{}>", rust_type_str_model(inner)),
554        IrTypeExpr::Map(inner) => format!(
555            "std::collections::HashMap<String, {}>",
556            rust_type_str_model(inner)
557        ),
558        IrTypeExpr::Nullable(inner) => format!("Option<{}>", rust_type_str_model(inner)),
559        other => rust_type_str(other),
560    }
561}
562
563fn rust_primitive(p: &IrPrimitive) -> &'static str {
564    match p {
565        IrPrimitive::String
566        | IrPrimitive::Date
567        | IrPrimitive::DateTime
568        | IrPrimitive::Uuid
569        | IrPrimitive::StringWithFormat(_) => "String",
570        IrPrimitive::Binary => "Vec<u8>",
571        IrPrimitive::Integer => "i64",
572        IrPrimitive::IntegerWithFormat(format) => match format.as_str() {
573            "int32" => "i32",
574            "int64" => "i64",
575            _ => "i64",
576        },
577        IrPrimitive::Number => "f64",
578        IrPrimitive::NumberWithFormat(format) => match format.as_str() {
579            "float" => "f32",
580            _ => "f64",
581        },
582        IrPrimitive::Boolean => "bool",
583    }
584}
585
586fn escape_str(s: &str) -> String {
587    s.replace('\\', "\\\\").replace('"', "\\\"")
588}
589
590fn escape_rust_keyword(name: &str) -> String {
591    const KEYWORDS: &[&str] = &[
592        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
593        "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",
594        "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",
595        "true", "type", "union", "unsafe", "use", "where", "while", "yield",
596    ];
597    if KEYWORDS.contains(&name) {
598        format!("r#{name}")
599    } else {
600        name.to_string()
601    }
602}