Skip to main content

openapi_clap/
builder.rs

1//! IR → clap Command tree builder
2//!
3//! Converts `ApiOperation`s into a clap `Command` tree grouped by tags.
4
5use std::collections::{BTreeMap, HashMap};
6
7use clap::{Arg, ArgAction, Command};
8
9use crate::spec::{is_bool_schema, ApiOperation};
10
11/// Configuration for building a CLI from an OpenAPI spec.
12#[derive(Debug, Clone)]
13#[non_exhaustive]
14pub struct CliConfig {
15    /// Root command name (e.g. "runpod", "myapi")
16    pub name: String,
17    /// Root command about/description
18    pub about: String,
19    /// Default base URL for the API
20    pub default_base_url: String,
21}
22
23impl CliConfig {
24    pub fn new(
25        name: impl Into<String>,
26        about: impl Into<String>,
27        default_base_url: impl Into<String>,
28    ) -> Self {
29        Self {
30            name: name.into(),
31            about: about.into(),
32            default_base_url: default_base_url.into(),
33        }
34    }
35}
36
37/// Build a clap `Command` tree from a list of API operations.
38///
39/// Structure: `<name> <group> <operation> [args] [--options]`
40/// Groups are derived from OpenAPI tags (e.g. "pods", "endpoints").
41pub fn build_commands(config: &CliConfig, ops: &[ApiOperation]) -> Command {
42    let root = Command::new(config.name.clone())
43        .about(config.about.clone())
44        .subcommand_required(true)
45        .arg_required_else_help(true)
46        .arg(
47            Arg::new("base-url")
48                .long("base-url")
49                .global(true)
50                .default_value(config.default_base_url.clone())
51                .help("API base URL"),
52        )
53        .arg(
54            Arg::new("output")
55                .long("output")
56                .short('o')
57                .global(true)
58                .default_value("json")
59                .help("Output format: json, compact"),
60        );
61
62    // Group operations by tag
63    let mut groups: BTreeMap<String, Vec<&ApiOperation>> = BTreeMap::new();
64    for op in ops {
65        groups.entry(op.group.clone()).or_default().push(op);
66    }
67
68    let mut root = root;
69    for (group_name, group_ops) in &groups {
70        let mut group_cmd = Command::new(normalize_group(group_name))
71            .about(format!("Manage {group_name}"))
72            .subcommand_required(true)
73            .arg_required_else_help(true);
74
75        // Detect duplicate normalized names within this group
76        let mut name_count: HashMap<String, usize> = HashMap::new();
77        for op in group_ops {
78            *name_count
79                .entry(normalize_operation_id(&op.operation_id))
80                .or_default() += 1;
81        }
82
83        for op in group_ops {
84            let base_name = normalize_operation_id(&op.operation_id);
85            let cmd_name = if name_count.get(&base_name).copied().unwrap_or(0) > 1 {
86                format!("{}-{}", base_name, op.method.to_lowercase())
87            } else {
88                base_name
89            };
90            group_cmd = group_cmd.subcommand(build_operation_command(op, &cmd_name));
91        }
92
93        root = root.subcommand(group_cmd);
94    }
95
96    root
97}
98
99/// Find the matching `ApiOperation` for a resolved group + operation name.
100pub fn find_operation<'a>(
101    ops: &'a [ApiOperation],
102    group_name: &str,
103    op_name: &str,
104) -> Option<&'a ApiOperation> {
105    ops.iter().find(|o| {
106        let base = normalize_operation_id(&o.operation_id);
107        let with_method = format!("{}-{}", base, o.method.to_lowercase());
108        (base == op_name || with_method == op_name) && normalize_group(&o.group) == group_name
109    })
110}
111
112fn build_operation_command(op: &ApiOperation, cmd_name: &str) -> Command {
113    let mut cmd = Command::new(cmd_name.to_owned()).about(op.summary.clone());
114
115    // Path parameters → positional args
116    for param in &op.path_params {
117        cmd = cmd.arg(
118            Arg::new(param.name.clone())
119                .help(param.description.clone())
120                .required(true),
121        );
122    }
123
124    // Query parameters → --flag options
125    for param in &op.query_params {
126        let arg = Arg::new(param.name.clone())
127            .long(param.name.clone())
128            .help(param.description.clone())
129            .required(param.required);
130
131        let arg = if is_bool_schema(&param.schema) {
132            arg.action(ArgAction::SetTrue)
133        } else {
134            arg.action(ArgAction::Set)
135        };
136
137        cmd = cmd.arg(arg);
138    }
139
140    // Header parameters → --header-name options
141    for param in &op.header_params {
142        cmd = cmd.arg(
143            Arg::new(param.name.clone())
144                .long(param.name.clone())
145                .help(param.description.clone())
146                .required(param.required)
147                .action(ArgAction::Set),
148        );
149    }
150
151    // Request body → --json or --field options
152    if op.body_schema.is_some() {
153        cmd = cmd
154            .arg(
155                Arg::new("json-body")
156                    .long("json")
157                    .short('j')
158                    .help("Request body as JSON string")
159                    .action(ArgAction::Set),
160            )
161            .arg(
162                Arg::new("field")
163                    .long("field")
164                    .short('f')
165                    .help("Set body field: key=value (repeatable)")
166                    .action(ArgAction::Append),
167            );
168    }
169
170    cmd
171}
172
173pub fn normalize_group(name: &str) -> String {
174    let mut result = String::with_capacity(name.len());
175    for c in name.chars() {
176        if c.is_alphanumeric() {
177            result.push(c.to_ascii_lowercase());
178        } else if !result.is_empty() && !result.ends_with('-') {
179            result.push('-');
180        }
181    }
182    while result.ends_with('-') {
183        result.pop();
184    }
185    result
186}
187
188pub fn normalize_operation_id(s: &str) -> String {
189    let chars: Vec<char> = s.chars().collect();
190    let mut result = String::with_capacity(s.len() + 4);
191    for i in 0..chars.len() {
192        let c = chars[i];
193        if c.is_uppercase() {
194            if i > 0 {
195                let prev = chars[i - 1];
196                let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
197                if prev.is_lowercase() || (prev.is_uppercase() && next_is_lower) {
198                    result.push('-');
199                }
200            }
201            result.push(c.to_ascii_lowercase());
202        } else {
203            result.push(c);
204        }
205    }
206    result
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::spec::{ApiOperation, Param};
213    use serde_json::json;
214
215    fn make_operation(operation_id: &str, method: &str, path: &str, group: &str) -> ApiOperation {
216        ApiOperation {
217            operation_id: operation_id.to_string(),
218            method: method.to_string(),
219            path: path.to_string(),
220            group: group.to_string(),
221            summary: String::new(),
222            path_params: Vec::new(),
223            query_params: Vec::new(),
224            header_params: Vec::new(),
225            body_schema: None,
226            body_required: false,
227        }
228    }
229
230    // -- normalize_operation_id --
231
232    #[test]
233    fn normalize_operation_id_pascal_case() {
234        assert_eq!(normalize_operation_id("CreatePod"), "create-pod");
235    }
236
237    #[test]
238    fn normalize_operation_id_camel_case() {
239        assert_eq!(normalize_operation_id("getPods"), "get-pods");
240    }
241
242    #[test]
243    fn normalize_operation_id_already_lowercase() {
244        assert_eq!(normalize_operation_id("list"), "list");
245    }
246
247    #[test]
248    fn normalize_operation_id_consecutive_uppercase() {
249        assert_eq!(normalize_operation_id("getHTTPStatus"), "get-http-status");
250    }
251
252    #[test]
253    fn normalize_operation_id_acronym_at_start() {
254        assert_eq!(normalize_operation_id("HTMLParser"), "html-parser");
255    }
256
257    #[test]
258    fn normalize_operation_id_acronym_at_end() {
259        assert_eq!(normalize_operation_id("getAPI"), "get-api");
260    }
261
262    #[test]
263    fn normalize_operation_id_empty() {
264        assert_eq!(normalize_operation_id(""), "");
265    }
266
267    // -- normalize_group --
268
269    #[test]
270    fn normalize_group_with_spaces() {
271        assert_eq!(normalize_group("My Group"), "my-group");
272    }
273
274    #[test]
275    fn normalize_group_already_lowercase() {
276        assert_eq!(normalize_group("pods"), "pods");
277    }
278
279    #[test]
280    fn normalize_group_uppercase() {
281        assert_eq!(normalize_group("PODS"), "pods");
282    }
283
284    #[test]
285    fn normalize_group_multiple_spaces() {
286        assert_eq!(normalize_group("My Cool Group"), "my-cool-group");
287    }
288
289    #[test]
290    fn normalize_group_special_characters() {
291        assert_eq!(normalize_group("My/Group"), "my-group");
292        assert_eq!(normalize_group("My_Group"), "my-group");
293        assert_eq!(normalize_group("My..Group"), "my-group");
294    }
295
296    // -- build_commands --
297
298    #[test]
299    fn build_commands_creates_correct_tree_structure() {
300        let ops = vec![
301            make_operation("ListPods", "GET", "/pods", "Pods"),
302            make_operation("CreatePod", "POST", "/pods", "Pods"),
303            make_operation("ListEndpoints", "GET", "/endpoints", "Endpoints"),
304        ];
305
306        let config = CliConfig {
307            name: "testcli".into(),
308            about: "Test CLI".into(),
309            default_base_url: "https://api.example.com".into(),
310        };
311
312        let cmd = build_commands(&config, &ops);
313
314        assert_eq!(cmd.get_name(), "testcli");
315
316        let subcommands: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
317        assert!(subcommands.contains(&"pods"), "should have 'pods' group");
318        assert!(
319            subcommands.contains(&"endpoints"),
320            "should have 'endpoints' group"
321        );
322
323        let pods_cmd = cmd
324            .get_subcommands()
325            .find(|c| c.get_name() == "pods")
326            .unwrap();
327        let pod_subs: Vec<&str> = pods_cmd.get_subcommands().map(|c| c.get_name()).collect();
328        assert!(pod_subs.contains(&"list-pods"), "should have 'list-pods'");
329        assert!(pod_subs.contains(&"create-pod"), "should have 'create-pod'");
330
331        let endpoints_cmd = cmd
332            .get_subcommands()
333            .find(|c| c.get_name() == "endpoints")
334            .unwrap();
335        let ep_subs: Vec<&str> = endpoints_cmd
336            .get_subcommands()
337            .map(|c| c.get_name())
338            .collect();
339        assert!(
340            ep_subs.contains(&"list-endpoints"),
341            "should have 'list-endpoints'"
342        );
343    }
344
345    #[test]
346    fn build_commands_includes_path_params_as_positional_args() {
347        let ops = vec![ApiOperation {
348            operation_id: "GetPod".to_string(),
349            method: "GET".to_string(),
350            path: "/pods/{podId}".to_string(),
351            group: "Pods".to_string(),
352            summary: "Get a pod".to_string(),
353            path_params: vec![Param {
354                name: "podId".to_string(),
355                description: "Pod ID".to_string(),
356                required: true,
357                schema: json!({"type": "string"}),
358            }],
359            query_params: Vec::new(),
360            header_params: Vec::new(),
361            body_schema: None,
362            body_required: false,
363        }];
364
365        let config = CliConfig {
366            name: "testcli".into(),
367            about: "Test".into(),
368            default_base_url: "https://example.com".into(),
369        };
370
371        let cmd = build_commands(&config, &ops);
372        let pods = cmd
373            .get_subcommands()
374            .find(|c| c.get_name() == "pods")
375            .unwrap();
376        let get_pod = pods
377            .get_subcommands()
378            .find(|c| c.get_name() == "get-pod")
379            .unwrap();
380
381        let arg = get_pod.get_arguments().find(|a| a.get_id() == "podId");
382        assert!(arg.is_some(), "should have podId positional arg");
383        assert!(arg.unwrap().is_required_set());
384    }
385
386    #[test]
387    fn build_commands_includes_body_args_when_body_schema_present() {
388        let ops = vec![ApiOperation {
389            operation_id: "CreatePod".to_string(),
390            method: "POST".to_string(),
391            path: "/pods".to_string(),
392            group: "Pods".to_string(),
393            summary: "Create a pod".to_string(),
394            path_params: Vec::new(),
395            query_params: Vec::new(),
396            header_params: Vec::new(),
397            body_schema: Some(json!({"type": "object"})),
398            body_required: true,
399        }];
400
401        let config = CliConfig {
402            name: "testcli".into(),
403            about: "Test".into(),
404            default_base_url: "https://example.com".into(),
405        };
406
407        let cmd = build_commands(&config, &ops);
408        let pods = cmd
409            .get_subcommands()
410            .find(|c| c.get_name() == "pods")
411            .unwrap();
412        let create_pod = pods
413            .get_subcommands()
414            .find(|c| c.get_name() == "create-pod")
415            .unwrap();
416
417        assert!(
418            create_pod
419                .get_arguments()
420                .any(|a| a.get_id() == "json-body"),
421            "should have --json arg"
422        );
423        assert!(
424            create_pod.get_arguments().any(|a| a.get_id() == "field"),
425            "should have --field arg"
426        );
427    }
428
429    #[test]
430    fn build_commands_global_base_url_arg() {
431        let config = CliConfig {
432            name: "testcli".into(),
433            about: "Test".into(),
434            default_base_url: "https://api.example.com".into(),
435        };
436
437        let cmd = build_commands(&config, &[]);
438        let base_url_arg = cmd.get_arguments().find(|a| a.get_id() == "base-url");
439        assert!(base_url_arg.is_some(), "should have global base-url arg");
440    }
441
442    // -- find_operation --
443
444    #[test]
445    fn find_operation_returns_matching_operation() {
446        let ops = vec![
447            make_operation("ListPods", "GET", "/pods", "Pods"),
448            make_operation("CreatePod", "POST", "/pods", "Pods"),
449        ];
450
451        let found = find_operation(&ops, "pods", "create-pod");
452        assert!(found.is_some());
453        assert_eq!(found.unwrap().operation_id, "CreatePod");
454    }
455
456    #[test]
457    fn find_operation_returns_none_for_nonexistent() {
458        let ops = vec![make_operation("ListPods", "GET", "/pods", "Pods")];
459
460        let found = find_operation(&ops, "pods", "delete-pod");
461        assert!(found.is_none());
462    }
463
464    #[test]
465    fn find_operation_returns_none_for_wrong_group() {
466        let ops = vec![make_operation("ListPods", "GET", "/pods", "Pods")];
467
468        let found = find_operation(&ops, "endpoints", "list-pods");
469        assert!(found.is_none());
470    }
471
472    #[test]
473    fn find_operation_matches_with_method_suffix() {
474        let ops = vec![
475            make_operation("UpdatePod", "PUT", "/pods/{id}", "Pods"),
476            make_operation("UpdatePod", "PATCH", "/pods/{id}", "Pods"),
477        ];
478
479        let found = find_operation(&ops, "pods", "update-pod-patch");
480        assert!(found.is_some());
481        assert_eq!(found.unwrap().method, "PATCH");
482    }
483}