1use std::collections::{BTreeMap, HashMap};
6
7use clap::{Arg, ArgAction, Command};
8
9use crate::spec::{is_bool_schema, ApiOperation};
10
11#[derive(Debug, Clone)]
13#[non_exhaustive]
14pub struct CliConfig {
15 pub name: String,
17 pub about: String,
19 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
37pub 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 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 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
99pub 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 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 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(¶m.schema) {
132 arg.action(ArgAction::SetTrue)
133 } else {
134 arg.action(ArgAction::Set)
135 };
136
137 cmd = cmd.arg(arg);
138 }
139
140 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 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 #[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 #[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 #[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 #[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}