1use std::collections::{BTreeMap, HashMap};
6
7use clap::{Arg, ArgAction, Command};
8
9use crate::spec::{is_bool_schema, ApiOperation};
10
11#[derive(Debug, Clone, Copy)]
13pub enum CommandNaming {
14 Default,
18 StripGroup,
22 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 if result.is_empty() {
37 normalized_op_id.to_string()
38 } else {
39 result
40 }
41 }
42}
43
44fn strip_group(op: &str, group: &str) -> String {
49 if let Some(stripped) = op.strip_suffix(&format!("-{group}")) {
51 if !stripped.is_empty() {
52 return stripped.to_string();
53 }
54 }
55
56 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 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 op.to_string()
86}
87
88#[derive(Debug, Clone)]
90#[non_exhaustive]
91pub struct CliConfig {
92 pub name: String,
94 pub about: String,
96 pub default_base_url: String,
98 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 pub fn command_naming(mut self, naming: CommandNaming) -> Self {
118 self.command_naming = naming;
119 self
120 }
121}
122
123pub 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 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 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
181pub 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 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 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(¶m.schema) {
218 arg.action(ArgAction::SetTrue)
219 } else {
220 arg.action(ArgAction::Set)
221 };
222
223 cmd = cmd.arg(arg);
224 }
225
226 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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}