1use 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
26pub 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 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
84fn 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
97fn 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
145fn 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 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 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
253fn 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
296fn 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
364fn 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
424fn 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
473fn 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
503fn 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
516pub 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
535pub 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
549fn 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}