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/// Strategy for generating CLI command names from operation IDs.
12#[derive(Debug, Clone, Copy)]
13pub enum CommandNaming {
14    /// Use normalized operation_id as-is (default, backward compatible).
15    ///
16    /// `"listPods"` under group `"Pods"` → command `"list-pods"`
17    Default,
18    /// Strip group name from command name for shorter commands.
19    ///
20    /// `"listPods"` under group `"Pods"` → command `"list"`
21    StripGroup,
22    /// Custom naming logic.
23    ///
24    /// Arguments: `(normalized_op_id, normalized_group) -> command_name`
25    Custom(fn(&str, &str) -> String),
26}
27
28impl CommandNaming {
29    fn apply(&self, normalized_op_id: &str, normalized_group: &str) -> String {
30        let result = match self {
31            Self::Default => return normalized_op_id.to_string(),
32            Self::StripGroup => strip_group(normalized_op_id, normalized_group),
33            Self::Custom(f) => f(normalized_op_id, normalized_group),
34        };
35        // Guard: empty command name would panic in clap
36        if result.is_empty() {
37            normalized_op_id.to_string()
38        } else {
39            result
40        }
41    }
42}
43
44/// Strip group name (or its singular form) from an operation name.
45///
46/// Tries suffix removal first, then prefix removal.
47/// Falls back to the original name if stripping is not possible.
48fn strip_group(op: &str, group: &str) -> String {
49    // suffix: "list-pods" - "-pods" → "list"
50    if let Some(stripped) = op.strip_suffix(&format!("-{group}")) {
51        if !stripped.is_empty() {
52            return stripped.to_string();
53        }
54    }
55
56    // suffix with singular (trailing 's' removed): "create-pod" when group is "pods"
57    if let Some(singular) = group.strip_suffix('s') {
58        if !singular.is_empty() {
59            if let Some(stripped) = op.strip_suffix(&format!("-{singular}")) {
60                if !stripped.is_empty() {
61                    return stripped.to_string();
62                }
63            }
64        }
65    }
66
67    // prefix: "pods-list" - "pods-" → "list"
68    if let Some(stripped) = op.strip_prefix(&format!("{group}-")) {
69        if !stripped.is_empty() {
70            return stripped.to_string();
71        }
72    }
73
74    if let Some(singular) = group.strip_suffix('s') {
75        if !singular.is_empty() {
76            if let Some(stripped) = op.strip_prefix(&format!("{singular}-")) {
77                if !stripped.is_empty() {
78                    return stripped.to_string();
79                }
80            }
81        }
82    }
83
84    // Can't strip: return as-is
85    op.to_string()
86}
87
88/// Configuration for building a CLI from an OpenAPI spec.
89#[derive(Debug, Clone)]
90#[non_exhaustive]
91pub struct CliConfig {
92    /// Root command name (e.g. "runpod", "myapi")
93    pub name: String,
94    /// Root command about/description
95    pub about: String,
96    /// Default base URL for the API
97    pub default_base_url: String,
98    /// Strategy for generating command names from operation IDs
99    pub command_naming: CommandNaming,
100}
101
102impl CliConfig {
103    pub fn new(
104        name: impl Into<String>,
105        about: impl Into<String>,
106        default_base_url: impl Into<String>,
107    ) -> Self {
108        Self {
109            name: name.into(),
110            about: about.into(),
111            default_base_url: default_base_url.into(),
112            command_naming: CommandNaming::Default,
113        }
114    }
115
116    /// Set the command naming strategy.
117    pub fn command_naming(mut self, naming: CommandNaming) -> Self {
118        self.command_naming = naming;
119        self
120    }
121}
122
123/// Build a clap `Command` tree from a list of API operations.
124///
125/// Structure: `<name> <group> <operation> [args] [--options]`
126/// Groups are derived from OpenAPI tags (e.g. "pods", "endpoints").
127pub fn build_commands(config: &CliConfig, ops: &[ApiOperation]) -> Command {
128    let root = Command::new(config.name.clone())
129        .about(config.about.clone())
130        .subcommand_required(true)
131        .arg_required_else_help(true)
132        .arg(
133            Arg::new("base-url")
134                .long("base-url")
135                .global(true)
136                .default_value(config.default_base_url.clone())
137                .help("API base URL"),
138        );
139
140    // Group operations by tag
141    let mut groups: BTreeMap<String, Vec<&ApiOperation>> = BTreeMap::new();
142    for op in ops {
143        groups.entry(op.group.clone()).or_default().push(op);
144    }
145
146    let mut root = root;
147    for (group_name, group_ops) in &groups {
148        let norm_group = normalize_group(group_name);
149        let mut group_cmd = Command::new(norm_group.clone())
150            .about(format!("Manage {group_name}"))
151            .subcommand_required(true)
152            .arg_required_else_help(true);
153
154        // Detect duplicate names (after command_naming applied) within this group
155        let mut name_count: HashMap<String, usize> = HashMap::new();
156        for op in group_ops {
157            let name = config
158                .command_naming
159                .apply(&normalize_operation_id(&op.operation_id), &norm_group);
160            *name_count.entry(name).or_default() += 1;
161        }
162
163        for op in group_ops {
164            let base_name = config
165                .command_naming
166                .apply(&normalize_operation_id(&op.operation_id), &norm_group);
167            let cmd_name = if name_count.get(&base_name).copied().unwrap_or(0) > 1 {
168                format!("{}-{}", base_name, op.method.to_lowercase())
169            } else {
170                base_name
171            };
172            group_cmd = group_cmd.subcommand(build_operation_command(op, &cmd_name));
173        }
174
175        root = root.subcommand(group_cmd);
176    }
177
178    root
179}
180
181/// Find the matching `ApiOperation` for a resolved group + operation name.
182pub fn find_operation<'a>(
183    ops: &'a [ApiOperation],
184    group_name: &str,
185    op_name: &str,
186    config: &CliConfig,
187) -> Option<&'a ApiOperation> {
188    ops.iter().find(|o| {
189        let norm_group = normalize_group(&o.group);
190        let base = config
191            .command_naming
192            .apply(&normalize_operation_id(&o.operation_id), &norm_group);
193        let with_method = format!("{}-{}", base, o.method.to_lowercase());
194        (base == op_name || with_method == op_name) && norm_group == group_name
195    })
196}
197
198fn build_operation_command(op: &ApiOperation, cmd_name: &str) -> Command {
199    let mut cmd = Command::new(cmd_name.to_owned()).about(op.summary.clone());
200
201    // Path parameters → positional args
202    for param in &op.path_params {
203        cmd = cmd.arg(
204            Arg::new(param.name.clone())
205                .help(param.description.clone())
206                .required(true),
207        );
208    }
209
210    // Query parameters → --flag options
211    for param in &op.query_params {
212        let arg = Arg::new(param.name.clone())
213            .long(param.name.clone())
214            .help(param.description.clone())
215            .required(param.required);
216
217        let arg = if is_bool_schema(&param.schema) {
218            arg.action(ArgAction::SetTrue)
219        } else {
220            arg.action(ArgAction::Set)
221        };
222
223        cmd = cmd.arg(arg);
224    }
225
226    // Header parameters → --header-name options
227    for param in &op.header_params {
228        cmd = cmd.arg(
229            Arg::new(param.name.clone())
230                .long(param.name.clone())
231                .help(param.description.clone())
232                .required(param.required)
233                .action(ArgAction::Set),
234        );
235    }
236
237    // Request body → --json or --field options
238    if op.body_schema.is_some() {
239        cmd = cmd
240            .arg(
241                Arg::new("json-body")
242                    .long("json")
243                    .short('j')
244                    .help("Request body as JSON string")
245                    .action(ArgAction::Set),
246            )
247            .arg(
248                Arg::new("field")
249                    .long("field")
250                    .short('f')
251                    .help("Set body field: key=value (repeatable)")
252                    .action(ArgAction::Append),
253            );
254    }
255
256    cmd
257}
258
259pub fn normalize_group(name: &str) -> String {
260    let mut result = String::with_capacity(name.len());
261    for c in name.chars() {
262        if c.is_alphanumeric() {
263            result.push(c.to_ascii_lowercase());
264        } else if !result.is_empty() && !result.ends_with('-') {
265            result.push('-');
266        }
267    }
268    while result.ends_with('-') {
269        result.pop();
270    }
271    result
272}
273
274pub fn normalize_operation_id(s: &str) -> String {
275    let chars: Vec<char> = s.chars().collect();
276    let mut result = String::with_capacity(s.len() + 4);
277    for i in 0..chars.len() {
278        let c = chars[i];
279        if c.is_uppercase() {
280            if i > 0 {
281                let prev = chars[i - 1];
282                let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
283                if prev.is_lowercase() || (prev.is_uppercase() && next_is_lower) {
284                    result.push('-');
285                }
286            }
287            result.push(c.to_ascii_lowercase());
288        } else {
289            result.push(c);
290        }
291    }
292    result
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use crate::spec::{ApiOperation, Param};
299    use serde_json::json;
300
301    fn make_operation(operation_id: &str, method: &str, path: &str, group: &str) -> ApiOperation {
302        ApiOperation {
303            operation_id: operation_id.to_string(),
304            method: method.to_string(),
305            path: path.to_string(),
306            group: group.to_string(),
307            summary: String::new(),
308            path_params: Vec::new(),
309            query_params: Vec::new(),
310            header_params: Vec::new(),
311            body_schema: None,
312            body_required: false,
313        }
314    }
315
316    fn default_config() -> CliConfig {
317        CliConfig {
318            name: "testcli".into(),
319            about: "Test".into(),
320            default_base_url: "https://example.com".into(),
321            command_naming: CommandNaming::Default,
322        }
323    }
324
325    // -- normalize_operation_id --
326
327    #[test]
328    fn normalize_operation_id_pascal_case() {
329        assert_eq!(normalize_operation_id("CreatePod"), "create-pod");
330    }
331
332    #[test]
333    fn normalize_operation_id_camel_case() {
334        assert_eq!(normalize_operation_id("getPods"), "get-pods");
335    }
336
337    #[test]
338    fn normalize_operation_id_already_lowercase() {
339        assert_eq!(normalize_operation_id("list"), "list");
340    }
341
342    #[test]
343    fn normalize_operation_id_consecutive_uppercase() {
344        assert_eq!(normalize_operation_id("getHTTPStatus"), "get-http-status");
345    }
346
347    #[test]
348    fn normalize_operation_id_acronym_at_start() {
349        assert_eq!(normalize_operation_id("HTMLParser"), "html-parser");
350    }
351
352    #[test]
353    fn normalize_operation_id_acronym_at_end() {
354        assert_eq!(normalize_operation_id("getAPI"), "get-api");
355    }
356
357    #[test]
358    fn normalize_operation_id_empty() {
359        assert_eq!(normalize_operation_id(""), "");
360    }
361
362    // -- normalize_group --
363
364    #[test]
365    fn normalize_group_with_spaces() {
366        assert_eq!(normalize_group("My Group"), "my-group");
367    }
368
369    #[test]
370    fn normalize_group_already_lowercase() {
371        assert_eq!(normalize_group("pods"), "pods");
372    }
373
374    #[test]
375    fn normalize_group_uppercase() {
376        assert_eq!(normalize_group("PODS"), "pods");
377    }
378
379    #[test]
380    fn normalize_group_multiple_spaces() {
381        assert_eq!(normalize_group("My Cool Group"), "my-cool-group");
382    }
383
384    #[test]
385    fn normalize_group_special_characters() {
386        assert_eq!(normalize_group("My/Group"), "my-group");
387        assert_eq!(normalize_group("My_Group"), "my-group");
388        assert_eq!(normalize_group("My..Group"), "my-group");
389    }
390
391    // -- strip_group --
392
393    #[test]
394    fn strip_group_suffix_plural() {
395        assert_eq!(strip_group("list-pods", "pods"), "list");
396    }
397
398    #[test]
399    fn strip_group_suffix_singular() {
400        assert_eq!(strip_group("create-pod", "pods"), "create");
401    }
402
403    #[test]
404    fn strip_group_prefix() {
405        assert_eq!(strip_group("pods-list", "pods"), "list");
406    }
407
408    #[test]
409    fn strip_group_no_match_returns_original() {
410        assert_eq!(strip_group("get-status", "pods"), "get-status");
411    }
412
413    #[test]
414    fn strip_group_exact_match_returns_original() {
415        // "pods" stripped from "pods" would be empty → fallback to original
416        assert_eq!(strip_group("pods", "pods"), "pods");
417    }
418
419    #[test]
420    fn strip_group_multi_word_group() {
421        assert_eq!(strip_group("list-user-roles", "user-roles"), "list");
422    }
423
424    // -- CommandNaming --
425
426    #[test]
427    fn command_naming_default_returns_normalized_op_id() {
428        let naming = CommandNaming::Default;
429        assert_eq!(naming.apply("list-pods", "pods"), "list-pods");
430    }
431
432    #[test]
433    fn command_naming_strip_group_removes_suffix() {
434        let naming = CommandNaming::StripGroup;
435        assert_eq!(naming.apply("list-pods", "pods"), "list");
436        assert_eq!(naming.apply("create-pod", "pods"), "create");
437    }
438
439    #[test]
440    fn command_naming_custom() {
441        let naming = CommandNaming::Custom(|op, _group| op.replace("my-", ""));
442        assert_eq!(naming.apply("my-list", "group"), "list");
443    }
444
445    #[test]
446    fn command_naming_custom_empty_result_falls_back() {
447        let naming = CommandNaming::Custom(|_op, _group| String::new());
448        assert_eq!(naming.apply("list-pods", "pods"), "list-pods");
449    }
450
451    // -- build_commands --
452
453    #[test]
454    fn build_commands_creates_correct_tree_structure() {
455        let ops = vec![
456            make_operation("ListPods", "GET", "/pods", "Pods"),
457            make_operation("CreatePod", "POST", "/pods", "Pods"),
458            make_operation("ListEndpoints", "GET", "/endpoints", "Endpoints"),
459        ];
460
461        let config = CliConfig {
462            name: "testcli".into(),
463            about: "Test CLI".into(),
464            default_base_url: "https://api.example.com".into(),
465            command_naming: CommandNaming::Default,
466        };
467
468        let cmd = build_commands(&config, &ops);
469
470        assert_eq!(cmd.get_name(), "testcli");
471
472        let subcommands: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect();
473        assert!(subcommands.contains(&"pods"), "should have 'pods' group");
474        assert!(
475            subcommands.contains(&"endpoints"),
476            "should have 'endpoints' group"
477        );
478
479        let pods_cmd = cmd
480            .get_subcommands()
481            .find(|c| c.get_name() == "pods")
482            .unwrap();
483        let pod_subs: Vec<&str> = pods_cmd.get_subcommands().map(|c| c.get_name()).collect();
484        assert!(pod_subs.contains(&"list-pods"), "should have 'list-pods'");
485        assert!(pod_subs.contains(&"create-pod"), "should have 'create-pod'");
486
487        let endpoints_cmd = cmd
488            .get_subcommands()
489            .find(|c| c.get_name() == "endpoints")
490            .unwrap();
491        let ep_subs: Vec<&str> = endpoints_cmd
492            .get_subcommands()
493            .map(|c| c.get_name())
494            .collect();
495        assert!(
496            ep_subs.contains(&"list-endpoints"),
497            "should have 'list-endpoints'"
498        );
499    }
500
501    #[test]
502    fn build_commands_with_strip_group() {
503        let ops = vec![
504            make_operation("ListPods", "GET", "/pods", "Pods"),
505            make_operation("CreatePod", "POST", "/pods", "Pods"),
506            make_operation("GetPod", "GET", "/pods/{id}", "Pods"),
507        ];
508
509        let config = CliConfig::new("testcli", "Test", "https://example.com")
510            .command_naming(CommandNaming::StripGroup);
511
512        let cmd = build_commands(&config, &ops);
513        let pods_cmd = cmd
514            .get_subcommands()
515            .find(|c| c.get_name() == "pods")
516            .unwrap();
517        let pod_subs: Vec<&str> = pods_cmd.get_subcommands().map(|c| c.get_name()).collect();
518        assert!(pod_subs.contains(&"list"), "should have 'list'");
519        assert!(pod_subs.contains(&"create"), "should have 'create'");
520        assert!(pod_subs.contains(&"get"), "should have 'get'");
521    }
522
523    #[test]
524    fn build_commands_includes_path_params_as_positional_args() {
525        let ops = vec![ApiOperation {
526            operation_id: "GetPod".to_string(),
527            method: "GET".to_string(),
528            path: "/pods/{podId}".to_string(),
529            group: "Pods".to_string(),
530            summary: "Get a pod".to_string(),
531            path_params: vec![Param {
532                name: "podId".to_string(),
533                description: "Pod ID".to_string(),
534                required: true,
535                schema: json!({"type": "string"}),
536            }],
537            query_params: Vec::new(),
538            header_params: Vec::new(),
539            body_schema: None,
540            body_required: false,
541        }];
542
543        let config = default_config();
544
545        let cmd = build_commands(&config, &ops);
546        let pods = cmd
547            .get_subcommands()
548            .find(|c| c.get_name() == "pods")
549            .unwrap();
550        let get_pod = pods
551            .get_subcommands()
552            .find(|c| c.get_name() == "get-pod")
553            .unwrap();
554
555        let arg = get_pod.get_arguments().find(|a| a.get_id() == "podId");
556        assert!(arg.is_some(), "should have podId positional arg");
557        assert!(arg.unwrap().is_required_set());
558    }
559
560    #[test]
561    fn build_commands_includes_body_args_when_body_schema_present() {
562        let ops = vec![ApiOperation {
563            operation_id: "CreatePod".to_string(),
564            method: "POST".to_string(),
565            path: "/pods".to_string(),
566            group: "Pods".to_string(),
567            summary: "Create a pod".to_string(),
568            path_params: Vec::new(),
569            query_params: Vec::new(),
570            header_params: Vec::new(),
571            body_schema: Some(json!({"type": "object"})),
572            body_required: true,
573        }];
574
575        let config = default_config();
576
577        let cmd = build_commands(&config, &ops);
578        let pods = cmd
579            .get_subcommands()
580            .find(|c| c.get_name() == "pods")
581            .unwrap();
582        let create_pod = pods
583            .get_subcommands()
584            .find(|c| c.get_name() == "create-pod")
585            .unwrap();
586
587        assert!(
588            create_pod
589                .get_arguments()
590                .any(|a| a.get_id() == "json-body"),
591            "should have --json arg"
592        );
593        assert!(
594            create_pod.get_arguments().any(|a| a.get_id() == "field"),
595            "should have --field arg"
596        );
597    }
598
599    #[test]
600    fn build_commands_global_base_url_arg() {
601        let config = default_config();
602
603        let cmd = build_commands(&config, &[]);
604        let base_url_arg = cmd.get_arguments().find(|a| a.get_id() == "base-url");
605        assert!(base_url_arg.is_some(), "should have global base-url arg");
606    }
607
608    // -- find_operation --
609
610    #[test]
611    fn find_operation_returns_matching_operation() {
612        let config = default_config();
613        let ops = vec![
614            make_operation("ListPods", "GET", "/pods", "Pods"),
615            make_operation("CreatePod", "POST", "/pods", "Pods"),
616        ];
617
618        let found = find_operation(&ops, "pods", "create-pod", &config);
619        assert!(found.is_some());
620        assert_eq!(found.unwrap().operation_id, "CreatePod");
621    }
622
623    #[test]
624    fn find_operation_returns_none_for_nonexistent() {
625        let config = default_config();
626        let ops = vec![make_operation("ListPods", "GET", "/pods", "Pods")];
627
628        let found = find_operation(&ops, "pods", "delete-pod", &config);
629        assert!(found.is_none());
630    }
631
632    #[test]
633    fn find_operation_returns_none_for_wrong_group() {
634        let config = default_config();
635        let ops = vec![make_operation("ListPods", "GET", "/pods", "Pods")];
636
637        let found = find_operation(&ops, "endpoints", "list-pods", &config);
638        assert!(found.is_none());
639    }
640
641    #[test]
642    fn find_operation_matches_with_method_suffix() {
643        let config = default_config();
644        let ops = vec![
645            make_operation("UpdatePod", "PUT", "/pods/{id}", "Pods"),
646            make_operation("UpdatePod", "PATCH", "/pods/{id}", "Pods"),
647        ];
648
649        let found = find_operation(&ops, "pods", "update-pod-patch", &config);
650        assert!(found.is_some());
651        assert_eq!(found.unwrap().method, "PATCH");
652    }
653
654    #[test]
655    fn find_operation_with_strip_group() {
656        let config = CliConfig::new("testcli", "Test", "https://example.com")
657            .command_naming(CommandNaming::StripGroup);
658        let ops = vec![
659            make_operation("ListPods", "GET", "/pods", "Pods"),
660            make_operation("CreatePod", "POST", "/pods", "Pods"),
661        ];
662
663        let found = find_operation(&ops, "pods", "list", &config);
664        assert!(found.is_some());
665        assert_eq!(found.unwrap().operation_id, "ListPods");
666
667        let found = find_operation(&ops, "pods", "create", &config);
668        assert!(found.is_some());
669        assert_eq!(found.unwrap().operation_id, "CreatePod");
670    }
671
672    #[test]
673    fn find_operation_with_strip_group_method_suffix() {
674        let config = CliConfig::new("testcli", "Test", "https://example.com")
675            .command_naming(CommandNaming::StripGroup);
676        let ops = vec![
677            make_operation("UpdatePod", "PUT", "/pods/{id}", "Pods"),
678            make_operation("UpdatePod", "PATCH", "/pods/{id}", "Pods"),
679        ];
680
681        let found = find_operation(&ops, "pods", "update-patch", &config);
682        assert!(found.is_some());
683        assert_eq!(found.unwrap().method, "PATCH");
684    }
685}