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