1use std::collections::{BTreeMap, HashSet};
11
12use crate::codegen::traits::file_writer::FileInfo;
13use crate::ir::types::{
14 IrOperation, IrParameter, IrRequestBody, IrResponse, IrSpec, IrTypeExpr, ParameterLocation,
15};
16use heck::{ToPascalCase, ToSnakeCase};
17use sigil_stitch::code_block::{CodeBlock, CodeBlockBuilder};
18use sigil_stitch::prelude::sigil_quote;
19use sigil_stitch::spec::annotation_spec::AnnotationSpec;
20use sigil_stitch::spec::field_spec::FieldSpec;
21use sigil_stitch::spec::file_spec::FileSpec;
22use sigil_stitch::spec::import_spec::ImportSpec;
23use sigil_stitch::spec::modifiers::{TypeKind, Visibility};
24use sigil_stitch::spec::type_spec::TypeSpec;
25use sigil_stitch::type_name::TypeName;
26
27use super::config::ExtraDeriveConfig;
28use super::emit_models::rust_type_str_qualified;
29
30pub struct RustBackendConfig {
36 pub is_async: bool,
38 pub struct_generics: Option<String>,
41 pub client_type_args: Option<String>,
44}
45
46pub fn generate_api_files(
52 ir: &IrSpec,
53 header: &str,
54 config: &RustBackendConfig,
55 response_extra_derives: Option<&ExtraDeriveConfig>,
56 body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
57) -> Result<Vec<FileInfo>, String> {
58 let by_tag = group_by_tag(&ir.operations);
59 let mut files = Vec::with_capacity(by_tag.len());
60 let mut mod_entries = Vec::new();
61
62 for (tag, ops) in &by_tag {
63 let stem = tag.to_snake_case();
64 let filename = format!("{stem}.rs");
65 mod_entries.push(stem);
66 let body = emit_api_file(tag, ops, ir, config, response_extra_derives, body_emitter);
67 let content = format!("{header}{body}");
68 files.push(FileInfo::api(filename, content));
69 }
70
71 let mut mod_content = String::from(header);
73 for entry in &mod_entries {
74 mod_content.push_str(&format!("mod {entry};\npub use {entry}::*;\n"));
75 }
76 files.push(FileInfo::api("mod.rs".to_string(), mod_content));
77
78 Ok(files)
79}
80
81fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
86 let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
87 for op in operations {
88 let tags: Vec<String> = if op.tags.is_empty() {
89 vec!["default".to_string()]
90 } else {
91 op.tags.clone()
92 };
93 for tag in tags {
94 out.entry(tag).or_default().push(op);
95 }
96 }
97 out
98}
99
100fn emit_api_file(
105 tag: &str,
106 ops: &[&IrOperation],
107 ir: &IrSpec,
108 config: &RustBackendConfig,
109 response_extra_derives: Option<&ExtraDeriveConfig>,
110 body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
111) -> String {
112 let struct_name = format!("{}Api", tag.to_pascal_case());
113 let plans: Vec<OpPlan> = ops.iter().map(|op| plan_operation(op, ir)).collect();
114
115 let stem = tag.to_snake_case();
116 let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
117
118 fsb = fsb.add_import(ImportSpec::named("crate::runtime::client", "Client"));
120 fsb = fsb.add_import(ImportSpec::named("crate::runtime::error", "Error"));
121
122 let (struct_gen, impl_gen, type_args, client_field_args) = match &config.struct_generics {
124 Some(g) => {
125 let client_args = config.client_type_args.as_deref().unwrap_or("");
126 let param_name = g.split(':').next().unwrap_or(g).trim();
127 (
128 format!("<'a, {g}>"),
129 format!("<'a, {g}>"),
130 format!("<'a, {param_name}>"),
131 client_args.to_string(),
132 )
133 }
134 None => (
135 "<'a>".to_string(),
136 "<'a>".to_string(),
137 "<'a>".to_string(),
138 String::new(),
139 ),
140 };
141
142 let mut body = CodeBlock::builder();
144
145 let doc_struct = format!("/// API operations under the \"{tag}\" tag.");
147 let generics = struct_gen.as_str();
148 let client_type_suffix = client_field_args.as_str();
149 let client_field = format!("client: &'a Client{client_type_suffix},");
150 body.add_code(
151 sigil_quote!(RustLang {
152 $L(doc_struct)
153 pub struct $N(struct_name.as_str())$L(generics) {
154 $L(client_field)
155 }
156 })
157 .expect("struct sigil_quote builds"),
158 );
159 body.add_line();
160
161 let impl_header = format!("impl{impl_gen} {struct_name}{type_args}");
163 body.add(&impl_header, ());
164 body.begin_control_flow("", ());
165
166 let doc_ctor = format!("/// Create a new `{struct_name}` bound to the given client.");
168 body.add_code(
169 sigil_quote!(RustLang {
170 $L(doc_ctor)
171 pub fn $L("new(client: &'a Client@{client_type_suffix}) -> Self") {
172 Self {
173 client,
174 }
175 }
176 })
177 .expect("constructor sigil_quote builds"),
178 );
179
180 for plan in &plans {
182 body.add_line();
183 body.add_code(emit_operation(plan, config, body_emitter));
184 }
185
186 body.end_control_flow(); fsb = fsb.add_code(body.build().expect("body builds"));
189
190 for plan in &plans {
192 fsb = fsb.add_type(emit_response_struct(plan, response_extra_derives));
193 }
194
195 let file = fsb.build().expect("FileSpec builds");
196 file.render(100).expect("FileSpec renders")
197}
198
199pub struct OpPlan<'a> {
204 pub op: &'a IrOperation,
205 pub method_name: String,
206 pub response_type: String,
207 pub path_params: Vec<ParamBinding<'a>>,
208 pub query_params: Vec<ParamBinding<'a>>,
209 pub header_params: Vec<ParamBinding<'a>>,
210 pub body: Option<BodyBinding>,
211 pub typed_responses: Vec<TypedResponse>,
212}
213
214pub struct ParamBinding<'a> {
215 pub param: &'a IrParameter,
216 pub var_name: String,
217 pub rust_type: String,
218 pub is_optional: bool,
219}
220
221pub struct BodyBinding {
222 pub var_name: String,
223 pub rust_type: String,
224}
225
226pub struct TypedResponse {
227 pub status: String,
228 pub field_name: String,
229 pub rust_type: String,
230}
231
232pub fn plan_operation<'a>(op: &'a IrOperation, ir: &'a IrSpec) -> OpPlan<'a> {
233 let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
234 let method_name = op_id.to_snake_case();
235 let response_type = format!("{}Response", op_id.to_pascal_case());
236
237 let mut used_names: HashSet<String> = HashSet::new();
238 used_names.insert("self".to_string());
239
240 let mut path_params = Vec::new();
241 let mut query_params = Vec::new();
242 let mut header_params = Vec::new();
243 for p in &op.parameters {
244 let var_name = unique_name(&p.name.to_snake_case(), &mut used_names);
245 let (rust_type, is_optional) = param_rust_type(p, ir);
246 let binding = ParamBinding {
247 param: p,
248 var_name,
249 rust_type,
250 is_optional,
251 };
252 match p.location {
253 ParameterLocation::Path => path_params.push(binding),
254 ParameterLocation::Query => query_params.push(binding),
255 ParameterLocation::Header => header_params.push(binding),
256 ParameterLocation::Cookie => header_params.push(binding),
257 }
258 }
259
260 let body = op
261 .request_body
262 .as_ref()
263 .and_then(|b| plan_body(b, &mut used_names, ir));
264
265 let typed_responses = op
266 .responses
267 .iter()
268 .filter_map(|r| plan_response(r, ir))
269 .collect();
270
271 OpPlan {
272 op,
273 method_name,
274 response_type,
275 path_params,
276 query_params,
277 header_params,
278 body,
279 typed_responses,
280 }
281}
282
283pub fn plan_body(
284 b: &IrRequestBody,
285 used_names: &mut HashSet<String>,
286 ir: &IrSpec,
287) -> Option<BodyBinding> {
288 let t = pick_body_type(b)?;
289 let rust_type = rust_type_str_qualified(&t, ir);
290 let var_name = unique_name("body", used_names);
291 Some(BodyBinding {
292 var_name,
293 rust_type,
294 })
295}
296
297pub fn plan_response(r: &IrResponse, ir: &IrSpec) -> Option<TypedResponse> {
298 let t = pick_response_type(r)?;
299 let rust_type = rust_type_str_qualified(&t, ir);
300 Some(TypedResponse {
301 status: r.status.clone(),
302 field_name: response_field_name(&r.status),
303 rust_type,
304 })
305}
306
307pub fn param_rust_type(p: &IrParameter, ir: &IrSpec) -> (String, bool) {
308 let base = rust_type_str_qualified(&p.type_expr, ir);
309 if p.required {
310 (base, false)
311 } else {
312 (format!("Option<{base}>"), true)
313 }
314}
315
316pub fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
317 if used.insert(desired.to_string()) {
318 return desired.to_string();
319 }
320 for i in 2..=u32::MAX {
321 let candidate = format!("{desired}_{i}");
322 if used.insert(candidate.clone()) {
323 return candidate;
324 }
325 }
326 unreachable!("name collision space exhausted")
327}
328
329fn emit_operation(
334 plan: &OpPlan<'_>,
335 config: &RustBackendConfig,
336 body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
337) -> CodeBlock {
338 let OpPlan {
339 op,
340 method_name,
341 response_type,
342 ..
343 } = plan;
344
345 let mut b = CodeBlock::builder();
346
347 if let Some(summary) = &op.summary {
349 for line in summary.lines() {
350 if line.is_empty() {
351 b.add("///\n", ());
352 } else {
353 b.add(&format!("/// {line}\n"), ());
354 }
355 }
356 } else {
357 b.add(
358 &format!("/// {} {}\n", op.method.to_uppercase(), op.path),
359 (),
360 );
361 }
362 if let Some(desc) = &op.description {
363 b.add("///\n", ());
364 for line in desc.lines() {
365 if line.is_empty() {
366 b.add("///\n", ());
367 } else {
368 b.add(&format!("/// {line}\n"), ());
369 }
370 }
371 }
372
373 let mut params = Vec::new();
375 params.push("&self".to_string());
376 for p in plan
377 .path_params
378 .iter()
379 .chain(&plan.query_params)
380 .chain(&plan.header_params)
381 {
382 let ty = if is_copy_type(&p.rust_type) {
383 p.rust_type.clone()
384 } else if p.rust_type == "String" {
385 "&str".to_string()
386 } else if let Some(inner) = p
387 .rust_type
388 .strip_prefix("Vec<")
389 .and_then(|s| s.strip_suffix('>'))
390 {
391 format!("&[{inner}]")
392 } else {
393 format!("&{}", p.rust_type)
394 };
395 params.push(format!("{}: {ty}", p.var_name));
396 }
397 if let Some(body) = &plan.body {
398 params.push(format!("{}: &{}", body.var_name, body.rust_type));
399 }
400
401 let async_kw = if config.is_async { "async " } else { "" };
402 b.add(
403 &format!(
404 "pub {async_kw}fn {method_name}(\n {},\n) -> Result<{response_type}, Error>",
405 params.join(",\n "),
406 ),
407 (),
408 );
409 b.begin_control_flow("", ());
410
411 b.add_code(body_emitter(plan));
413
414 b.end_control_flow();
415 b.build().unwrap()
416}
417
418pub fn emit_response_struct(plan: &OpPlan<'_>, extra: Option<&ExtraDeriveConfig>) -> TypeSpec {
419 let mut tb = TypeSpec::builder(&plan.response_type, TypeKind::Struct);
420 tb = tb.visibility(Visibility::Public);
421 tb = tb.doc(&format!("Response from `{}`.", plan.method_name));
422
423 let mut ann = AnnotationSpec::new("derive").args(["Debug"]);
424 if let Some(cfg) = extra {
425 ann = ann.args(cfg.derives.iter().map(|s| s.as_str()));
426 }
427 tb = tb.annotate(ann);
428
429 {
431 let fb = FieldSpec::builder("status_code", TypeName::primitive("u16"));
432 let fb = fb.visibility(Visibility::Public);
433 tb = tb.add_field(fb.build().expect("FieldSpec builds"));
434 }
435
436 let mut seen: HashSet<String> = HashSet::new();
438 for tr in &plan.typed_responses {
439 if !seen.insert(tr.field_name.clone()) {
440 continue;
441 }
442 let fb = FieldSpec::builder(
443 &tr.field_name,
444 TypeName::raw(&format!("Option<{}>", tr.rust_type)),
445 );
446 let fb = fb.visibility(Visibility::Public);
447 tb = tb.add_field(fb.build().expect("FieldSpec builds"));
448 }
449
450 tb.build().expect("TypeSpec builds")
451}
452
453pub fn sanitize_operation_id(id: &str, method: &str, path: &str) -> String {
458 if !id.is_empty() {
459 return id.to_string();
460 }
461 format!(
462 "{}_{}",
463 method,
464 path.replace('/', "_").replace(['{', '}'], "")
465 )
466}
467
468pub fn response_field_name(status: &str) -> String {
469 match status {
470 "200" => "data".to_string(),
471 "201" => "created".to_string(),
472 "204" => "no_content".to_string(),
473 "default" => "error_body".to_string(),
474 s if s.ends_with("XX") => {
475 let prefix = &s[..s.len() - 2];
476 format!("status_{prefix}xx")
477 }
478 s => format!("status_{s}"),
479 }
480}
481
482pub fn status_match_pattern(status: &str) -> String {
484 match status {
485 "default" => "_".to_string(),
486 s if s.ends_with("XX") => {
487 let prefix: u16 = s[..s.len() - 2].parse().unwrap_or(0);
488 let lo = prefix * 100;
489 let hi = lo + 99;
490 format!("{lo}..={hi}")
491 }
492 s => s.to_string(),
493 }
494}
495
496pub fn pick_body_type(b: &IrRequestBody) -> Option<IrTypeExpr> {
497 b.content
498 .get("application/json")
499 .or_else(|| b.content.values().next())
500 .cloned()
501}
502
503pub fn pick_response_type(r: &IrResponse) -> Option<IrTypeExpr> {
504 r.content
505 .get("application/json")
506 .or_else(|| r.content.values().next())
507 .cloned()
508}
509
510pub fn render_to_string(var: &str, type_expr: &IrTypeExpr, _is_optional: bool) -> String {
511 match type_expr {
512 IrTypeExpr::Array(_) => {
513 format!("{var}.iter().map(ToString::to_string).collect::<Vec<_>>().join(\",\")")
514 }
515 _ => format!("{var}.to_string()"),
516 }
517}
518
519pub fn is_copy_type(ty: &str) -> bool {
520 matches!(
521 ty,
522 "bool" | "i32" | "i64" | "f32" | "f64" | "u8" | "u16" | "u32" | "u64"
523 ) || ty.starts_with("Option<")
524 && is_copy_type(
525 ty.strip_prefix("Option<")
526 .unwrap()
527 .strip_suffix('>')
528 .unwrap_or(""),
529 )
530}
531
532pub fn emit_result_init(
538 b: &mut CodeBlockBuilder,
539 response_type: &str,
540 typed_responses: &[TypedResponse],
541) {
542 let mut fields = vec!["status_code".to_string()];
543 let mut seen: HashSet<String> = HashSet::new();
544 for tr in typed_responses {
545 if seen.insert(tr.field_name.clone()) {
546 fields.push(format!("{}: None", tr.field_name));
547 }
548 }
549 b.add(
550 &format!(
551 "let mut result = {response_type} {{ {} }};\n",
552 fields.join(", ")
553 ),
554 (),
555 );
556}
557
558pub fn emit_response_match(
560 b: &mut CodeBlockBuilder,
561 typed_responses: &[TypedResponse],
562 deser_expr: &str,
563) {
564 b.begin_control_flow("match status_code", ());
565 let mut seen: HashSet<String> = HashSet::new();
566 for tr in typed_responses {
567 if !seen.insert(format!("{}-{}", tr.status, tr.field_name)) {
568 continue;
569 }
570 let status_pattern = status_match_pattern(&tr.status);
571 b.begin_control_flow(&format!("{status_pattern} =>"), ());
572 b.add(
573 &format!(
574 "result.{} = Some({deser_expr}.map_err(Error::Deserialize)?);\n",
575 tr.field_name
576 ),
577 (),
578 );
579 b.end_control_flow();
580 }
581 if !typed_responses.iter().any(|tr| tr.status == "default") {
582 b.add("_ => {}\n", ());
583 }
584 b.end_control_flow();
585}