Skip to main content

oxide_gen/emit/
rust_cli.rs

1//! Emit `src/main.rs` — the `clap`-based CLI wrapper around the generated
2//! client.
3//!
4//! One subcommand per operation. Primitive parameters map to typed `clap`
5//! flags directly; complex parameters (custom structs, vectors, JSON values)
6//! are accepted as raw JSON strings and parsed at runtime.
7
8use std::fmt::Write;
9
10use crate::ir::{ApiKind, ApiSpec, Operation, Param, ParamLocation, StreamingMode};
11use crate::parsers::naming::pascal_ident;
12
13/// Render the full `main.rs` contents.
14pub 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; // referenced indirectly through ApiSpec; keep import in scope
68    if matches!(spec.kind, ApiKind::Grpc) {
69        // No additional helpers needed; grpc methods bail at runtime.
70    }
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    // Per-param parsing for complex types.
138    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}