gen_circleci_orb/help_parser/
clap.rs1use std::collections::HashSet;
2
3use super::run_help;
4use super::types::{CliDefinition, ParamType, Parameter, SubCommand};
5use anyhow::Result;
6
7pub fn parse_top_level(binary: &str, help_text: &str) -> Result<CliDefinition> {
10 let description = extract_description(help_text);
11 let sub_names = extract_subcommand_names(help_text);
12
13 let mut subcommands = Vec::new();
14 for name in sub_names {
15 let sub_help = run_help(binary, &[&name])?;
16 let sub = parse_subcommand(&name, &sub_help, binary)?;
17 subcommands.push(sub);
18 }
19
20 Ok(CliDefinition {
21 binary_name: normalize_binary_name(binary),
22 description,
23 subcommands,
24 })
25}
26
27fn normalize_binary_name(binary: &str) -> String {
33 std::path::Path::new(binary)
34 .file_stem()
35 .and_then(|s| s.to_str())
36 .unwrap_or(binary)
37 .to_string()
38}
39
40fn parse_subcommand(name: &str, help_text: &str, binary: &str) -> Result<SubCommand> {
41 let description = extract_description(help_text);
42 let child_names = extract_subcommand_names(help_text);
43 let is_leaf = child_names.is_empty();
44
45 let mut subcommands = Vec::new();
46 for child_name in &child_names {
47 let child_help = run_help(binary, &[name, child_name])?;
48 let child = parse_subcommand(child_name, &child_help, binary)?;
49 subcommands.push(child);
50 }
51
52 let parameters = if is_leaf {
53 parse_parameters(help_text)
54 } else {
55 Vec::new()
56 };
57
58 Ok(SubCommand {
59 name: name.to_string(),
60 description,
61 is_leaf,
62 parameters,
63 subcommands,
64 })
65}
66
67fn extract_description(text: &str) -> String {
69 let mut lines = Vec::new();
70 for line in text.lines() {
71 let trimmed = line.trim();
72 if is_section_header(trimmed) {
74 break;
75 }
76 if trimmed.starts_with("Usage:") {
78 break;
79 }
80 lines.push(trimmed.to_string());
81 }
82 let joined: Vec<&str> = lines
84 .iter()
85 .map(|s| s.as_str())
86 .skip_while(|s| s.is_empty())
87 .collect();
88 let end = joined
90 .iter()
91 .rposition(|s| !s.is_empty())
92 .map_or(0, |i| i + 1);
93 joined[..end].join(" ")
94}
95
96pub fn extract_subcommand_names(text: &str) -> Vec<String> {
98 let mut in_commands = false;
99 let mut names = Vec::new();
100
101 for line in text.lines() {
102 let trimmed = line.trim();
103 if trimmed == "Commands:" {
104 in_commands = true;
105 continue;
106 }
107 if in_commands {
108 if trimmed.is_empty() {
109 continue;
110 }
111 if is_section_header(trimmed) {
113 break;
114 }
115 if let Some(name) = trimmed.split_whitespace().next() {
117 if name != "help" {
118 names.push(name.to_string());
119 }
120 }
121 }
122 }
123 names
124}
125
126fn extract_required_flags(text: &str) -> HashSet<String> {
129 let usage_line = match text.lines().find(|l| l.trim().starts_with("Usage:")) {
130 Some(l) => l.to_string(),
131 None => return HashSet::new(),
132 };
133
134 let re_brackets = regex::Regex::new(r"\[[^\[\]]*\]").unwrap();
136 let mut cleaned = usage_line;
137 loop {
138 let next = re_brackets.replace_all(&cleaned, "").to_string();
139 if next == cleaned {
140 break;
141 }
142 cleaned = next;
143 }
144
145 let re_flags = regex::Regex::new(r"--([a-zA-Z][a-zA-Z0-9-]*)").unwrap();
147 re_flags
148 .captures_iter(&cleaned)
149 .map(|cap| cap[1].to_string())
150 .collect()
151}
152
153pub fn parse_parameters(text: &str) -> Vec<Parameter> {
155 let required_flags = extract_required_flags(text);
156 let lines: Vec<&str> = text.lines().collect();
157 let mut params = Vec::new();
158 let mut i = 0;
159
160 while i < lines.len() {
161 let line = lines[i];
162 let trimmed = line.trim();
163
164 if is_top_level_section(line) || trimmed.is_empty() {
166 i += 1;
167 continue;
168 }
169
170 if !trimmed.starts_with('-') {
172 i += 1;
173 continue;
174 }
175
176 if trimmed.contains("--help") {
181 i += 1;
182 continue;
183 }
184 if let Some(pos) = trimmed.find("--version") {
185 let after = trimmed[pos + "--version".len()..].trim_start();
186 if !after.starts_with('<') && !after.starts_with('[') {
187 i += 1;
188 continue;
189 }
190 }
191
192 let flag_indent = leading_spaces(line);
195
196 let mut block_lines: Vec<&str> = vec![trimmed];
200 let mut j = i + 1;
201 while j < lines.len() {
202 let next = lines[j];
203 let next_trimmed = next.trim();
204
205 if next_trimmed.is_empty() {
206 let peek = peek_next_non_blank(lines.as_slice(), j + 1);
209 match peek {
210 None => {
211 j += 1;
212 break;
213 }
214 Some((_, peek_line)) => {
215 let peek_indent = leading_spaces(peek_line);
216 let peek_trimmed = peek_line.trim();
217 if peek_indent > flag_indent
220 && !peek_trimmed.starts_with('-')
221 && !is_top_level_section(peek_line)
222 {
223 block_lines.push(next_trimmed); j += 1;
225 } else {
226 j += 1;
227 break;
228 }
229 }
230 }
231 } else {
232 let indent = leading_spaces(next);
233 if indent <= flag_indent
234 && (next_trimmed.starts_with('-') || is_top_level_section(next))
235 {
236 break;
237 }
238 block_lines.push(next_trimmed);
239 j += 1;
240 }
241 }
242
243 let block = block_lines.join(" ");
244
245 let possible_values = extract_possible_values_from_block(&block);
247
248 if let Some(param) = parse_option_block(&block, possible_values, &required_flags) {
249 params.push(param);
250 }
251
252 i = j;
253 }
254 params
255}
256
257fn leading_spaces(line: &str) -> usize {
258 line.len() - line.trim_start().len()
259}
260
261fn peek_next_non_blank<'a>(lines: &[&'a str], from: usize) -> Option<(usize, &'a str)> {
262 for (offset, line) in lines[from..].iter().enumerate() {
263 if !line.trim().is_empty() {
264 return Some((from + offset, line));
265 }
266 }
267 None
268}
269
270fn extract_possible_values_from_block(block: &str) -> Vec<String> {
272 if let Some(pos) = block.find("Possible values:") {
274 let after = &block[pos + "Possible values:".len()..];
275 let mut values = Vec::new();
276 for part in after.split("- ") {
277 let part = part.trim();
278 if part.is_empty() {
279 continue;
280 }
281 let val = part.split(':').next().unwrap_or(part).trim();
282 if !val.is_empty() {
283 values.push(val.to_string());
284 }
285 }
286 return values;
287 }
288 if let Some(pos) = block.find("[possible values:") {
290 let after = &block[pos + "[possible values:".len()..];
291 let content = after.find(']').map_or(after, |end| &after[..end]);
292 return content
293 .split(',')
294 .map(|v| v.trim().to_string())
295 .filter(|v| !v.is_empty())
296 .collect();
297 }
298 Vec::new()
299}
300
301fn parse_option_block(
303 block: &str,
304 possible_values: Vec<String>,
305 required_flags: &HashSet<String>,
306) -> Option<Parameter> {
307 let long_flag = extract_long_flag(block)?;
309 let short = extract_short_flag(block);
310
311 let is_boolean = !has_value_metavar(block, &long_flag);
313
314 let param_type = if !possible_values.is_empty() {
315 ParamType::Enum(possible_values)
316 } else if is_boolean {
317 ParamType::Boolean
318 } else {
319 ParamType::String
320 };
321
322 let default = extract_default(block);
323 let required = !is_boolean && required_flags.contains(&long_flag);
325
326 let description = extract_param_description(block);
328
329 let long_name = Parameter::normalize_name(&long_flag);
330
331 Some(Parameter {
332 long_name,
333 short,
334 param_type,
335 default,
336 required,
337 description,
338 })
339}
340
341fn extract_long_flag(block: &str) -> Option<String> {
342 let re = regex::Regex::new(r"--([a-zA-Z][a-zA-Z0-9-]*)").ok()?;
344 let cap = re.captures(block)?;
345 Some(cap[1].to_string())
346}
347
348fn extract_short_flag(block: &str) -> Option<char> {
349 let re = regex::Regex::new(r"(?:^|[ ,])-([a-zA-Z])(?:\b|,| )").ok()?;
351 let cap = re.captures(block)?;
352 cap[1].chars().next()
353}
354
355fn has_value_metavar(block: &str, long_flag: &str) -> bool {
356 let flag_pos = block.find(&format!("--{long_flag}"));
358 if let Some(pos) = flag_pos {
359 let after = &block[pos + 2 + long_flag.len()..];
360 let after = after.trim_start_matches([',', ' ']);
361 after.starts_with('<') || after.starts_with('[')
362 } else {
363 false
364 }
365}
366
367fn extract_default(block: &str) -> Option<String> {
368 let re = regex::Regex::new(r"\[default:\s*([^\]]+)\]").ok()?;
369 let cap = re.captures(block)?;
370 Some(cap[1].trim().to_string())
371}
372
373fn extract_param_description(block: &str) -> String {
374 let re_decl = regex::Regex::new(r"--[a-zA-Z][a-zA-Z0-9-]*(?:\s+<[A-Z][A-Z0-9_]*>)?").unwrap();
380 let candidate = if let Some(m) = re_decl.find(block) {
381 block[m.end()..].trim().to_string()
382 } else if let Some(pos) = block.find(" ") {
383 block[pos..].trim().to_string()
384 } else {
385 block.to_string()
386 };
387
388 let re_default = regex::Regex::new(r"\s*\[default:[^\]]*\]").unwrap();
390 let re_pv_inline = regex::Regex::new(r"\s*\[possible values:[^\]]*\]").unwrap();
391 let candidate = if let Some(pv) = candidate.find("Possible values:") {
392 candidate[..pv].trim().to_string()
393 } else {
394 candidate
395 };
396 let candidate = re_pv_inline.replace_all(&candidate, "").to_string();
397 re_default.replace_all(&candidate, "").trim().to_string()
398}
399
400fn is_section_header(line: &str) -> bool {
403 line.ends_with(':') && !line.starts_with(' ') && !line.starts_with('-')
404}
405
406fn is_top_level_section(line: &str) -> bool {
408 is_section_header(line.trim()) && leading_spaces(line) == 0
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414
415 #[test]
418 fn top_level_extracts_description() {
419 let help = r#"Generate MCP servers from CircleCI orb definitions
420
421Usage: gen-orb-mcp <COMMAND>
422
423Commands:
424 generate Generate an MCP server from an orb definition
425 validate Validate an orb definition without generating
426 help Print this message or the help of the given subcommand(s)
427
428Options:
429 -h, --help Print help
430 -V, --version Print version
431"#;
432 let desc = extract_description(help);
433 assert_eq!(desc, "Generate MCP servers from CircleCI orb definitions");
434 }
435
436 #[test]
437 fn top_level_extracts_subcommand_names() {
438 let help = r#"Generate MCP servers from CircleCI orb definitions
439
440Usage: gen-orb-mcp <COMMAND>
441
442Commands:
443 generate Generate an MCP server from an orb definition
444 validate Validate an orb definition without generating
445 diff Compute conformance rules by diffing two orb versions
446 migrate Apply conformance-based migration
447 prime Populate prior-versions/ and migrations/ from git history
448 help Print this message or the help of the given subcommand(s)
449
450Options:
451 -h, --help Print help
452 -V, --version Print version
453"#;
454 let names = extract_subcommand_names(help);
455 assert_eq!(
456 names,
457 vec!["generate", "validate", "diff", "migrate", "prime"]
458 );
459 }
460
461 #[test]
462 fn help_subcommand_is_skipped() {
463 let help = r#"Usage: tool <COMMAND>
464
465Commands:
466 run Run the thing
467 help Print this message
468"#;
469 let names = extract_subcommand_names(help);
470 assert_eq!(names, vec!["run"]);
471 }
472
473 #[test]
476 fn boolean_flag_detected() {
477 let help = r#"Run the tool
478
479Usage: tool run [OPTIONS]
480
481Options:
482 --force
483 Overwrite existing files without confirmation
484
485 -h, --help
486 Print help
487"#;
488 let params = parse_parameters(help);
489 let force = params.iter().find(|p| p.long_name == "force").unwrap();
490 assert_eq!(force.param_type, ParamType::Boolean);
491 assert!(!force.required);
492 }
493
494 #[test]
495 fn enum_type_detected_from_possible_values() {
496 let help = r#"Generate something
497
498Usage: tool generate [OPTIONS]
499
500Options:
501 -f, --format <FORMAT>
502 Output format
503
504 Possible values:
505 - binary: Compile to native binary
506 - source: Generate Rust source code
507
508 -h, --help
509 Print help
510"#;
511 let params = parse_parameters(help);
512 let fmt = params.iter().find(|p| p.long_name == "format").unwrap();
513 assert_eq!(
514 fmt.param_type,
515 ParamType::Enum(vec!["binary".to_string(), "source".to_string()])
516 );
517 }
518
519 #[test]
520 fn default_value_extracted() {
521 let help = r#"Generate something
522
523Usage: tool generate [OPTIONS]
524
525Options:
526 -o, --output <OUTPUT>
527 Output directory
528
529 [default: ./dist]
530
531 -h, --help
532 Print help
533"#;
534 let params = parse_parameters(help);
535 let out = params.iter().find(|p| p.long_name == "output").unwrap();
536 assert_eq!(out.default, Some("./dist".to_string()));
537 assert!(!out.required);
538 }
539
540 #[test]
541 fn optional_no_default_is_not_required() {
542 let help = r#"Validate something
545
546Usage: tool validate [OPTIONS]
547
548Options:
549 -p, --orb-path <ORB_PATH>
550 Path to the orb YAML file
551
552 -h, --help
553 Print help
554"#;
555 let params = parse_parameters(help);
556 let p = params.iter().find(|p| p.long_name == "orb_path").unwrap();
557 assert!(
558 !p.required,
559 "param inside [OPTIONS] with no default must not be required"
560 );
561 assert_eq!(p.default, None);
562 }
563
564 #[test]
565 fn truly_required_detected_from_usage_line() {
566 let help = r#"Generate something
568
569Usage: tool generate [OPTIONS] --orb-path <ORB_PATH>
570
571Options:
572 -p, --orb-path <ORB_PATH>
573 Path to the orb YAML file
574
575 --name <NAME>
576 Optional name
577
578 -h, --help
579 Print help
580"#;
581 let params = parse_parameters(help);
582 let orb_path = params.iter().find(|p| p.long_name == "orb_path").unwrap();
583 let name = params.iter().find(|p| p.long_name == "name").unwrap();
584 assert!(orb_path.required, "flag in usage line must be required");
585 assert!(
586 !name.required,
587 "flag only in [OPTIONS] must not be required"
588 );
589 }
590
591 #[test]
592 fn long_name_normalised_kebab_to_snake() {
593 let help = r#"Usage: tool cmd [OPTIONS]
594
595Options:
596 --orb-path <ORB_PATH> Path to orb
597 -h, --help Print help
598"#;
599 let params = parse_parameters(help);
600 assert!(
601 params.iter().any(|p| p.long_name == "orb_path"),
602 "expected orb_path, got: {:?}",
603 params.iter().map(|p| &p.long_name).collect::<Vec<_>>()
604 );
605 }
606
607 #[test]
608 fn short_flag_extracted() {
609 let help = r#"Usage: tool cmd [OPTIONS]
610
611Options:
612 -p, --orb-path <ORB_PATH> Path to orb
613 -h, --help Print help
614"#;
615 let params = parse_parameters(help);
616 let p = params.iter().find(|p| p.long_name == "orb_path").unwrap();
617 assert_eq!(p.short, Some('p'));
618 }
619
620 #[test]
621 fn help_and_version_flags_excluded() {
622 let help = r#"Usage: tool cmd [OPTIONS]
623
624Options:
625 -p, --orb-path <ORB_PATH> Path to orb
626 -h, --help Print help
627 -V, --version Print version
628"#;
629 let params = parse_parameters(help);
630 assert!(!params.iter().any(|p| p.long_name == "help"));
631 assert!(!params.iter().any(|p| p.long_name == "version"));
632 }
633
634 #[test]
635 fn app_version_flag_with_metavar_is_included() {
636 let help = r#"Generate something
640
641Usage: tool generate [OPTIONS]
642
643Options:
644 -V, --version <VERSION>
645 Version string to embed in the generated output (e.g. "1.0.0")
646
647 -h, --help
648 Print help
649
650 --other-flag
651 A boolean flag for comparison
652"#;
653 let params = parse_parameters(help);
654 assert!(
655 params.iter().any(|p| p.long_name == "version"),
656 "app --version <VALUE> flag must be included, got: {:?}",
657 params.iter().map(|p| &p.long_name).collect::<Vec<_>>()
658 );
659 }
660
661 #[test]
662 fn description_not_truncated_by_lowercase_angle_brackets_in_text() {
663 let help = r#"Do something
666
667Usage: tool cmd [OPTIONS]
668
669Options:
670 --output <OUTPUT>
671 Project root directory (orb source is written to <output>/<orb-dir>/) [default: .]
672
673 -h, --help
674 Print help
675"#;
676 let params = parse_parameters(help);
677 let p = params.iter().find(|p| p.long_name == "output").unwrap();
678 assert!(
679 p.description.contains("Project root directory"),
680 "description truncated to {:?}",
681 p.description
682 );
683 }
684
685 #[test]
686 fn enum_type_detected_from_inline_possible_values() {
687 let help = r#"Do something
689
690Usage: tool cmd [OPTIONS]
691
692Options:
693 --install-method <INSTALL_METHOD>
694 How the binary is installed [default: binstall] [possible values: binstall, apt]
695
696 -h, --help
697 Print help
698"#;
699 let params = parse_parameters(help);
700 let p = params
701 .iter()
702 .find(|p| p.long_name == "install_method")
703 .unwrap();
704 assert_eq!(
705 p.param_type,
706 ParamType::Enum(vec!["binstall".to_string(), "apt".to_string()])
707 );
708 assert_eq!(p.default, Some("binstall".to_string()));
709 }
710
711 #[test]
712 fn clap_builtin_version_without_metavar_is_excluded() {
713 let help = r#"Usage: tool cmd [OPTIONS]
716
717Options:
718 -p, --flag <FLAG> Some flag
719 -h, --help Print help
720 -V, --version Print version
721"#;
722 let params = parse_parameters(help);
723 assert!(
724 !params.iter().any(|p| p.long_name == "version"),
725 "clap built-in --version must be excluded"
726 );
727 }
728
729 #[test]
730 fn enum_default_combined() {
731 let help = r#"Generate something
732
733Usage: tool generate [OPTIONS]
734
735Options:
736 -f, --format <FORMAT>
737 Output format
738
739 [default: source]
740
741 Possible values:
742 - binary: Compile to native binary
743 - source: Generate Rust source code
744
745 -h, --help
746 Print help
747"#;
748 let params = parse_parameters(help);
749 let fmt = params.iter().find(|p| p.long_name == "format").unwrap();
750 assert_eq!(
751 fmt.param_type,
752 ParamType::Enum(vec!["binary".to_string(), "source".to_string()])
753 );
754 assert_eq!(fmt.default, Some("source".to_string()));
755 assert!(!fmt.required);
756 }
757
758 #[test]
761 fn normalize_binary_name_relative_path_extracts_stem() {
762 assert_eq!(
763 normalize_binary_name("./target/release/gen-orb-mcp"),
764 "gen-orb-mcp"
765 );
766 }
767
768 #[test]
769 fn normalize_binary_name_absolute_path_extracts_stem() {
770 assert_eq!(
771 normalize_binary_name("/usr/local/bin/gen-orb-mcp"),
772 "gen-orb-mcp"
773 );
774 }
775
776 #[test]
777 fn normalize_binary_name_plain_name_unchanged() {
778 assert_eq!(normalize_binary_name("gen-orb-mcp"), "gen-orb-mcp");
779 }
780
781 #[test]
782 fn normalize_binary_name_nested_relative_path() {
783 assert_eq!(normalize_binary_name("../../some/path/mytool"), "mytool");
784 }
785}