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 b.add(&format!("/// {summary}\n"), ());
335 } else {
336 b.add(
337 &format!("/// {} {}\n", op.method.to_uppercase(), op.path),
338 (),
339 );
340 }
341 if let Some(desc) = &op.description {
342 b.add("///\n", ());
343 for line in desc.lines() {
344 b.add(&format!("/// {line}\n"), ());
345 }
346 }
347
348 let mut params = Vec::new();
350 params.push("&self".to_string());
351 for p in plan
352 .path_params
353 .iter()
354 .chain(&plan.query_params)
355 .chain(&plan.header_params)
356 {
357 let ty = if is_copy_type(&p.rust_type) {
358 p.rust_type.clone()
359 } else {
360 format!("&{}", p.rust_type)
361 };
362 params.push(format!("{}: {ty}", p.var_name));
363 }
364 if let Some(body) = &plan.body {
365 params.push(format!("{}: &{}", body.var_name, body.rust_type));
366 }
367
368 let async_kw = if config.is_async { "async " } else { "" };
369 b.add(
370 &format!(
371 "pub {async_kw}fn {method_name}(\n {},\n) -> Result<{response_type}, Error>",
372 params.join(",\n "),
373 ),
374 (),
375 );
376 b.begin_control_flow("", ());
377
378 b.add_code(body_emitter(plan));
380
381 b.end_control_flow();
382 b.build().unwrap()
383}
384
385pub fn emit_response_struct(plan: &OpPlan<'_>, extra: Option<&ExtraDeriveConfig>) -> TypeSpec {
386 let mut tb = TypeSpec::builder(&plan.response_type, TypeKind::Struct);
387 tb = tb.visibility(Visibility::Public);
388 tb = tb.doc(&format!("Response from `{}`.", plan.method_name));
389
390 let mut ann = AnnotationSpec::new("derive");
391 ann = ann.arg("Debug");
392 if let Some(cfg) = extra {
393 for d in &cfg.derives {
394 ann = ann.arg(d);
395 }
396 }
397 tb = tb.annotate(ann);
398
399 {
401 let fb = FieldSpec::builder("status_code", TypeName::primitive("u16"));
402 let fb = fb.visibility(Visibility::Public);
403 tb = tb.add_field(fb.build().expect("FieldSpec builds"));
404 }
405
406 let mut seen: HashSet<String> = HashSet::new();
408 for tr in &plan.typed_responses {
409 if !seen.insert(tr.field_name.clone()) {
410 continue;
411 }
412 let fb = FieldSpec::builder(
413 &tr.field_name,
414 TypeName::raw(&format!("Option<{}>", tr.rust_type)),
415 );
416 let fb = fb.visibility(Visibility::Public);
417 tb = tb.add_field(fb.build().expect("FieldSpec builds"));
418 }
419
420 tb.build().expect("TypeSpec builds")
421}
422
423pub fn sanitize_operation_id(id: &str, method: &str, path: &str) -> String {
428 if !id.is_empty() {
429 return id.to_string();
430 }
431 format!(
432 "{}_{}",
433 method,
434 path.replace('/', "_").replace(['{', '}'], "")
435 )
436}
437
438pub fn response_field_name(status: &str) -> String {
439 match status {
440 "200" => "data".to_string(),
441 "201" => "created".to_string(),
442 "204" => "no_content".to_string(),
443 "default" => "error_body".to_string(),
444 s if s.ends_with("XX") => {
445 let prefix = &s[..s.len() - 2];
446 format!("status_{prefix}xx")
447 }
448 s => format!("status_{s}"),
449 }
450}
451
452pub fn status_match_pattern(status: &str) -> String {
454 match status {
455 "default" => "_".to_string(),
456 s if s.ends_with("XX") => {
457 let prefix: u16 = s[..s.len() - 2].parse().unwrap_or(0);
458 let lo = prefix * 100;
459 let hi = lo + 99;
460 format!("{lo}..={hi}")
461 }
462 s => s.to_string(),
463 }
464}
465
466pub fn pick_body_type(b: &IrRequestBody) -> Option<IrTypeExpr> {
467 b.content
468 .get("application/json")
469 .or_else(|| b.content.values().next())
470 .cloned()
471}
472
473pub fn pick_response_type(r: &IrResponse) -> Option<IrTypeExpr> {
474 r.content
475 .get("application/json")
476 .or_else(|| r.content.values().next())
477 .cloned()
478}
479
480pub fn render_to_string(var: &str, type_expr: &IrTypeExpr, _is_optional: bool) -> String {
481 match type_expr {
482 IrTypeExpr::Array(_) => {
483 format!("{var}.iter().map(ToString::to_string).collect::<Vec<_>>().join(\",\")")
484 }
485 _ => format!("{var}.to_string()"),
486 }
487}
488
489pub fn is_copy_type(ty: &str) -> bool {
490 matches!(
491 ty,
492 "bool" | "i32" | "i64" | "f32" | "f64" | "u8" | "u16" | "u32" | "u64"
493 ) || ty.starts_with("Option<")
494 && is_copy_type(
495 ty.strip_prefix("Option<")
496 .unwrap()
497 .strip_suffix('>')
498 .unwrap_or(""),
499 )
500}
501
502pub fn emit_result_init(
508 b: &mut CodeBlockBuilder,
509 response_type: &str,
510 typed_responses: &[TypedResponse],
511) {
512 let mut fields = vec!["status_code".to_string()];
513 let mut seen: HashSet<String> = HashSet::new();
514 for tr in typed_responses {
515 if seen.insert(tr.field_name.clone()) {
516 fields.push(format!("{}: None", tr.field_name));
517 }
518 }
519 b.add(
520 &format!(
521 "let mut result = {response_type} {{ {} }};\n",
522 fields.join(", ")
523 ),
524 (),
525 );
526}
527
528pub fn emit_response_match(
530 b: &mut CodeBlockBuilder,
531 typed_responses: &[TypedResponse],
532 deser_expr: &str,
533) {
534 b.begin_control_flow("match status_code", ());
535 let mut seen: HashSet<String> = HashSet::new();
536 for tr in typed_responses {
537 if !seen.insert(format!("{}-{}", tr.status, tr.field_name)) {
538 continue;
539 }
540 let status_pattern = status_match_pattern(&tr.status);
541 b.begin_control_flow(&format!("{status_pattern} =>"), ());
542 b.add(
543 &format!(
544 "result.{} = Some({deser_expr}.map_err(Error::Deserialize)?);\n",
545 tr.field_name
546 ),
547 (),
548 );
549 b.end_control_flow();
550 }
551 if !typed_responses.iter().any(|tr| tr.status == "default") {
552 b.add("_ => {}\n", ());
553 }
554 b.end_control_flow();
555}