oxide-gen 0.3.0

Spec-to-crate generator for Rust Oxide. Generates Rust clients, CLI commands, SKILL.md, and MCP server configs from OpenAPI, GraphQL, and gRPC specs.
Documentation
//! Emit `src/main.rs` — the `clap`-based CLI wrapper around the generated
//! client.
//!
//! One subcommand per operation. Primitive parameters map to typed `clap`
//! flags directly; complex parameters (custom structs, vectors, JSON values)
//! are accepted as raw JSON strings and parsed at runtime.

use std::fmt::Write;

use crate::ir::{ApiKind, ApiSpec, Operation, Param, ParamLocation, StreamingMode};
use crate::parsers::naming::pascal_ident;

/// Render the full `main.rs` contents.
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; // referenced indirectly through ApiSpec; keep import in scope
    if matches!(spec.kind, ApiKind::Grpc) {
        // No additional helpers needed; grpc methods bail at runtime.
    }

    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();

    // Per-param parsing for complex types.
    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;
}