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