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