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