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