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