1use std::fmt::Write;
9
10use crate::ir::{ApiKind, ApiSpec, Operation, Param, ParamLocation, StreamingMode};
11use crate::parsers::naming::pascal_ident;
12
13pub fn render(spec: &ApiSpec) -> String {
15 let mut out = String::new();
16 let bin_name = format!("{}-cli", spec.name.replace('_', "-"));
17
18 writeln!(out, "//! `{}` — generated by `oxide-gen`.", bin_name).unwrap();
19 writeln!(out, "//!").unwrap();
20 writeln!(out, "//! Re-run `oxide-gen` to regenerate.").unwrap();
21 writeln!(out).unwrap();
22 writeln!(
23 out,
24 "#![allow(clippy::all, dead_code, unused_imports, unused_variables, unused_mut)]"
25 )
26 .unwrap();
27 writeln!(out).unwrap();
28 writeln!(out, "use clap::{{Parser, Subcommand}};").unwrap();
29 writeln!(out, "use {}::*;", spec.name).unwrap();
30 writeln!(out).unwrap();
31
32 writeln!(
33 out,
34 "#[derive(Parser, Debug)]\n#[command(name = \"{bin}\", about = \"{about}\", version = \"{version}\")]\nstruct Cli {{",
35 bin = bin_name,
36 about = escape(&spec.display_name),
37 version = spec.version,
38 )
39 .unwrap();
40 writeln!(out, " /// Override the API base URL. Falls back to the default baked into the generated client.").unwrap();
41 writeln!(out, " #[arg(long, env = \"OXIDE_BASE_URL\")]").unwrap();
42 writeln!(out, " base_url: Option<String>,").unwrap();
43 writeln!(out).unwrap();
44 writeln!(out, " #[command(subcommand)]").unwrap();
45 writeln!(out, " command: Command,").unwrap();
46 writeln!(out, "}}").unwrap();
47 writeln!(out).unwrap();
48
49 render_command_enum(&mut out, spec);
50
51 writeln!(out, "#[tokio::main]").unwrap();
52 writeln!(out, "async fn main() -> anyhow::Result<()> {{").unwrap();
53 writeln!(out, " let cli = Cli::parse();").unwrap();
54 writeln!(out, " let client = match cli.base_url {{").unwrap();
55 writeln!(out, " Some(url) => Client::new(url),").unwrap();
56 writeln!(out, " None => Client::default_endpoint(),").unwrap();
57 writeln!(out, " }};").unwrap();
58 writeln!(out).unwrap();
59 writeln!(out, " match cli.command {{").unwrap();
60 for op in &spec.operations {
61 render_match_arm(&mut out, op);
62 }
63 writeln!(out, " }}").unwrap();
64 writeln!(out, " Ok(())").unwrap();
65 writeln!(out, "}}").unwrap();
66
67 let _ = spec.kind; if matches!(spec.kind, ApiKind::Grpc) {
69 }
71
72 out
73}
74
75fn render_command_enum(out: &mut String, spec: &ApiSpec) {
76 writeln!(out, "#[derive(Subcommand, Debug)]").unwrap();
77 writeln!(out, "enum Command {{").unwrap();
78 for op in &spec.operations {
79 if let Some(desc) = &op.description {
80 for line in desc.lines() {
81 writeln!(out, " /// {line}").unwrap();
82 }
83 }
84 let variant = pascal_ident(&op.original_id);
85 writeln!(out, " {variant} {{").unwrap();
86 for p in &op.params {
87 render_arg(out, p);
88 }
89 writeln!(out, " }},").unwrap();
90 }
91 writeln!(out, "}}").unwrap();
92 writeln!(out).unwrap();
93}
94
95fn render_arg(out: &mut String, p: &Param) {
96 if let Some(desc) = &p.description {
97 for line in desc.lines() {
98 writeln!(out, " /// {line}").unwrap();
99 }
100 }
101 let is_simple = is_simple(&p.rust_type);
102 let ty = if is_simple {
103 if p.required {
104 p.rust_type.clone()
105 } else {
106 format!("Option<{}>", p.rust_type)
107 }
108 } else {
109 if p.required {
110 "String".to_string()
111 } else {
112 "Option<String>".to_string()
113 }
114 };
115 if !is_simple {
116 writeln!(out, " /// JSON-encoded `{}` value.", p.rust_type).unwrap();
117 }
118 writeln!(out, " #[arg(long)]").unwrap();
119 writeln!(out, " {}: {},", p.name, ty).unwrap();
120}
121
122fn render_match_arm(out: &mut String, op: &Operation) {
123 let variant = pascal_ident(&op.original_id);
124 let bindings = op
125 .params
126 .iter()
127 .map(|p| p.name.clone())
128 .collect::<Vec<_>>()
129 .join(", ");
130 let pattern = if bindings.is_empty() {
131 format!("Command::{variant} {{}}")
132 } else {
133 format!("Command::{variant} {{ {bindings} }}")
134 };
135 writeln!(out, " {pattern} => {{").unwrap();
136
137 for p in &op.params {
139 if is_simple(&p.rust_type) {
140 continue;
141 }
142 if p.required {
143 writeln!(
144 out,
145 " let {n}: {ty} = serde_json::from_str(&{n})?;",
146 n = p.name,
147 ty = p.rust_type
148 )
149 .unwrap();
150 } else {
151 writeln!(
152 out,
153 " let {n}: Option<{ty}> = match {n}.as_deref() {{ Some(s) => Some(serde_json::from_str(s)?), None => None }};",
154 n = p.name,
155 ty = p.rust_type
156 )
157 .unwrap();
158 }
159 }
160
161 let is_client_or_bidi_stream =
162 op.streaming == StreamingMode::ClientStream || op.streaming == StreamingMode::BidiStream;
163 if is_client_or_bidi_stream {
164 let first_param_name = op
165 .params
166 .first()
167 .map(|p| p.name.as_str())
168 .unwrap_or("request");
169 writeln!(
170 out,
171 " let req_stream = futures_util::stream::once(async move {{ {first_param_name} }});"
172 )
173 .unwrap();
174 }
175
176 let call_args = if is_client_or_bidi_stream {
177 "req_stream".to_string()
178 } else {
179 op.params
180 .iter()
181 .map(|p| p.name.clone())
182 .collect::<Vec<_>>()
183 .join(", ")
184 };
185
186 let returns_stream =
187 op.streaming == StreamingMode::ServerStream || op.streaming == StreamingMode::BidiStream;
188 let mut_prefix = if returns_stream { "mut " } else { "" };
189 writeln!(
190 out,
191 " let {mut_prefix}result = client.{name}({call_args}).await?;",
192 name = op.id
193 )
194 .unwrap();
195
196 if returns_stream {
197 writeln!(out, " use futures_util::StreamExt;").unwrap();
198 writeln!(
199 out,
200 " while let Some(item) = result.next().await {{"
201 )
202 .unwrap();
203 writeln!(out, " let item = item?;").unwrap();
204 writeln!(
205 out,
206 " println!(\"{{}}\", serde_json::to_string_pretty(&item)?);"
207 )
208 .unwrap();
209 writeln!(out, " }}").unwrap();
210 } else {
211 writeln!(
212 out,
213 " println!(\"{{}}\", serde_json::to_string_pretty(&result)?);"
214 )
215 .unwrap();
216 }
217 writeln!(out, " }}").unwrap();
218}
219
220fn is_simple(ty: &str) -> bool {
221 matches!(
222 ty,
223 "String"
224 | "i8"
225 | "i16"
226 | "i32"
227 | "i64"
228 | "u8"
229 | "u16"
230 | "u32"
231 | "u64"
232 | "f32"
233 | "f64"
234 | "bool"
235 )
236}
237
238fn escape(s: &str) -> String {
239 s.replace('\\', "\\\\").replace('"', "\\\"")
240}
241
242#[allow(dead_code)]
243fn _silence_unused(p: ParamLocation) {
244 let _ = p;
245}