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