Skip to main content

spikard_cli/codegen/
rust.rs

1//! Rust code generation from `OpenAPI` schemas
2
3use super::RustDtoStyle;
4use crate::codegen::common::{TargetLanguage, sanitize_identifier_snake_case};
5use anyhow::Result;
6use heck::{ToPascalCase, ToSnakeCase};
7use openapiv3::{
8    IntegerFormat, OpenAPI, Operation, ReferenceOr, Schema, SchemaKind, StringFormat, Type, VariantOrUnknownOrEmpty,
9};
10use std::collections::{BTreeSet, HashSet};
11
12#[derive(Debug, Clone)]
13struct RustFieldSpec {
14    original_name: String,
15    field_name: String,
16    type_hint: String,
17    required: bool,
18}
19
20pub struct RustGenerator {
21    spec: OpenAPI,
22    style: RustDtoStyle,
23}
24
25impl RustGenerator {
26    #[must_use]
27    pub const fn new(spec: OpenAPI, style: RustDtoStyle) -> Self {
28        Self { spec, style }
29    }
30
31    pub fn generate(&self) -> Result<String> {
32        let mut output = String::new();
33        match self.style {
34            RustDtoStyle::SerdeStruct => {}
35        }
36
37        output.push_str(&self.generate_header());
38
39        output.push_str(&self.generate_models()?);
40        output.push_str(&self.generate_operation_models()?);
41
42        let (handlers, registrations) = self.generate_handlers()?;
43        output.push_str(&handlers);
44        output.push_str(&self.generate_builder(&registrations));
45
46        Ok(output)
47    }
48
49    fn generate_header(&self) -> String {
50        let mut imports = vec![
51            "App".to_string(),
52            "AppError".to_string(),
53            "HandlerResult".to_string(),
54            "RequestContext".to_string(),
55        ];
56        imports.extend(self.route_builder_imports());
57        format!(
58            r"// Generated by Spikard OpenAPI code generator
59// OpenAPI Version: {}
60// Title: {}
61// DO NOT EDIT - regenerate from OpenAPI schema
62
63use axum::body::Body;
64use axum::http::StatusCode;
65use axum::http::Response as HttpResponse;
66use schemars::JsonSchema;
67use serde::{{Deserialize, Serialize}};
68use spikard::{{{}}};
69
70",
71            self.spec.openapi,
72            self.spec.info.title,
73            imports.join(", ")
74        )
75    }
76
77    fn route_builder_imports(&self) -> Vec<String> {
78        let mut builders = BTreeSet::new();
79
80        for path_item_ref in self.spec.paths.paths.values() {
81            let ReferenceOr::Item(path_item) = path_item_ref else {
82                continue;
83            };
84
85            if path_item.get.is_some() {
86                builders.insert("get".to_string());
87            }
88            if path_item.post.is_some() {
89                builders.insert("post".to_string());
90            }
91            if path_item.put.is_some() {
92                builders.insert("put".to_string());
93            }
94            if path_item.patch.is_some() {
95                builders.insert("patch".to_string());
96            }
97            if path_item.delete.is_some() {
98                builders.insert("delete".to_string());
99            }
100        }
101
102        builders.into_iter().collect()
103    }
104
105    fn generate_models(&self) -> Result<String> {
106        let mut output = String::new();
107        output.push_str("// Schema Models\n\n");
108
109        if let Some(components) = &self.spec.components {
110            for (name, schema_ref) in &components.schemas {
111                match schema_ref {
112                    ReferenceOr::Item(schema) => {
113                        output.push_str(&self.generate_model_struct(name, schema)?);
114                        output.push('\n');
115                    }
116                    ReferenceOr::Reference { .. } => {
117                        continue;
118                    }
119                }
120            }
121        }
122
123        Ok(output)
124    }
125
126    fn generate_operation_models(&self) -> Result<String> {
127        let mut output = String::new();
128        let mut emitted = HashSet::new();
129
130        for path_item_ref in self.spec.paths.paths.values() {
131            let ReferenceOr::Item(path_item) = path_item_ref else {
132                continue;
133            };
134
135            for operation in [
136                path_item.get.as_ref(),
137                path_item.post.as_ref(),
138                path_item.put.as_ref(),
139                path_item.delete.as_ref(),
140                path_item.patch.as_ref(),
141            ]
142            .into_iter()
143            .flatten()
144            {
145                if let Some((name, schema)) = self.request_body_inline_model(operation)
146                    && emitted.insert(name.clone())
147                {
148                    output.push_str(&self.generate_inline_operation_model(&name, schema)?);
149                    output.push('\n');
150                }
151
152                if let Some((name, schema)) = self.response_body_inline_model(operation)
153                    && emitted.insert(name.clone())
154                {
155                    output.push_str(&self.generate_inline_operation_model(&name, schema)?);
156                    output.push('\n');
157                }
158            }
159        }
160
161        Ok(output)
162    }
163
164    fn generate_model_struct(&self, name: &str, schema: &Schema) -> Result<String> {
165        let struct_name = name.to_pascal_case();
166        self.generate_named_struct_recursive(&struct_name, schema)
167    }
168
169    fn generate_named_struct_recursive(&self, struct_name: &str, schema: &Schema) -> Result<String> {
170        let mut output = String::new();
171        let mut properties = Vec::new();
172        self.collect_object_properties(schema, &mut properties);
173
174        if let Some(description) = &schema.schema_data.description {
175            output.push_str(&render_doc_comment(description, 0));
176        }
177
178        for (prop_name, prop_schema_ref, _required) in &properties {
179            match prop_schema_ref {
180                ReferenceOr::Item(prop_schema) => match &prop_schema.schema_kind {
181                    SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
182                        let nested_name = format!("{struct_name}{}", prop_name.to_pascal_case());
183                        output.push_str(&self.generate_named_struct_recursive(&nested_name, prop_schema)?);
184                        output.push('\n');
185                    }
186                    SchemaKind::Type(Type::Array(arr)) => {
187                        if let Some(ReferenceOr::Item(item_schema)) = &arr.items
188                            && let SchemaKind::Type(Type::Object(item_obj)) = &item_schema.schema_kind
189                            && !item_obj.properties.is_empty()
190                        {
191                            let nested_name = format!("{struct_name}{}Item", prop_name.to_pascal_case());
192                            output.push_str(&self.generate_named_struct_recursive(&nested_name, item_schema)?);
193                            output.push('\n');
194                        }
195                    }
196                    _ => {}
197                },
198                ReferenceOr::Reference { .. } => {}
199            }
200        }
201
202        output.push_str("#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]\n");
203        output.push_str(&format!("pub struct {struct_name} {{\n"));
204
205        let fields = self.collect_struct_fields(struct_name, &properties);
206        if fields.is_empty() {
207            output.push_str("    // Empty struct\n");
208        } else {
209            for field in fields {
210                if !field.required {
211                    output.push_str("    #[serde(skip_serializing_if = \"Option::is_none\")]\n");
212                }
213                if field.field_name != field.original_name {
214                    output.push_str(&format!("    #[serde(rename = \"{}\")]\n", field.original_name));
215                }
216
217                output.push_str(&format!("    pub {}: {},\n", field.field_name, field.type_hint));
218            }
219        }
220
221        output.push_str("}\n");
222
223        Ok(output)
224    }
225
226    fn collect_object_properties(
227        &self,
228        schema: &Schema,
229        properties: &mut Vec<(String, ReferenceOr<Box<Schema>>, bool)>,
230    ) {
231        match &schema.schema_kind {
232            SchemaKind::Type(Type::Object(obj)) => {
233                for (prop_name, prop_schema_ref) in &obj.properties {
234                    if properties
235                        .iter()
236                        .any(|(existing_name, _, _)| existing_name == prop_name)
237                    {
238                        continue;
239                    }
240                    properties.push((
241                        prop_name.clone(),
242                        prop_schema_ref.clone(),
243                        obj.required.contains(prop_name),
244                    ));
245                }
246            }
247            SchemaKind::AllOf { all_of } => {
248                for schema_ref in all_of {
249                    match schema_ref {
250                        ReferenceOr::Item(schema) => self.collect_object_properties(schema, properties),
251                        ReferenceOr::Reference { reference } => {
252                            if let Some(schema) = self.resolve_schema_reference(reference) {
253                                self.collect_object_properties(schema, properties);
254                            }
255                        }
256                    }
257                }
258            }
259            _ => {}
260        }
261    }
262
263    fn collect_struct_fields(
264        &self,
265        struct_name: &str,
266        properties: &[(String, ReferenceOr<Box<Schema>>, bool)],
267    ) -> Vec<RustFieldSpec> {
268        properties
269            .iter()
270            .map(|(prop_name, prop_schema_ref, is_required)| {
271                let field_name = sanitize_rust_identifier(prop_name);
272                let type_hint = match prop_schema_ref {
273                    ReferenceOr::Item(prop_schema) => {
274                        self.inline_field_type(struct_name, prop_name, prop_schema, *is_required)
275                    }
276                    ReferenceOr::Reference { reference } => {
277                        let ref_name = reference.split('/').next_back().unwrap();
278                        let base_type = ref_name.to_pascal_case();
279                        if *is_required {
280                            base_type
281                        } else {
282                            format!("Option<{base_type}>")
283                        }
284                    }
285                };
286
287                RustFieldSpec {
288                    original_name: prop_name.clone(),
289                    field_name,
290                    type_hint,
291                    required: *is_required,
292                }
293            })
294            .collect()
295    }
296
297    fn inline_field_type(&self, struct_name: &str, prop_name: &str, schema: &Schema, required: bool) -> String {
298        let base_type = match &schema.schema_kind {
299            SchemaKind::Type(Type::Object(obj)) if !obj.properties.is_empty() => {
300                format!("{struct_name}{}", prop_name.to_pascal_case())
301            }
302            SchemaKind::Type(Type::Array(arr)) => {
303                if let Some(ReferenceOr::Item(item_schema)) = &arr.items
304                    && let SchemaKind::Type(Type::Object(item_obj)) = &item_schema.schema_kind
305                    && !item_obj.properties.is_empty()
306                {
307                    format!("Vec<{struct_name}{}Item>", prop_name.to_pascal_case())
308                } else {
309                    Self::schema_to_rust_type(schema, false)
310                }
311            }
312            _ => Self::schema_to_rust_type(schema, false),
313        };
314
315        if required {
316            base_type
317        } else {
318            format!("Option<{base_type}>")
319        }
320    }
321
322    fn resolve_schema_reference<'a>(&'a self, reference: &str) -> Option<&'a Schema> {
323        let name = reference.split('/').next_back()?;
324        self.spec
325            .components
326            .as_ref()?
327            .schemas
328            .get(name)
329            .and_then(|schema_ref| match schema_ref {
330                ReferenceOr::Item(schema) => Some(schema),
331                ReferenceOr::Reference { .. } => None,
332            })
333    }
334
335    /// Extract type name from a schema reference or inline schema
336    fn extract_type_from_schema_ref(&self, schema_ref: &ReferenceOr<Schema>) -> String {
337        match schema_ref {
338            ReferenceOr::Reference { reference } => {
339                let ref_name = reference.split('/').next_back().unwrap();
340                ref_name.to_pascal_case()
341            }
342            ReferenceOr::Item(schema) => Self::schema_to_rust_type(schema, false),
343        }
344    }
345
346    /// Extract request body type from operation
347    fn extract_request_body_type(&self, operation: &Operation) -> Option<String> {
348        operation.request_body.as_ref().and_then(|body_ref| match body_ref {
349            ReferenceOr::Item(request_body) => request_body.content.get("application/json").and_then(|media_type| {
350                media_type.schema.as_ref().map(|schema_ref| match schema_ref {
351                    ReferenceOr::Reference { .. } => self.extract_type_from_schema_ref(schema_ref),
352                    ReferenceOr::Item(schema) => self
353                        .request_body_inline_model(operation)
354                        .map_or_else(|| Self::schema_to_rust_type(schema, false), |(name, _)| name),
355                })
356            }),
357            ReferenceOr::Reference { reference } => {
358                let ref_name = reference.split('/').next_back().unwrap();
359                Some(ref_name.to_pascal_case())
360            }
361        })
362    }
363
364    /// Extract response type from operation (looks for 200/201 responses)
365    fn extract_response_type(&self, operation: &Operation) -> Option<String> {
366        use openapiv3::StatusCode;
367
368        let response = operation
369            .responses
370            .responses
371            .get(&StatusCode::Code(200))
372            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
373            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)));
374
375        if let Some(response_ref) = response {
376            match response_ref {
377                ReferenceOr::Item(response) => {
378                    if let Some(content) = response.content.get("application/json")
379                        && let Some(schema_ref) = &content.schema
380                    {
381                        return Some(match schema_ref {
382                            ReferenceOr::Reference { .. } => self.extract_type_from_schema_ref(schema_ref),
383                            ReferenceOr::Item(schema) => self
384                                .response_body_inline_model(operation)
385                                .map_or_else(|| Self::schema_to_rust_type(schema, false), |(name, _)| name),
386                        });
387                    }
388                }
389                ReferenceOr::Reference { reference } => {
390                    let ref_name = reference.split('/').next_back().unwrap();
391                    return Some(ref_name.to_pascal_case());
392                }
393            }
394        }
395
396        None
397    }
398
399    fn request_body_inline_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
400        let operation_id = operation.operation_id.as_ref()?;
401        let body_ref = operation.request_body.as_ref()?;
402        let ReferenceOr::Item(request_body) = body_ref else {
403            return None;
404        };
405        let media_type = request_body.content.get("application/json")?;
406        let schema_ref = media_type.schema.as_ref()?;
407        let ReferenceOr::Item(schema) = schema_ref else {
408            return None;
409        };
410        if Self::schema_needs_named_inline_type(schema) {
411            Some((format!("{}RequestBody", operation_id.to_pascal_case()), schema))
412        } else {
413            None
414        }
415    }
416
417    fn response_body_inline_model<'a>(&self, operation: &'a Operation) -> Option<(String, &'a Schema)> {
418        use openapiv3::StatusCode;
419
420        let operation_id = operation.operation_id.as_ref()?;
421        let response_ref = operation
422            .responses
423            .responses
424            .get(&StatusCode::Code(200))
425            .or_else(|| operation.responses.responses.get(&StatusCode::Code(201)))
426            .or_else(|| operation.responses.responses.get(&StatusCode::Range(2)))?;
427        let ReferenceOr::Item(response) = response_ref else {
428            return None;
429        };
430        let content = response.content.get("application/json")?;
431        let schema_ref = content.schema.as_ref()?;
432        let ReferenceOr::Item(schema) = schema_ref else {
433            return None;
434        };
435        if Self::schema_needs_named_inline_type(schema) {
436            Some((format!("{}ResponseBody", operation_id.to_pascal_case()), schema))
437        } else {
438            None
439        }
440    }
441
442    fn schema_needs_named_inline_type(schema: &Schema) -> bool {
443        matches!(
444            schema.schema_kind,
445            SchemaKind::Type(Type::Object(_))
446                | SchemaKind::AllOf { .. }
447                | SchemaKind::OneOf { .. }
448                | SchemaKind::AnyOf { .. }
449        )
450    }
451
452    fn generate_inline_operation_model(&self, name: &str, schema: &Schema) -> Result<String> {
453        match &schema.schema_kind {
454            SchemaKind::OneOf { one_of } | SchemaKind::AnyOf { any_of: one_of } => {
455                let mut output = String::new();
456                output.push_str("#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]\n");
457                output.push_str("#[serde(untagged)]\n");
458                output.push_str(&format!("pub enum {name} {{\n"));
459
460                for (index, variant) in one_of.iter().enumerate() {
461                    let (variant_name, variant_type) = match variant {
462                        ReferenceOr::Reference { reference } => {
463                            let ref_name = reference.split('/').next_back().unwrap();
464                            (ref_name.to_pascal_case(), ref_name.to_pascal_case())
465                        }
466                        ReferenceOr::Item(item_schema) => (
467                            format!("Variant{}", index + 1),
468                            Self::schema_to_rust_type(item_schema, false),
469                        ),
470                    };
471                    output.push_str(&format!("    {variant_name}({variant_type}),\n"));
472                }
473
474                output.push_str("}\n");
475                Ok(output)
476            }
477            _ => self.generate_model_struct(name, schema),
478        }
479    }
480
481    fn schema_to_rust_type(schema: &Schema, optional: bool) -> String {
482        let base_type = match &schema.schema_kind {
483            SchemaKind::Type(Type::String(string_type)) => match &string_type.format {
484                VariantOrUnknownOrEmpty::Item(StringFormat::Date) => "chrono::NaiveDate".to_string(),
485                VariantOrUnknownOrEmpty::Item(StringFormat::DateTime) => "chrono::DateTime<chrono::Utc>".to_string(),
486                VariantOrUnknownOrEmpty::Unknown(format) if format == "uuid" => "uuid::Uuid".to_string(),
487                _ => "String".to_string(),
488            },
489            SchemaKind::Type(Type::Number(_)) => "f64".to_string(),
490            SchemaKind::Type(Type::Integer(int_type)) => match &int_type.format {
491                VariantOrUnknownOrEmpty::Item(IntegerFormat::Int32) => "i32".to_string(),
492                VariantOrUnknownOrEmpty::Item(IntegerFormat::Int64) => "i64".to_string(),
493                _ => "i64".to_string(),
494            },
495            SchemaKind::Type(Type::Boolean(_)) => "bool".to_string(),
496            SchemaKind::Type(Type::Array(arr)) => {
497                let item_type = match &arr.items {
498                    Some(ReferenceOr::Item(item_schema)) => Self::schema_to_rust_type(item_schema, false),
499                    Some(ReferenceOr::Reference { reference }) => {
500                        let ref_name = reference.split('/').next_back().unwrap();
501                        ref_name.to_pascal_case()
502                    }
503                    None => "serde_json::Value".to_string(),
504                };
505                format!("Vec<{item_type}>")
506            }
507            SchemaKind::Type(Type::Object(_)) => "serde_json::Value".to_string(),
508            _ => "serde_json::Value".to_string(),
509        };
510
511        if optional {
512            format!("Option<{base_type}>")
513        } else {
514            base_type
515        }
516    }
517
518    fn generate_handlers(&self) -> Result<(String, String)> {
519        let mut handlers = String::from(
520            "
521// Route Handlers
522
523",
524        );
525        let mut registrations = String::new();
526
527        for (path, path_item_ref) in &self.spec.paths.paths {
528            let path_item = match path_item_ref {
529                ReferenceOr::Item(item) => item,
530                ReferenceOr::Reference { .. } => continue,
531            };
532
533            if let Some(op) = &path_item.get {
534                self.append_handler(path, "GET", op, &mut handlers, &mut registrations)?;
535            }
536            if let Some(op) = &path_item.post {
537                self.append_handler(path, "POST", op, &mut handlers, &mut registrations)?;
538            }
539            if let Some(op) = &path_item.put {
540                self.append_handler(path, "PUT", op, &mut handlers, &mut registrations)?;
541            }
542            if let Some(op) = &path_item.delete {
543                self.append_handler(path, "DELETE", op, &mut handlers, &mut registrations)?;
544            }
545            if let Some(op) = &path_item.patch {
546                self.append_handler(path, "PATCH", op, &mut handlers, &mut registrations)?;
547            }
548        }
549
550        Ok((handlers, registrations))
551    }
552
553    fn append_handler(
554        &self,
555        path: &str,
556        method: &str,
557        operation: &Operation,
558        handlers: &mut String,
559        registrations: &mut String,
560    ) -> Result<()> {
561        let builder_fn = match method {
562            "GET" => "get",
563            "POST" => "post",
564            "PUT" => "put",
565            "PATCH" => "patch",
566            "DELETE" => "delete",
567            _ => return Ok(()),
568        };
569
570        let handler_name = operation.operation_id.as_ref().map_or_else(
571            || format!("{}_{}", method.to_lowercase(), sanitize_identifier(path)),
572            |id| id.to_snake_case(),
573        );
574
575        let request_type = self.extract_request_body_type(operation);
576        let response_type = self.extract_response_type(operation);
577        let escaped_path = path.replace('"', "\\\"");
578
579        let mut builder = format!("{builder_fn}(\"{escaped_path}\")");
580        builder.push_str(&format!(".handler_name(\"{handler_name}\")"));
581        if let Some(ref req_ty) = request_type {
582            builder.push_str(&format!(".request_body::<{req_ty}>()"));
583        }
584        if let Some(ref resp_ty) = response_type {
585            builder.push_str(&format!(".response_body::<{resp_ty}>()"));
586        }
587        registrations.push_str(&format!("    app.route({builder}, {handler_name})?;\n"));
588
589        if let Some(summary) = &operation.summary {
590            handlers.push_str(&render_doc_comment(summary, 0));
591        }
592        if let Some(description) = &operation.description {
593            handlers.push_str(&render_doc_comment(description, 0));
594        }
595        handlers.push_str(&format!(
596            "pub async fn {handler_name}(_ctx: RequestContext) -> HandlerResult {{\n"
597        ));
598        if let Some(req_ty) = request_type {
599            handlers.push_str(&format!(
600                "    // let body: {req_ty} = _ctx.json().map_err(|err| (StatusCode::BAD_REQUEST, err.to_string()))?;\n"
601            ));
602        }
603        handlers.push_str(
604            "    HttpResponse::builder()\n        .status(StatusCode::NOT_IMPLEMENTED)\n        .header(\"content-type\", \"application/json\")\n        .body(Body::from(r#\"{\"error\":\"Not implemented\"}\"#))\n        .map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))\n",
605        );
606        handlers.push_str("}\n\n");
607
608        Ok(())
609    }
610
611    fn generate_builder(&self, registrations: &str) -> String {
612        format!(
613            "pub fn build_app() -> Result<App, AppError> {{
614    let mut app = App::new();
615{registrations}    Ok(app)
616}}
617
618"
619        )
620    }
621}
622
623fn sanitize_identifier(path: &str) -> String {
624    path.chars()
625        .map(|c| {
626            if c.is_ascii_alphanumeric() {
627                c.to_ascii_lowercase()
628            } else {
629                '_'
630            }
631        })
632        .collect::<String>()
633        .trim_matches('_')
634        .to_string()
635}
636
637fn sanitize_rust_identifier(name: &str) -> String {
638    sanitize_identifier_snake_case(name, TargetLanguage::Rust)
639}
640
641fn render_doc_comment(text: &str, indent: usize) -> String {
642    let prefix = " ".repeat(indent);
643    let mut output = String::new();
644    let mut previous_was_list_item = false;
645
646    for raw_line in text.lines() {
647        let line = raw_line.trim();
648        if line.is_empty() {
649            output.push_str(&format!("{prefix}///\n"));
650            previous_was_list_item = false;
651            continue;
652        }
653
654        let is_list_item = line.starts_with("- ")
655            || line.starts_with("* ")
656            || line
657                .chars()
658                .next()
659                .is_some_and(|first| first.is_ascii_digit() && line.contains(". "));
660
661        if previous_was_list_item && !is_list_item {
662            output.push_str(&format!("{prefix}///\n"));
663        }
664
665        output.push_str(&format!("{prefix}/// {line}\n"));
666        previous_was_list_item = is_list_item;
667    }
668
669    output
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675
676    fn sample_spec() -> OpenAPI {
677        serde_json::from_value(serde_json::json!({
678            "openapi": "3.1.0",
679            "info": { "title": "Todo API", "version": "1.0.0" },
680            "components": {
681                "schemas": {
682                    "CreateTodoRequest": {
683                        "type": "object",
684                        "required": ["title"],
685                        "properties": {
686                            "title": { "type": "string" }
687                        }
688                    },
689                    "TodoResponse": {
690                        "type": "object",
691                        "required": ["id"],
692                        "properties": {
693                            "id": { "type": "string" }
694                        }
695                    }
696                }
697            },
698            "paths": {
699                "/todos": {
700                    "post": {
701                        "operationId": "createTodo",
702                        "summary": "Create a todo",
703                        "description": "Creates a new todo item.",
704                        "requestBody": {
705                            "required": true,
706                            "content": {
707                                "application/json": {
708                                    "schema": { "$ref": "#/components/schemas/CreateTodoRequest" }
709                                }
710                            }
711                        },
712                        "responses": {
713                            "201": {
714                                "description": "Created",
715                                "content": {
716                                    "application/json": {
717                                        "schema": { "$ref": "#/components/schemas/TodoResponse" }
718                                    }
719                                }
720                            }
721                        }
722                    }
723                }
724            }
725        }))
726        .expect("sample OpenAPI spec should deserialize")
727    }
728
729    #[test]
730    fn rust_openapi_generator_emits_module_style_scaffold() {
731        let generator = RustGenerator::new(sample_spec(), RustDtoStyle::SerdeStruct);
732        let output = generator.generate().unwrap();
733
734        assert!(output.contains("use spikard::{App, AppError, HandlerResult, RequestContext"));
735        assert!(output.contains("pub async fn create_todo(_ctx: RequestContext) -> HandlerResult"));
736        assert!(output.contains("pub fn build_app() -> Result<App, AppError>"));
737        assert!(!output.contains("#![allow(dead_code)]"));
738        assert!(!output.contains("async fn main()"));
739    }
740
741    #[test]
742    fn rust_openapi_generator_merges_all_of_object_fields() {
743        let spec: OpenAPI = serde_json::from_value(serde_json::json!({
744            "openapi": "3.1.0",
745            "info": { "title": "Errors", "version": "1.0.0" },
746            "components": {
747                "schemas": {
748                    "BaseError": {
749                        "type": "object",
750                        "required": ["title", "status"],
751                        "properties": {
752                            "title": { "type": "string" },
753                            "status": { "type": "integer" }
754                        }
755                    },
756                    "AuthError": {
757                        "allOf": [
758                            { "$ref": "#/components/schemas/BaseError" },
759                            {
760                                "type": "object",
761                                "properties": {
762                                    "detail": { "default": "Missing auth" }
763                                }
764                            }
765                        ]
766                    }
767                }
768            },
769            "paths": {}
770        }))
771        .expect("OpenAPI spec should deserialize");
772
773        let generator = RustGenerator::new(spec, RustDtoStyle::SerdeStruct);
774        let output = generator.generate().unwrap();
775
776        assert!(output.contains("pub struct AuthError"));
777        assert!(output.contains("pub title: String"));
778        assert!(output.contains("pub status: i64"));
779    }
780}