Skip to main content

openapi_nexus/generators/rust/common/
emit_api.rs

1//! API emission for IR operations (Rust APIs).
2//!
3//! Groups operations by tag, emits one `apis/<tag>.rs` per tag group. Each file
4//! declares a `{Tag}Api` struct holding a `&runtime::Client` and exposes one
5//! method per operation.
6//!
7//! Backend-specific method bodies are injected via a closure, keeping this module
8//! agnostic to the HTTP library (reqwest, ureq, aioduct, etc.).
9
10use std::collections::{BTreeMap, HashSet};
11
12use crate::codegen::traits::file_writer::FileInfo;
13use crate::generators::multipart::multipart_parts_for_request_body;
14pub use crate::generators::multipart::{MultipartPart, MultipartValueEncoding};
15use crate::generators::request_inputs::{RequestInputPlan, request_input_for_operation};
16use crate::generators::response_names::{
17    response_entry_name as response_variant_name, response_match_rank,
18};
19use crate::ir::types::{
20    IrOperation, IrParameter, IrRequestBody, IrResponse, IrSpec, IrTypeExpr, ParameterLocation,
21};
22use heck::{ToPascalCase, ToSnakeCase};
23use sigil_stitch::code_block::{CodeBlock, CodeBlockBuilder};
24use sigil_stitch::prelude::sigil_quote;
25use sigil_stitch::spec::annotation_spec::AnnotationSpec;
26use sigil_stitch::spec::field_spec::FieldSpec;
27use sigil_stitch::spec::file_spec::FileSpec;
28use sigil_stitch::spec::import_spec::ImportSpec;
29use sigil_stitch::spec::modifiers::{TypeKind, Visibility};
30use sigil_stitch::spec::type_spec::TypeSpec;
31use sigil_stitch::type_name::TypeName;
32
33use super::config::ExtraDeriveConfig;
34use super::emit_models::rust_type_str_qualified;
35
36// ---------------------------------------------------------------------------
37// Backend configuration
38// ---------------------------------------------------------------------------
39
40/// Captures the differences between Rust HTTP backends.
41pub struct RustBackendConfig {
42    /// Whether methods are async (reqwest, aioduct) or sync (ureq).
43    pub is_async: bool,
44    /// Extra generic parameters on the Api struct, e.g., `"R: aioduct::RuntimePoll"`.
45    /// `None` for reqwest and ureq.
46    pub struct_generics: Option<String>,
47    /// Extra generic args for the client field type, e.g., `"<R>"`.
48    /// `None` for reqwest and ureq.
49    pub client_type_args: Option<String>,
50}
51
52// ---------------------------------------------------------------------------
53// Public API
54// ---------------------------------------------------------------------------
55
56/// Generate every API file from the IR.
57pub fn generate_api_files(
58    ir: &IrSpec,
59    header: &str,
60    config: &RustBackendConfig,
61    response_extra_derives: Option<&ExtraDeriveConfig>,
62    request_inputs: &RequestInputPlan,
63    body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
64) -> Result<Vec<FileInfo>, String> {
65    let by_tag = group_by_tag(&ir.operations);
66    let mut files = Vec::with_capacity(by_tag.len());
67    let mut mod_entries = Vec::new();
68
69    for (tag, ops) in &by_tag {
70        let stem = tag.to_snake_case();
71        let filename = format!("{stem}.rs");
72        mod_entries.push(stem);
73        let body = emit_api_file(
74            tag,
75            ops,
76            ir,
77            config,
78            response_extra_derives,
79            request_inputs,
80            body_emitter,
81        );
82        let content = format!("{header}{body}");
83        files.push(FileInfo::api(filename, content));
84    }
85
86    // mod.rs
87    let mut mod_content = String::from(header);
88    for entry in &mod_entries {
89        mod_content.push_str(&format!("mod {entry};\npub use {entry}::*;\n"));
90    }
91    files.push(FileInfo::api("mod.rs".to_string(), mod_content));
92
93    Ok(files)
94}
95
96// ---------------------------------------------------------------------------
97// Grouping
98// ---------------------------------------------------------------------------
99
100fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
101    let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
102    for op in operations {
103        let tags: Vec<String> = if op.tags.is_empty() {
104            vec!["default".to_string()]
105        } else {
106            op.tags.clone()
107        };
108        for tag in tags {
109            out.entry(tag).or_default().push(op);
110        }
111    }
112    out
113}
114
115// ---------------------------------------------------------------------------
116// File assembly
117// ---------------------------------------------------------------------------
118
119fn emit_api_file(
120    tag: &str,
121    ops: &[&IrOperation],
122    ir: &IrSpec,
123    config: &RustBackendConfig,
124    response_extra_derives: Option<&ExtraDeriveConfig>,
125    request_inputs: &RequestInputPlan,
126    body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
127) -> String {
128    let struct_name = format!("{}Api", tag.to_pascal_case());
129    let plans: Vec<OpPlan> = ops
130        .iter()
131        .map(|op| plan_operation(op, ir, request_inputs))
132        .collect();
133
134    let stem = tag.to_snake_case();
135    let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
136
137    // Use imports
138    fsb = fsb.add_import(ImportSpec::named("crate::runtime::client", "Client"));
139    fsb = fsb.add_import(ImportSpec::named("crate::runtime::error", "ApiError"));
140    fsb = fsb.add_import(ImportSpec::named("crate::runtime::error", "Error"));
141
142    // Struct generics (e.g., `<'a, R: aioduct::RuntimePoll>`)
143    let (struct_gen, impl_gen, type_args, client_field_args) = match &config.struct_generics {
144        Some(g) => {
145            let client_args = config.client_type_args.as_deref().unwrap_or("");
146            let param_names = g
147                .split(',')
148                .map(|param| param.split(':').next().unwrap_or(param).trim())
149                .collect::<Vec<_>>()
150                .join(", ");
151            (
152                format!("<'a, {g}>"),
153                format!("<'a, {g}>"),
154                format!("<'a, {param_names}>"),
155                client_args.to_string(),
156            )
157        }
158        None => (
159            "<'a>".to_string(),
160            "<'a>".to_string(),
161            "<'a>".to_string(),
162            String::new(),
163        ),
164    };
165
166    // Build struct + impl as a CodeBlock (lifetimes/generics don't fit TypeSpec)
167    let mut body = CodeBlock::builder();
168
169    // Struct declaration via sigil_quote
170    let doc_struct = format!("/// API operations under the \"{tag}\" tag.");
171    let generics = struct_gen.as_str();
172    let client_type_suffix = client_field_args.as_str();
173    let client_field = format!("client: &'a Client{client_type_suffix},");
174    body.add_code(
175        sigil_quote!(RustLang {
176            $L(doc_struct)
177            pub struct $N(struct_name.as_str())$L(generics) {
178                $L(client_field)
179            }
180        })
181        .expect("struct sigil_quote builds"),
182    );
183    body.add_line();
184
185    // Impl block (kept open for method injection)
186    let impl_header = format!("impl{impl_gen} {struct_name}{type_args}");
187    body.add(&impl_header, ());
188    body.begin_control_flow("", ());
189
190    // Constructor via sigil_quote
191    let doc_ctor = format!("/// Create a new `{struct_name}` bound to the given client.");
192    body.add_code(
193        sigil_quote!(RustLang {
194            $L(doc_ctor)
195            pub fn $L("new(client: &'a Client@{client_type_suffix}) -> Self") {
196                Self {
197                    client,
198                }
199            }
200        })
201        .expect("constructor sigil_quote builds"),
202    );
203
204    // Methods
205    for plan in &plans {
206        body.add_line();
207        body.add_code(emit_operation(plan, config, body_emitter));
208    }
209
210    body.end_control_flow(); // close impl
211
212    fsb = fsb.add_code(body.build().expect("body builds"));
213
214    // Response structs -- add as TypeSpec members
215    for plan in &plans {
216        fsb = fsb.add_type(emit_response_struct(plan, response_extra_derives));
217        fsb = fsb.add_code(emit_error_enum(plan));
218    }
219
220    let file = fsb.build().expect("FileSpec builds");
221    file.render(100).expect("FileSpec renders")
222}
223
224// ---------------------------------------------------------------------------
225// Operation planning (public for backend use)
226// ---------------------------------------------------------------------------
227
228pub struct OpPlan<'a> {
229    pub op: &'a IrOperation,
230    pub method_name: String,
231    pub response_type: String,
232    pub error_type: String,
233    pub path_params: Vec<ParamBinding<'a>>,
234    pub query_params: Vec<ParamBinding<'a>>,
235    pub header_params: Vec<ParamBinding<'a>>,
236    pub body: Option<BodyBinding>,
237    pub typed_responses: Vec<TypedResponse>,
238    pub error_responses: Vec<ErrorResponse>,
239}
240
241pub struct ParamBinding<'a> {
242    pub param: &'a IrParameter,
243    pub var_name: String,
244    pub rust_type: String,
245    pub is_optional: bool,
246}
247
248pub struct BodyBinding {
249    pub var_name: String,
250    pub rust_type: String,
251    pub media_type: String,
252    pub required: bool,
253    pub encoding: BodyEncoding,
254    pub multipart_supported: bool,
255    pub multipart_parts: Vec<MultipartPart>,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub enum BodyEncoding {
260    Json,
261    FormUrlEncoded,
262    Multipart,
263    Xml,
264    TextPlain,
265    OctetStream,
266    Other(String),
267}
268
269pub struct TypedResponse {
270    pub status: String,
271    pub field_name: String,
272    pub rust_type: String,
273    pub decoding: ResponseDecoding,
274}
275
276pub struct ErrorResponse {
277    pub status: String,
278    pub variant_name: String,
279    pub rust_type: String,
280    pub decoding: Option<ResponseDecoding>,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub enum ResponseDecoding {
285    Json,
286    Xml,
287    TextPlain,
288    OctetStream,
289    Other(String),
290}
291
292pub fn plan_operation<'a>(
293    op: &'a IrOperation,
294    ir: &'a IrSpec,
295    request_inputs: &RequestInputPlan,
296) -> OpPlan<'a> {
297    let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
298    let method_name = op_id.to_snake_case();
299    let response_type = format!("{}Response", op_id.to_pascal_case());
300    let error_type = format!("{}Error", op_id.to_pascal_case());
301
302    let mut used_names: HashSet<String> = HashSet::new();
303    used_names.insert("self".to_string());
304
305    let mut path_params = Vec::new();
306    let mut query_params = Vec::new();
307    let mut header_params = Vec::new();
308    for p in &op.parameters {
309        let var_name = unique_name(&p.name.to_snake_case(), &mut used_names);
310        let (rust_type, is_optional) = param_rust_type(p, ir);
311        let binding = ParamBinding {
312            param: p,
313            var_name,
314            rust_type,
315            is_optional,
316        };
317        match p.location {
318            ParameterLocation::Path => path_params.push(binding),
319            ParameterLocation::Query => query_params.push(binding),
320            ParameterLocation::Header => header_params.push(binding),
321            ParameterLocation::Cookie => header_params.push(binding),
322        }
323    }
324
325    let body = op
326        .request_body
327        .as_ref()
328        .and_then(|b| plan_body(op, b, &mut used_names, ir, request_inputs));
329
330    let mut typed_responses: Vec<TypedResponse> = op
331        .responses
332        .iter()
333        .filter(|r| is_success_status(&r.status))
334        .filter_map(|r| plan_response(r, ir))
335        .collect();
336    typed_responses.sort_by_key(|r| response_match_rank(&r.status));
337    let error_responses = op
338        .responses
339        .iter()
340        .filter(|r| !is_success_status(&r.status))
341        .map(|r| plan_error_response(r, ir))
342        .collect();
343
344    OpPlan {
345        op,
346        method_name,
347        response_type,
348        error_type,
349        path_params,
350        query_params,
351        header_params,
352        body,
353        typed_responses,
354        error_responses,
355    }
356}
357
358pub fn plan_body(
359    op: &IrOperation,
360    b: &IrRequestBody,
361    used_names: &mut HashSet<String>,
362    ir: &IrSpec,
363    request_inputs: &RequestInputPlan,
364) -> Option<BodyBinding> {
365    let (media_type, t) = pick_body_content(b)?;
366    let encoding = body_encoding(&media_type);
367    let rust_type = match encoding {
368        BodyEncoding::OctetStream => "Vec<u8>".to_string(),
369        BodyEncoding::TextPlain => "String".to_string(),
370        BodyEncoding::Multipart => request_input_for_operation(request_inputs, op, &media_type)
371            .map(|input| format!("crate::models::{}", input.name.to_pascal_case()))
372            .unwrap_or_else(|| rust_type_str_qualified(&t, ir)),
373        _ => rust_type_str_qualified(&t, ir),
374    };
375    let multipart_parts = if encoding == BodyEncoding::Multipart {
376        multipart_parts_for_request_body(b, &media_type, ir).unwrap_or_default()
377    } else {
378        Vec::new()
379    };
380    let multipart_supported = encoding != BodyEncoding::Multipart
381        || multipart_parts_for_request_body(b, &media_type, ir).is_some();
382    let var_name = unique_name("body", used_names);
383    Some(BodyBinding {
384        var_name,
385        rust_type,
386        media_type,
387        required: b.required,
388        encoding,
389        multipart_supported,
390        multipart_parts,
391    })
392}
393
394pub fn plan_response(r: &IrResponse, ir: &IrSpec) -> Option<TypedResponse> {
395    let (media_type, t) = pick_response_content(r)?;
396    let decoding = response_decoding(&media_type);
397    let rust_type = match decoding {
398        ResponseDecoding::OctetStream => "Vec<u8>".to_string(),
399        ResponseDecoding::TextPlain => "String".to_string(),
400        _ => rust_type_str_qualified(&t, ir),
401    };
402    Some(TypedResponse {
403        status: r.status.clone(),
404        field_name: response_field_name(&r.status),
405        rust_type,
406        decoding,
407    })
408}
409
410pub fn plan_error_response(r: &IrResponse, ir: &IrSpec) -> ErrorResponse {
411    let (rust_type, decoding) = match pick_response_content(r) {
412        Some((media_type, t)) => {
413            let decoding = response_decoding(&media_type);
414            let rust_type = match decoding {
415                ResponseDecoding::OctetStream => "Vec<u8>".to_string(),
416                ResponseDecoding::TextPlain => "String".to_string(),
417                _ => rust_type_str_qualified(&t, ir),
418            };
419            (rust_type, Some(decoding))
420        }
421        None => ("()".to_string(), None),
422    };
423    ErrorResponse {
424        status: r.status.clone(),
425        variant_name: response_variant_name(&r.status),
426        rust_type,
427        decoding,
428    }
429}
430
431pub fn is_success_status(status: &str) -> bool {
432    status
433        .parse::<u16>()
434        .is_ok_and(|code| (200..300).contains(&code))
435        || status.eq_ignore_ascii_case("2XX")
436}
437
438pub fn param_rust_type(p: &IrParameter, ir: &IrSpec) -> (String, bool) {
439    let base = rust_type_str_qualified(&p.type_expr, ir);
440    if p.required {
441        (base, false)
442    } else if matches!(p.type_expr, IrTypeExpr::Nullable(_)) {
443        // Already wrapped in Option by rust_type_str_qualified → avoid double-wrapping
444        (base, true)
445    } else {
446        (format!("Option<{base}>"), true)
447    }
448}
449
450pub fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
451    if used.insert(desired.to_string()) {
452        return desired.to_string();
453    }
454    for i in 2..=u32::MAX {
455        let candidate = format!("{desired}_{i}");
456        if used.insert(candidate.clone()) {
457            return candidate;
458        }
459    }
460    unreachable!("name collision space exhausted")
461}
462
463// ---------------------------------------------------------------------------
464// Per-operation emission
465// ---------------------------------------------------------------------------
466
467fn emit_operation(
468    plan: &OpPlan<'_>,
469    config: &RustBackendConfig,
470    body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
471) -> CodeBlock {
472    let OpPlan {
473        op,
474        method_name,
475        response_type,
476        error_type,
477        ..
478    } = plan;
479
480    let mut b = CodeBlock::builder();
481
482    // Doc comment
483    if let Some(summary) = &op.summary {
484        for line in summary.lines() {
485            if line.is_empty() {
486                b.add("///\n", ());
487            } else {
488                b.add(&format!("/// {line}\n"), ());
489            }
490        }
491    } else {
492        b.add(
493            &format!("/// {} {}\n", op.method.to_uppercase(), op.path),
494            (),
495        );
496    }
497    if let Some(desc) = &op.description {
498        b.add("///\n", ());
499        for line in desc.lines() {
500            if line.is_empty() {
501                b.add("///\n", ());
502            } else {
503                b.add(&format!("/// {line}\n"), ());
504            }
505        }
506    }
507
508    // Method signature
509    let mut params = Vec::new();
510    params.push("&self".to_string());
511    for p in plan
512        .path_params
513        .iter()
514        .chain(&plan.query_params)
515        .chain(&plan.header_params)
516    {
517        let ty = if is_copy_type(&p.rust_type) {
518            p.rust_type.clone()
519        } else if p.rust_type == "String" {
520            "&str".to_string()
521        } else if let Some(inner) = p
522            .rust_type
523            .strip_prefix("Vec<")
524            .and_then(|s| s.strip_suffix('>'))
525        {
526            format!("&[{inner}]")
527        } else {
528            format!("&{}", p.rust_type)
529        };
530        params.push(format!("{}: {ty}", p.var_name));
531    }
532    if let Some(body) = &plan.body {
533        let ty = if body.required {
534            format!("&{}", body.rust_type)
535        } else {
536            format!("Option<&{}>", body.rust_type)
537        };
538        params.push(format!("{}: {ty}", body.var_name));
539    }
540
541    let async_kw = if config.is_async { "async " } else { "" };
542    b.add(
543        &format!(
544            "pub {async_kw}fn {method_name}(\n    {},\n) -> Result<{response_type}, {error_type}>",
545            params.join(",\n    "),
546        ),
547        (),
548    );
549    b.begin_control_flow("", ());
550
551    // Method body from backend
552    b.add_code(body_emitter(plan));
553
554    b.end_control_flow();
555    b.build().unwrap()
556}
557
558pub fn emit_response_struct(plan: &OpPlan<'_>, extra: Option<&ExtraDeriveConfig>) -> TypeSpec {
559    let mut tb = TypeSpec::builder(&plan.response_type, TypeKind::Struct);
560    tb = tb.visibility(Visibility::Public);
561    tb = tb.doc(&format!("Response from `{}`.", plan.method_name));
562
563    let mut ann = AnnotationSpec::new("derive").args(["Debug"]);
564    if let Some(cfg) = extra {
565        ann = ann.args(cfg.derives.iter().map(|s| s.as_str()));
566    }
567    tb = tb.annotate(ann);
568
569    // status_code field
570    {
571        let fb = FieldSpec::builder("status_code", TypeName::primitive("u16"));
572        let fb = fb.visibility(Visibility::Public);
573        tb = tb.add_field(fb.build().expect("FieldSpec builds"));
574    }
575
576    // typed response fields
577    let mut seen: HashSet<String> = HashSet::new();
578    for tr in &plan.typed_responses {
579        if !seen.insert(tr.field_name.clone()) {
580            continue;
581        }
582        let fb = FieldSpec::builder(
583            &tr.field_name,
584            TypeName::raw(&format!("Option<{}>", tr.rust_type)),
585        );
586        let fb = fb.visibility(Visibility::Public);
587        tb = tb.add_field(fb.build().expect("FieldSpec builds"));
588    }
589
590    tb.build().expect("TypeSpec builds")
591}
592
593pub fn emit_error_enum(plan: &OpPlan<'_>) -> CodeBlock {
594    let mut cb = CodeBlock::builder();
595    let mut variants = Vec::new();
596    let mut seen: HashSet<String> = HashSet::new();
597    for er in &plan.error_responses {
598        let variant = unique_variant_name(&er.variant_name, &mut seen);
599        variants.push((variant, er.rust_type.clone()));
600    }
601    let unexpected_variant = unique_variant_name("Unexpected", &mut seen);
602    let transport_variant = unique_variant_name("Transport", &mut seen);
603
604    cb.add(&format!("/// Error from `{}`.\n", plan.method_name), ());
605    cb.add("#[derive(Debug)]\n", ());
606    cb.add(&format!("pub enum {} {{\n", plan.error_type), ());
607    for (variant, rust_type) in &variants {
608        cb.add(&format!("    {variant}(ApiError<{rust_type}>),\n"), ());
609    }
610    cb.add(
611        &format!("    {unexpected_variant}(ApiError<Vec<u8>>),\n"),
612        (),
613    );
614    cb.add(&format!("    {transport_variant}(Error),\n"), ());
615    cb.add("}\n\n", ());
616
617    cb.add(
618        &format!("impl From<Error> for {} {{\n", plan.error_type),
619        (),
620    );
621    cb.add("    fn from(error: Error) -> Self {\n", ());
622    cb.add(&format!("        Self::{transport_variant}(error)\n"), ());
623    cb.add("    }\n", ());
624    cb.add("}\n\n", ());
625
626    cb.add(
627        &format!("impl std::fmt::Display for {} {{\n", plan.error_type),
628        (),
629    );
630    cb.add(
631        "    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n",
632        (),
633    );
634    cb.add("        match self {\n", ());
635    for (variant, _) in &variants {
636        cb.add(
637            &format!("            Self::{variant}(err) => write!(f, \"HTTP error {{}}\", err.status_code()),\n"),
638            (),
639        );
640    }
641    cb.add(&format!(
642        "            Self::{unexpected_variant}(err) => write!(f, \"unexpected HTTP error {{}}\", err.status_code()),\n"
643    ), ());
644    cb.add(
645        &format!("            Self::{transport_variant}(err) => std::fmt::Display::fmt(err, f),\n"),
646        (),
647    );
648    cb.add("        }\n", ());
649    cb.add("    }\n", ());
650    cb.add("}\n\n", ());
651
652    cb.add(
653        &format!("impl std::error::Error for {} {{\n", plan.error_type),
654        (),
655    );
656    cb.add(
657        "    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {\n",
658        (),
659    );
660    cb.add("        match self {\n", ());
661    cb.add(
662        &format!("            Self::{transport_variant}(err) => Some(err),\n"),
663        (),
664    );
665    cb.add("            _ => None,\n", ());
666    cb.add("        }\n", ());
667    cb.add("    }\n", ());
668    cb.add("}\n", ());
669
670    cb.build().expect("error enum builds")
671}
672
673fn unique_variant_name(desired: &str, used: &mut HashSet<String>) -> String {
674    if used.insert(desired.to_string()) {
675        return desired.to_string();
676    }
677    for i in 2..=u32::MAX {
678        let candidate = format!("{desired}{i}");
679        if used.insert(candidate.clone()) {
680            return candidate;
681        }
682    }
683    unreachable!("variant collision space exhausted")
684}
685
686// ---------------------------------------------------------------------------
687// Helpers (public for backend use)
688// ---------------------------------------------------------------------------
689
690pub fn sanitize_operation_id(id: &str, method: &str, path: &str) -> String {
691    if !id.is_empty() {
692        return id.to_string();
693    }
694    format!(
695        "{}_{}",
696        method,
697        path.replace('/', "_").replace(['{', '}'], "")
698    )
699}
700
701pub fn response_field_name(status: &str) -> String {
702    match status {
703        "200" => "data".to_string(),
704        "201" => "created".to_string(),
705        "204" => "no_content".to_string(),
706        "default" => "error_body".to_string(),
707        s if s.ends_with("XX") => {
708            let prefix = &s[..s.len() - 2];
709            format!("status_{prefix}xx")
710        }
711        s => format!("status_{s}"),
712    }
713}
714
715/// Convert an OpenAPI status code string to a Rust match pattern.
716pub fn status_match_pattern(status: &str) -> String {
717    match status {
718        "default" => "_".to_string(),
719        s if s.ends_with("XX") => {
720            let prefix: u16 = s[..s.len() - 2].parse().unwrap_or(0);
721            let lo = prefix * 100;
722            let hi = lo + 99;
723            format!("{lo}..={hi}")
724        }
725        s => s.to_string(),
726    }
727}
728
729pub fn pick_body_type(b: &IrRequestBody) -> Option<IrTypeExpr> {
730    pick_body_content(b).map(|(_, t)| t)
731}
732
733pub fn pick_response_type(r: &IrResponse) -> Option<IrTypeExpr> {
734    pick_response_content(r).map(|(_, t)| t)
735}
736
737fn pick_body_content(b: &IrRequestBody) -> Option<(String, IrTypeExpr)> {
738    pick_media_type(&b.content, |media_type| {
739        media_type_base(media_type) == "application/json"
740    })
741    .or_else(|| pick_media_type(&b.content, is_json_media_type))
742    .or_else(|| {
743        pick_media_type(&b.content, |media_type| {
744            media_type_base(media_type) == "multipart/form-data"
745        })
746    })
747    .or_else(|| {
748        pick_media_type(&b.content, |media_type| {
749            media_type_base(media_type) == "application/x-www-form-urlencoded"
750        })
751    })
752    .or_else(|| pick_media_type(&b.content, is_xml_media_type))
753    .or_else(|| {
754        pick_media_type(&b.content, |media_type| {
755            media_type_base(media_type) == "text/plain"
756        })
757    })
758    .or_else(|| {
759        pick_media_type(&b.content, |media_type| {
760            media_type_base(media_type) == "application/octet-stream"
761        })
762    })
763    .or_else(|| pick_first_content(&b.content))
764}
765
766fn pick_response_content(r: &IrResponse) -> Option<(String, IrTypeExpr)> {
767    pick_media_type(&r.content, |media_type| {
768        media_type_base(media_type) == "application/json"
769    })
770    .or_else(|| pick_media_type(&r.content, is_json_media_type))
771    .or_else(|| {
772        pick_media_type(&r.content, |media_type| {
773            media_type_base(media_type) == "application/octet-stream"
774        })
775    })
776    .or_else(|| {
777        pick_media_type(&r.content, |media_type| {
778            media_type_base(media_type) == "text/plain"
779        })
780    })
781    .or_else(|| pick_media_type(&r.content, is_xml_media_type))
782    .or_else(|| pick_first_content(&r.content))
783}
784
785fn pick_media_type(
786    content: &indexmap::IndexMap<String, IrTypeExpr>,
787    predicate: impl Fn(&str) -> bool,
788) -> Option<(String, IrTypeExpr)> {
789    content
790        .iter()
791        .find(|(media_type, _)| predicate(media_type))
792        .map(|(media_type, t)| (media_type.clone(), t.clone()))
793}
794
795fn pick_first_content(
796    content: &indexmap::IndexMap<String, IrTypeExpr>,
797) -> Option<(String, IrTypeExpr)> {
798    content
799        .iter()
800        .next()
801        .map(|(media_type, t)| (media_type.clone(), t.clone()))
802}
803
804fn body_encoding(media_type: &str) -> BodyEncoding {
805    let base = media_type_base(media_type);
806    match base.as_str() {
807        "application/json" => BodyEncoding::Json,
808        "application/x-www-form-urlencoded" => BodyEncoding::FormUrlEncoded,
809        "multipart/form-data" => BodyEncoding::Multipart,
810        "application/xml" | "text/xml" => BodyEncoding::Xml,
811        "text/plain" => BodyEncoding::TextPlain,
812        "application/octet-stream" => BodyEncoding::OctetStream,
813        _ if is_json_media_type(media_type) => BodyEncoding::Json,
814        _ if is_xml_media_type(media_type) => BodyEncoding::Xml,
815        _ => BodyEncoding::Other(media_type.to_string()),
816    }
817}
818
819fn response_decoding(media_type: &str) -> ResponseDecoding {
820    let base = media_type_base(media_type);
821    match base.as_str() {
822        "application/json" => ResponseDecoding::Json,
823        "application/xml" | "text/xml" => ResponseDecoding::Xml,
824        "text/plain" => ResponseDecoding::TextPlain,
825        "application/octet-stream" => ResponseDecoding::OctetStream,
826        _ if is_json_media_type(media_type) => ResponseDecoding::Json,
827        _ if is_xml_media_type(media_type) => ResponseDecoding::Xml,
828        _ => ResponseDecoding::Other(media_type.to_string()),
829    }
830}
831
832fn media_type_base(media_type: &str) -> String {
833    media_type
834        .split(';')
835        .next()
836        .unwrap_or(media_type)
837        .trim()
838        .to_ascii_lowercase()
839}
840
841fn is_json_media_type(media_type: &str) -> bool {
842    let base = media_type_base(media_type);
843    base == "application/json" || base.ends_with("+json")
844}
845
846fn is_xml_media_type(media_type: &str) -> bool {
847    let base = media_type_base(media_type);
848    base == "application/xml" || base == "text/xml" || base.ends_with("+xml")
849}
850
851pub fn rust_field_name(wire_name: &str) -> String {
852    escape_rust_keyword(&wire_name.to_snake_case())
853}
854
855fn escape_rust_keyword(name: &str) -> String {
856    const KEYWORDS: &[&str] = &[
857        "as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
858        "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move",
859        "mut", "pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait",
860        "true", "type", "union", "unsafe", "use", "where", "while", "yield",
861    ];
862    if KEYWORDS.contains(&name) {
863        format!("r#{name}")
864    } else {
865        name.to_string()
866    }
867}
868
869pub fn rust_string_literal(value: &str) -> String {
870    format!("{value:?}")
871}
872
873pub fn text_field_expr(base: &str, part: &MultipartPart) -> String {
874    let field_name = rust_field_name(&part.wire_name);
875    match part.value_encoding {
876        MultipartValueEncoding::Text => format!("{base}.{field_name}.to_string()"),
877        MultipartValueEncoding::Json => {
878            format!("serde_json::to_string(&{base}.{field_name}).map_err(Error::Deserialize)?")
879        }
880        MultipartValueEncoding::Unsupported => {
881            unreachable!("unsupported multipart parts are emitted before value expressions")
882        }
883    }
884}
885
886pub fn binary_field_expr(base: &str, part: &MultipartPart) -> String {
887    format!("{base}.{}.data.clone()", rust_field_name(&part.wire_name))
888}
889
890pub fn optional_text_field_expr(value: &str, part: &MultipartPart) -> String {
891    match part.value_encoding {
892        MultipartValueEncoding::Text => format!("{value}.to_string()"),
893        MultipartValueEncoding::Json => {
894            format!("serde_json::to_string({value}).map_err(Error::Deserialize)?")
895        }
896        MultipartValueEncoding::Unsupported => {
897            unreachable!("unsupported multipart parts are emitted before value expressions")
898        }
899    }
900}
901
902pub fn optional_binary_field_expr(value: &str) -> String {
903    format!("{value}.data.clone()")
904}
905
906pub fn binary_filename_expr(base: &str, part: &MultipartPart) -> String {
907    format!(
908        "{base}.{}.filename_or_default({}).to_string()",
909        rust_field_name(&part.wire_name),
910        rust_string_literal(&part.default_filename)
911    )
912}
913
914pub fn optional_binary_filename_expr(value: &str, part: &MultipartPart) -> String {
915    format!(
916        "{value}.filename_or_default({}).to_string()",
917        rust_string_literal(&part.default_filename)
918    )
919}
920
921pub fn response_value_expr(tr: &TypedResponse, bytes_var: &str) -> String {
922    let owned_bytes_expr = bytes_var.strip_prefix('&').unwrap_or(bytes_var);
923    match tr.decoding {
924        ResponseDecoding::Json => {
925            format!("serde_json::from_slice({bytes_var}).map_err(Error::Deserialize)")
926        }
927        ResponseDecoding::Xml => {
928            format!(
929                "serde_xml_rs::from_reader(std::io::Cursor::new({bytes_var})).map_err(Error::Xml)"
930            )
931        }
932        ResponseDecoding::TextPlain => {
933            format!("Ok::<String, Error>(String::from_utf8_lossy({bytes_var}).into_owned())")
934        }
935        ResponseDecoding::OctetStream => {
936            format!("Ok::<Vec<u8>, Error>({owned_bytes_expr}.to_vec())")
937        }
938        ResponseDecoding::Other(_) => {
939            format!("serde_json::from_slice({bytes_var}).map_err(Error::Deserialize)")
940        }
941    }
942}
943
944pub fn response_value_expr_from_str(tr: &TypedResponse, body_var: &str) -> String {
945    match tr.decoding {
946        ResponseDecoding::Json => {
947            format!("serde_json::from_str({body_var}).map_err(Error::Deserialize)")
948        }
949        ResponseDecoding::Xml => {
950            format!("serde_xml_rs::from_str({body_var}).map_err(Error::Xml)")
951        }
952        ResponseDecoding::TextPlain => format!("Ok::<String, Error>({body_var})"),
953        ResponseDecoding::OctetStream => {
954            format!("Ok::<Vec<u8>, Error>({body_var}.into_bytes())")
955        }
956        ResponseDecoding::Other(_) => {
957            format!("serde_json::from_str({body_var}).map_err(Error::Deserialize)")
958        }
959    }
960}
961
962pub fn response_needs_bytes(typed_responses: &[TypedResponse]) -> bool {
963    typed_responses
964        .iter()
965        .any(|tr| matches!(tr.decoding, ResponseDecoding::OctetStream))
966}
967
968pub fn render_to_string(var: &str, type_expr: &IrTypeExpr, _is_optional: bool) -> String {
969    match type_expr {
970        IrTypeExpr::Array(_) => {
971            format!("{var}.iter().map(ToString::to_string).collect::<Vec<_>>().join(\",\")")
972        }
973        _ => format!("{var}.to_string()"),
974    }
975}
976
977pub fn is_copy_type(ty: &str) -> bool {
978    matches!(
979        ty,
980        "bool" | "i32" | "i64" | "f32" | "f64" | "u8" | "u16" | "u32" | "u64"
981    ) || ty.starts_with("Option<")
982        && is_copy_type(
983            ty.strip_prefix("Option<")
984                .unwrap()
985                .strip_suffix('>')
986                .unwrap_or(""),
987        )
988}
989
990// ---------------------------------------------------------------------------
991// Shared body-emission helpers (used by all Rust backends)
992// ---------------------------------------------------------------------------
993
994/// Emit `let mut result = FooResponse { status_code, field1: None, ... };`
995pub fn emit_result_init(
996    b: &mut CodeBlockBuilder,
997    response_type: &str,
998    typed_responses: &[TypedResponse],
999) {
1000    let mut fields = vec!["status_code".to_string()];
1001    let mut seen: HashSet<String> = HashSet::new();
1002    for tr in typed_responses {
1003        if seen.insert(tr.field_name.clone()) {
1004            fields.push(format!("{}: None", tr.field_name));
1005        }
1006    }
1007    b.add(
1008        &format!(
1009            "let mut result = {response_type} {{ {} }};\n",
1010            fields.join(", ")
1011        ),
1012        (),
1013    );
1014}
1015
1016pub fn emit_empty_result_init(b: &mut CodeBlockBuilder, response_type: &str) {
1017    b.add(
1018        &format!("let result = {response_type} {{ status_code }};\n"),
1019        (),
1020    );
1021}
1022
1023/// Emit `match status_code { ... }` dispatching deserialized bodies into result fields.
1024pub fn emit_response_match(
1025    b: &mut CodeBlockBuilder,
1026    typed_responses: &[TypedResponse],
1027    value_expr: &dyn Fn(&TypedResponse) -> String,
1028) {
1029    b.begin_control_flow("match status_code", ());
1030    let mut seen: HashSet<String> = HashSet::new();
1031    for tr in typed_responses {
1032        if !seen.insert(format!("{}-{}", tr.status, tr.field_name)) {
1033            continue;
1034        }
1035        let status_pattern = status_match_pattern(&tr.status);
1036        let value_expr = value_expr(tr);
1037        b.begin_control_flow(&format!("{status_pattern} =>"), ());
1038        b.add(
1039            &format!("result.{} = Some({value_expr}?);\n", tr.field_name),
1040            (),
1041        );
1042        b.end_control_flow();
1043    }
1044    if !typed_responses.iter().any(|tr| tr.status == "default") {
1045        b.add("_ => {}\n", ());
1046    }
1047    b.end_control_flow();
1048}
1049
1050pub fn emit_error_response_match(
1051    b: &mut CodeBlockBuilder,
1052    error_type: &str,
1053    error_responses: &[ErrorResponse],
1054    value_expr: &dyn Fn(&ErrorResponse) -> String,
1055) {
1056    b.begin_control_flow("if !(200..300).contains(&status_code)", ());
1057    b.begin_control_flow("match status_code", ());
1058
1059    let mut seen: HashSet<String> = HashSet::new();
1060    for er in error_responses
1061        .iter()
1062        .filter(|er| er.status.parse::<u16>().is_ok())
1063    {
1064        let key = format!("{}-{}", er.status, er.variant_name);
1065        if !seen.insert(key) {
1066            continue;
1067        }
1068        let pattern = status_match_pattern(&er.status);
1069        let body_expr = value_expr(er);
1070        b.begin_control_flow(&format!("{pattern} =>"), ());
1071        b.add(&format!("let body = {body_expr};\n"), ());
1072        b.add(&format!(
1073            "return Err({error_type}::{}(ApiError::new(status_code, response_headers.clone(), body_bytes.to_vec(), body)));\n",
1074            er.variant_name
1075        ), ());
1076        b.end_control_flow();
1077    }
1078
1079    for er in error_responses
1080        .iter()
1081        .filter(|er| er.status.ends_with("XX") && er.status.parse::<u16>().is_err())
1082    {
1083        let key = format!("{}-{}", er.status, er.variant_name);
1084        if !seen.insert(key) {
1085            continue;
1086        }
1087        let pattern = status_match_pattern(&er.status);
1088        let body_expr = value_expr(er);
1089        b.begin_control_flow(&format!("{pattern} =>"), ());
1090        b.add(&format!("let body = {body_expr};\n"), ());
1091        b.add(&format!(
1092            "return Err({error_type}::{}(ApiError::new(status_code, response_headers.clone(), body_bytes.to_vec(), body)));\n",
1093            er.variant_name
1094        ), ());
1095        b.end_control_flow();
1096    }
1097
1098    if let Some(er) = error_responses
1099        .iter()
1100        .find(|er| er.status.eq_ignore_ascii_case("default"))
1101    {
1102        let body_expr = value_expr(er);
1103        b.begin_control_flow("_ =>", ());
1104        b.add(&format!("let body = {body_expr};\n"), ());
1105        b.add(&format!(
1106            "return Err({error_type}::{}(ApiError::new(status_code, response_headers.clone(), body_bytes.to_vec(), body)));\n",
1107            er.variant_name
1108        ), ());
1109        b.end_control_flow();
1110    } else {
1111        b.begin_control_flow("_ =>", ());
1112        b.add(
1113            "let body = Ok::<Vec<u8>, Error>(body_bytes.to_vec());\n",
1114            (),
1115        );
1116        b.add(&format!(
1117            "return Err({error_type}::Unexpected(ApiError::new(status_code, response_headers.clone(), body_bytes.to_vec(), body)));\n"
1118        ), ());
1119        b.end_control_flow();
1120    }
1121
1122    b.end_control_flow();
1123    b.end_control_flow();
1124}
1125
1126pub fn error_response_value_expr(er: &ErrorResponse, bytes_var: &str) -> String {
1127    let owned_bytes_expr = bytes_var.strip_prefix('&').unwrap_or(bytes_var);
1128    match er.decoding {
1129        Some(ResponseDecoding::Json) => {
1130            format!("serde_json::from_slice({bytes_var}).map_err(Error::Deserialize)")
1131        }
1132        Some(ResponseDecoding::Xml) => {
1133            format!(
1134                "serde_xml_rs::from_reader(std::io::Cursor::new({bytes_var})).map_err(Error::Xml)"
1135            )
1136        }
1137        Some(ResponseDecoding::TextPlain) => {
1138            format!("Ok::<String, Error>(String::from_utf8_lossy({bytes_var}).into_owned())")
1139        }
1140        Some(ResponseDecoding::OctetStream) => {
1141            format!("Ok::<Vec<u8>, Error>({owned_bytes_expr}.to_vec())")
1142        }
1143        Some(ResponseDecoding::Other(_)) => {
1144            format!("serde_json::from_slice({bytes_var}).map_err(Error::Deserialize)")
1145        }
1146        None => "Ok::<(), Error>(())".to_string(),
1147    }
1148}