use std::collections::{BTreeMap, HashSet};
use crate::codegen::traits::file_writer::FileInfo;
use crate::ir::types::{
IrOperation, IrParameter, IrRequestBody, IrResponse, IrSpec, IrTypeExpr, ParameterLocation,
};
use heck::{ToPascalCase, ToSnakeCase};
use sigil_stitch::code_block::{CodeBlock, CodeBlockBuilder};
use sigil_stitch::spec::annotation_spec::AnnotationSpec;
use sigil_stitch::spec::field_spec::FieldSpec;
use sigil_stitch::spec::file_spec::FileSpec;
use sigil_stitch::spec::import_spec::ImportSpec;
use sigil_stitch::spec::modifiers::{TypeKind, Visibility};
use sigil_stitch::spec::type_spec::TypeSpec;
use sigil_stitch::type_name::TypeName;
use super::config::ExtraDeriveConfig;
use super::emit_models::rust_type_str_qualified;
pub struct RustBackendConfig {
pub is_async: bool,
pub struct_generics: Option<String>,
pub client_type_args: Option<String>,
}
pub fn generate_api_files(
ir: &IrSpec,
header: &str,
config: &RustBackendConfig,
response_extra_derives: Option<&ExtraDeriveConfig>,
body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
) -> Result<Vec<FileInfo>, String> {
let by_tag = group_by_tag(&ir.operations);
let mut files = Vec::with_capacity(by_tag.len());
let mut mod_entries = Vec::new();
for (tag, ops) in &by_tag {
let stem = tag.to_snake_case();
let filename = format!("{stem}.rs");
mod_entries.push(stem);
let body = emit_api_file(tag, ops, config, response_extra_derives, body_emitter);
let content = format!("{header}{body}");
files.push(FileInfo::api(filename, content));
}
let mut mod_content = String::from(header);
for entry in &mod_entries {
mod_content.push_str(&format!("mod {entry};\npub use {entry}::*;\n"));
}
files.push(FileInfo::api("mod.rs".to_string(), mod_content));
Ok(files)
}
fn group_by_tag(operations: &[IrOperation]) -> BTreeMap<String, Vec<&IrOperation>> {
let mut out: BTreeMap<String, Vec<&IrOperation>> = BTreeMap::new();
for op in operations {
let tags: Vec<String> = if op.tags.is_empty() {
vec!["default".to_string()]
} else {
op.tags.clone()
};
for tag in tags {
out.entry(tag).or_default().push(op);
}
}
out
}
fn emit_api_file(
tag: &str,
ops: &[&IrOperation],
config: &RustBackendConfig,
response_extra_derives: Option<&ExtraDeriveConfig>,
body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
) -> String {
let struct_name = format!("{}Api", tag.to_pascal_case());
let plans: Vec<OpPlan> = ops.iter().map(|op| plan_operation(op)).collect();
let stem = tag.to_snake_case();
let mut fsb = FileSpec::builder(&format!("{stem}.rs"));
fsb = fsb.add_import(ImportSpec::named("crate::runtime::client", "Client"));
fsb = fsb.add_import(ImportSpec::named("crate::runtime::error", "Error"));
let (struct_gen, impl_gen, type_args, client_field_args) = match &config.struct_generics {
Some(g) => {
let client_args = config.client_type_args.as_deref().unwrap_or("");
let param_name = g.split(':').next().unwrap_or(g).trim();
(
format!("<'a, {g}>"),
format!("<'a, {g}>"),
format!("<'a, {param_name}>"),
client_args.to_string(),
)
}
None => (
"<'a>".to_string(),
"<'a>".to_string(),
"<'a>".to_string(),
String::new(),
),
};
let mut body = CodeBlock::builder();
body.add(&format!("/// API operations under the \"{tag}\" tag."), ());
body.add_line();
body.add(&format!("pub struct {struct_name}{struct_gen}"), ());
body.begin_control_flow("", ());
body.add(&format!("client: &'a Client{client_field_args},\n"), ());
body.end_control_flow();
body.add_line();
body.add(&format!("impl{impl_gen} {struct_name}{type_args}"), ());
body.begin_control_flow("", ());
body.add(
&format!("/// Create a new `{struct_name}` bound to the given client."),
(),
);
body.add_line();
body.add(
&format!("pub fn new(client: &'a Client{client_field_args}) -> Self"),
(),
);
body.begin_control_flow("", ());
body.add("Self", ());
body.begin_control_flow("", ());
body.add("client,\n", ());
body.end_control_flow();
body.end_control_flow();
for plan in &plans {
body.add_line();
body.add_code(emit_operation(plan, config, body_emitter));
}
body.end_control_flow();
fsb = fsb.add_code(body.build().expect("body builds"));
for plan in &plans {
fsb = fsb.add_type(emit_response_struct(plan, response_extra_derives));
}
let file = fsb.build().expect("FileSpec builds");
file.render(100).expect("FileSpec renders")
}
pub struct OpPlan<'a> {
pub op: &'a IrOperation,
pub method_name: String,
pub response_type: String,
pub path_params: Vec<ParamBinding<'a>>,
pub query_params: Vec<ParamBinding<'a>>,
pub header_params: Vec<ParamBinding<'a>>,
pub body: Option<BodyBinding>,
pub typed_responses: Vec<TypedResponse>,
}
pub struct ParamBinding<'a> {
pub param: &'a IrParameter,
pub var_name: String,
pub rust_type: String,
pub is_optional: bool,
}
pub struct BodyBinding {
pub var_name: String,
pub rust_type: String,
}
pub struct TypedResponse {
pub status: String,
pub field_name: String,
pub rust_type: String,
}
pub fn plan_operation<'a>(op: &'a IrOperation) -> OpPlan<'a> {
let op_id = sanitize_operation_id(&op.operation_id, &op.method, &op.path);
let method_name = op_id.to_snake_case();
let response_type = format!("{}Response", op_id.to_pascal_case());
let mut used_names: HashSet<String> = HashSet::new();
used_names.insert("self".to_string());
let mut path_params = Vec::new();
let mut query_params = Vec::new();
let mut header_params = Vec::new();
for p in &op.parameters {
let var_name = unique_name(&p.name.to_snake_case(), &mut used_names);
let (rust_type, is_optional) = param_rust_type(p);
let binding = ParamBinding {
param: p,
var_name,
rust_type,
is_optional,
};
match p.location {
ParameterLocation::Path => path_params.push(binding),
ParameterLocation::Query => query_params.push(binding),
ParameterLocation::Header => header_params.push(binding),
ParameterLocation::Cookie => header_params.push(binding),
}
}
let body = op
.request_body
.as_ref()
.and_then(|b| plan_body(b, &mut used_names));
let typed_responses = op.responses.iter().filter_map(plan_response).collect();
OpPlan {
op,
method_name,
response_type,
path_params,
query_params,
header_params,
body,
typed_responses,
}
}
pub fn plan_body(b: &IrRequestBody, used_names: &mut HashSet<String>) -> Option<BodyBinding> {
let t = pick_body_type(b)?;
let rust_type = rust_type_str_qualified(&t);
let var_name = unique_name("body", used_names);
Some(BodyBinding {
var_name,
rust_type,
})
}
pub fn plan_response(r: &IrResponse) -> Option<TypedResponse> {
let t = pick_response_type(r)?;
let rust_type = rust_type_str_qualified(&t);
Some(TypedResponse {
status: r.status.clone(),
field_name: response_field_name(&r.status),
rust_type,
})
}
pub fn param_rust_type(p: &IrParameter) -> (String, bool) {
let base = rust_type_str_qualified(&p.type_expr);
if p.required {
(base, false)
} else {
(format!("Option<{base}>"), true)
}
}
pub fn unique_name(desired: &str, used: &mut HashSet<String>) -> String {
if used.insert(desired.to_string()) {
return desired.to_string();
}
for i in 2..=u32::MAX {
let candidate = format!("{desired}_{i}");
if used.insert(candidate.clone()) {
return candidate;
}
}
unreachable!("name collision space exhausted")
}
fn emit_operation(
plan: &OpPlan<'_>,
config: &RustBackendConfig,
body_emitter: &dyn Fn(&OpPlan<'_>) -> CodeBlock,
) -> CodeBlock {
let OpPlan {
op,
method_name,
response_type,
..
} = plan;
let mut b = CodeBlock::builder();
if let Some(summary) = &op.summary {
for line in summary.lines() {
if line.is_empty() {
b.add("///\n", ());
} else {
b.add(&format!("/// {line}\n"), ());
}
}
} else {
b.add(
&format!("/// {} {}\n", op.method.to_uppercase(), op.path),
(),
);
}
if let Some(desc) = &op.description {
b.add("///\n", ());
for line in desc.lines() {
if line.is_empty() {
b.add("///\n", ());
} else {
b.add(&format!("/// {line}\n"), ());
}
}
}
let mut params = Vec::new();
params.push("&self".to_string());
for p in plan
.path_params
.iter()
.chain(&plan.query_params)
.chain(&plan.header_params)
{
let ty = if is_copy_type(&p.rust_type) {
p.rust_type.clone()
} else if p.rust_type == "String" {
"&str".to_string()
} else if let Some(inner) = p
.rust_type
.strip_prefix("Vec<")
.and_then(|s| s.strip_suffix('>'))
{
format!("&[{inner}]")
} else {
format!("&{}", p.rust_type)
};
params.push(format!("{}: {ty}", p.var_name));
}
if let Some(body) = &plan.body {
params.push(format!("{}: &{}", body.var_name, body.rust_type));
}
let async_kw = if config.is_async { "async " } else { "" };
b.add(
&format!(
"pub {async_kw}fn {method_name}(\n {},\n) -> Result<{response_type}, Error>",
params.join(",\n "),
),
(),
);
b.begin_control_flow("", ());
b.add_code(body_emitter(plan));
b.end_control_flow();
b.build().unwrap()
}
pub fn emit_response_struct(plan: &OpPlan<'_>, extra: Option<&ExtraDeriveConfig>) -> TypeSpec {
let mut tb = TypeSpec::builder(&plan.response_type, TypeKind::Struct);
tb = tb.visibility(Visibility::Public);
tb = tb.doc(&format!("Response from `{}`.", plan.method_name));
let mut ann = AnnotationSpec::new("derive");
ann = ann.arg("Debug");
if let Some(cfg) = extra {
for d in &cfg.derives {
ann = ann.arg(d);
}
}
tb = tb.annotate(ann);
{
let fb = FieldSpec::builder("status_code", TypeName::primitive("u16"));
let fb = fb.visibility(Visibility::Public);
tb = tb.add_field(fb.build().expect("FieldSpec builds"));
}
let mut seen: HashSet<String> = HashSet::new();
for tr in &plan.typed_responses {
if !seen.insert(tr.field_name.clone()) {
continue;
}
let fb = FieldSpec::builder(
&tr.field_name,
TypeName::raw(&format!("Option<{}>", tr.rust_type)),
);
let fb = fb.visibility(Visibility::Public);
tb = tb.add_field(fb.build().expect("FieldSpec builds"));
}
tb.build().expect("TypeSpec builds")
}
pub fn sanitize_operation_id(id: &str, method: &str, path: &str) -> String {
if !id.is_empty() {
return id.to_string();
}
format!(
"{}_{}",
method,
path.replace('/', "_").replace(['{', '}'], "")
)
}
pub fn response_field_name(status: &str) -> String {
match status {
"200" => "data".to_string(),
"201" => "created".to_string(),
"204" => "no_content".to_string(),
"default" => "error_body".to_string(),
s if s.ends_with("XX") => {
let prefix = &s[..s.len() - 2];
format!("status_{prefix}xx")
}
s => format!("status_{s}"),
}
}
pub fn status_match_pattern(status: &str) -> String {
match status {
"default" => "_".to_string(),
s if s.ends_with("XX") => {
let prefix: u16 = s[..s.len() - 2].parse().unwrap_or(0);
let lo = prefix * 100;
let hi = lo + 99;
format!("{lo}..={hi}")
}
s => s.to_string(),
}
}
pub fn pick_body_type(b: &IrRequestBody) -> Option<IrTypeExpr> {
b.content
.get("application/json")
.or_else(|| b.content.values().next())
.cloned()
}
pub fn pick_response_type(r: &IrResponse) -> Option<IrTypeExpr> {
r.content
.get("application/json")
.or_else(|| r.content.values().next())
.cloned()
}
pub fn render_to_string(var: &str, type_expr: &IrTypeExpr, _is_optional: bool) -> String {
match type_expr {
IrTypeExpr::Array(_) => {
format!("{var}.iter().map(ToString::to_string).collect::<Vec<_>>().join(\",\")")
}
_ => format!("{var}.to_string()"),
}
}
pub fn is_copy_type(ty: &str) -> bool {
matches!(
ty,
"bool" | "i32" | "i64" | "f32" | "f64" | "u8" | "u16" | "u32" | "u64"
) || ty.starts_with("Option<")
&& is_copy_type(
ty.strip_prefix("Option<")
.unwrap()
.strip_suffix('>')
.unwrap_or(""),
)
}
pub fn emit_result_init(
b: &mut CodeBlockBuilder,
response_type: &str,
typed_responses: &[TypedResponse],
) {
let mut fields = vec!["status_code".to_string()];
let mut seen: HashSet<String> = HashSet::new();
for tr in typed_responses {
if seen.insert(tr.field_name.clone()) {
fields.push(format!("{}: None", tr.field_name));
}
}
b.add(
&format!(
"let mut result = {response_type} {{ {} }};\n",
fields.join(", ")
),
(),
);
}
pub fn emit_response_match(
b: &mut CodeBlockBuilder,
typed_responses: &[TypedResponse],
deser_expr: &str,
) {
b.begin_control_flow("match status_code", ());
let mut seen: HashSet<String> = HashSet::new();
for tr in typed_responses {
if !seen.insert(format!("{}-{}", tr.status, tr.field_name)) {
continue;
}
let status_pattern = status_match_pattern(&tr.status);
b.begin_control_flow(&format!("{status_pattern} =>"), ());
b.add(
&format!(
"result.{} = Some({deser_expr}.map_err(Error::Deserialize)?);\n",
tr.field_name
),
(),
);
b.end_control_flow();
}
if !typed_responses.iter().any(|tr| tr.status == "default") {
b.add("_ => {}\n", ());
}
b.end_control_flow();
}