use std::fmt::Write;
use crate::ir::{ApiKind, ApiSpec, Operation, Param, ParamLocation, StreamingMode};
use crate::parsers::naming::pascal_ident;
pub fn render(spec: &ApiSpec) -> String {
let mut out = String::new();
let bin_name = format!("{}-cli", spec.name.replace('_', "-"));
writeln!(out, "//! `{}` — generated by `oxide-gen`.", bin_name).unwrap();
writeln!(out, "//!").unwrap();
writeln!(out, "//! Re-run `oxide-gen` to regenerate.").unwrap();
writeln!(out).unwrap();
writeln!(
out,
"#![allow(clippy::all, dead_code, unused_imports, unused_variables, unused_mut)]"
)
.unwrap();
writeln!(out).unwrap();
writeln!(out, "use clap::{{Parser, Subcommand}};").unwrap();
writeln!(out, "use {}::*;", spec.name).unwrap();
writeln!(out).unwrap();
writeln!(
out,
"#[derive(Parser, Debug)]\n#[command(name = \"{bin}\", about = \"{about}\", version = \"{version}\")]\nstruct Cli {{",
bin = bin_name,
about = escape(&spec.display_name),
version = spec.version,
)
.unwrap();
writeln!(out, " /// Override the API base URL. Falls back to the default baked into the generated client.").unwrap();
writeln!(out, " #[arg(long, env = \"OXIDE_BASE_URL\")]").unwrap();
writeln!(out, " base_url: Option<String>,").unwrap();
writeln!(out).unwrap();
writeln!(out, " #[command(subcommand)]").unwrap();
writeln!(out, " command: Command,").unwrap();
writeln!(out, "}}").unwrap();
writeln!(out).unwrap();
render_command_enum(&mut out, spec);
writeln!(out, "#[tokio::main]").unwrap();
writeln!(out, "async fn main() -> anyhow::Result<()> {{").unwrap();
writeln!(out, " let cli = Cli::parse();").unwrap();
writeln!(out, " let client = match cli.base_url {{").unwrap();
writeln!(out, " Some(url) => Client::new(url),").unwrap();
writeln!(out, " None => Client::default_endpoint(),").unwrap();
writeln!(out, " }};").unwrap();
writeln!(out).unwrap();
writeln!(out, " match cli.command {{").unwrap();
for op in &spec.operations {
render_match_arm(&mut out, op);
}
writeln!(out, " }}").unwrap();
writeln!(out, " Ok(())").unwrap();
writeln!(out, "}}").unwrap();
let _ = spec.kind; if matches!(spec.kind, ApiKind::Grpc) {
}
out
}
fn render_command_enum(out: &mut String, spec: &ApiSpec) {
writeln!(out, "#[derive(Subcommand, Debug)]").unwrap();
writeln!(out, "enum Command {{").unwrap();
for op in &spec.operations {
if let Some(desc) = &op.description {
for line in desc.lines() {
writeln!(out, " /// {line}").unwrap();
}
}
let variant = pascal_ident(&op.original_id);
writeln!(out, " {variant} {{").unwrap();
for p in &op.params {
render_arg(out, p);
}
writeln!(out, " }},").unwrap();
}
writeln!(out, "}}").unwrap();
writeln!(out).unwrap();
}
fn render_arg(out: &mut String, p: &Param) {
if let Some(desc) = &p.description {
for line in desc.lines() {
writeln!(out, " /// {line}").unwrap();
}
}
let is_simple = is_simple(&p.rust_type);
let ty = if is_simple {
if p.required {
p.rust_type.clone()
} else {
format!("Option<{}>", p.rust_type)
}
} else {
if p.required {
"String".to_string()
} else {
"Option<String>".to_string()
}
};
if !is_simple {
writeln!(out, " /// JSON-encoded `{}` value.", p.rust_type).unwrap();
}
writeln!(out, " #[arg(long)]").unwrap();
writeln!(out, " {}: {},", p.name, ty).unwrap();
}
fn render_match_arm(out: &mut String, op: &Operation) {
let variant = pascal_ident(&op.original_id);
let bindings = op
.params
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>()
.join(", ");
let pattern = if bindings.is_empty() {
format!("Command::{variant} {{}}")
} else {
format!("Command::{variant} {{ {bindings} }}")
};
writeln!(out, " {pattern} => {{").unwrap();
for p in &op.params {
if is_simple(&p.rust_type) {
continue;
}
if p.required {
writeln!(
out,
" let {n}: {ty} = serde_json::from_str(&{n})?;",
n = p.name,
ty = p.rust_type
)
.unwrap();
} else {
writeln!(
out,
" let {n}: Option<{ty}> = match {n}.as_deref() {{ Some(s) => Some(serde_json::from_str(s)?), None => None }};",
n = p.name,
ty = p.rust_type
)
.unwrap();
}
}
let is_client_or_bidi_stream =
op.streaming == StreamingMode::ClientStream || op.streaming == StreamingMode::BidiStream;
if is_client_or_bidi_stream {
let first_param_name = op
.params
.first()
.map(|p| p.name.as_str())
.unwrap_or("request");
writeln!(
out,
" let req_stream = futures_util::stream::once(async move {{ {first_param_name} }});"
)
.unwrap();
}
let call_args = if is_client_or_bidi_stream {
"req_stream".to_string()
} else {
op.params
.iter()
.map(|p| p.name.clone())
.collect::<Vec<_>>()
.join(", ")
};
let returns_stream =
op.streaming == StreamingMode::ServerStream || op.streaming == StreamingMode::BidiStream;
let mut_prefix = if returns_stream { "mut " } else { "" };
writeln!(
out,
" let {mut_prefix}result = client.{name}({call_args}).await?;",
name = op.id
)
.unwrap();
if returns_stream {
writeln!(out, " use futures_util::StreamExt;").unwrap();
writeln!(
out,
" while let Some(item) = result.next().await {{"
)
.unwrap();
writeln!(out, " let item = item?;").unwrap();
writeln!(
out,
" println!(\"{{}}\", serde_json::to_string_pretty(&item)?);"
)
.unwrap();
writeln!(out, " }}").unwrap();
} else {
writeln!(
out,
" println!(\"{{}}\", serde_json::to_string_pretty(&result)?);"
)
.unwrap();
}
writeln!(out, " }}").unwrap();
}
fn is_simple(ty: &str) -> bool {
matches!(
ty,
"String"
| "i8"
| "i16"
| "i32"
| "i64"
| "u8"
| "u16"
| "u32"
| "u64"
| "f32"
| "f64"
| "bool"
)
}
fn escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[allow(dead_code)]
fn _silence_unused(p: ParamLocation) {
let _ = p;
}