Skip to main content

gen_circleci_orb/help_parser/
clap.rs

1use std::collections::HashSet;
2
3use super::run_help;
4use super::types::{CliDefinition, ParamType, Parameter, SubCommand};
5use anyhow::Result;
6
7/// Parse the top-level `--help` output for `binary` and recursively fetch
8/// help for each discovered subcommand.
9pub 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
27/// Extract the filename stem from a binary path, returning just the bare name.
28///
29/// `./target/release/gen-orb-mcp` → `gen-orb-mcp`
30/// `/usr/local/bin/gen-orb-mcp`   → `gen-orb-mcp`
31/// `gen-orb-mcp`                  → `gen-orb-mcp`
32fn 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
67/// Extract the first non-empty paragraph before any section header as the description.
68fn extract_description(text: &str) -> String {
69    let mut lines = Vec::new();
70    for line in text.lines() {
71        let trimmed = line.trim();
72        // Stop at section headers (capitalised word followed by colon)
73        if is_section_header(trimmed) {
74            break;
75        }
76        // Skip "Usage:" lines
77        if trimmed.starts_with("Usage:") {
78            break;
79        }
80        lines.push(trimmed.to_string());
81    }
82    // Drop leading/trailing blanks and join
83    let joined: Vec<&str> = lines
84        .iter()
85        .map(|s| s.as_str())
86        .skip_while(|s| s.is_empty())
87        .collect();
88    // Trim trailing empty lines
89    let end = joined
90        .iter()
91        .rposition(|s| !s.is_empty())
92        .map_or(0, |i| i + 1);
93    joined[..end].join(" ")
94}
95
96/// Extract subcommand names from the `Commands:` section, skipping `help`.
97pub 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            // A new section header ends the commands block
112            if is_section_header(trimmed) {
113                break;
114            }
115            // Each command line starts with the command name, optionally followed by description
116            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
126/// Parse the Usage line to find flags listed outside any `[...]` group.
127/// Those flags are truly required (clap will reject invocations that omit them).
128fn 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    // Remove all [...] groups (optional items) iteratively to handle nesting.
135    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    // Every --flag remaining after bracket removal is required.
146    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
153/// Parse the `Options:` / `Arguments:` sections to build `Parameter` list.
154pub 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        // Detect section headers; skip non-option lines
165        if is_top_level_section(line) || trimmed.is_empty() {
166            i += 1;
167            continue;
168        }
169
170        // Only process lines that look like flags (start with - after trimming)
171        if !trimmed.starts_with('-') {
172            i += 1;
173            continue;
174        }
175
176        // Skip -h/--help built-in and the clap -V/--version built-in.
177        // The clap built-in --version has no <VALUE> metavar; application flags
178        // also named --version that accept a value (e.g. --version <VERSION>) must
179        // NOT be excluded — check for a metavar to tell them apart.
180        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        // Determine indentation of this flag line so we can collect its
193        // full description block, which may contain blank separator lines.
194        let flag_indent = leading_spaces(line);
195
196        // Collect the full option block using indentation: gather all lines
197        // until we hit a non-blank line that is at flag_indent or less AND
198        // starts a new flag or section header.
199        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                // Blank lines within the block are fine — peek ahead to decide
207                // whether the block continues
208                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 the next non-blank line is indented MORE than the flag
218                        // it belongs to this block; otherwise the block is done.
219                        if peek_indent > flag_indent
220                            && !peek_trimmed.starts_with('-')
221                            && !is_top_level_section(peek_line)
222                        {
223                            block_lines.push(next_trimmed); // include the blank
224                            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        // Extract possible values from within the block text
246        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
270/// Extract possible values from within a collected block string.
271fn extract_possible_values_from_block(block: &str) -> Vec<String> {
272    // Indented block format:  "Possible values:  - name: description"
273    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    // Inline bracket format:  "[possible values: a, b]"
289    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
301/// Parse a single collected option block string into a `Parameter`.
302fn parse_option_block(
303    block: &str,
304    possible_values: Vec<String>,
305    required_flags: &HashSet<String>,
306) -> Option<Parameter> {
307    // Extract long flag: look for --word
308    let long_flag = extract_long_flag(block)?;
309    let short = extract_short_flag(block);
310
311    // Determine if boolean: no <VALUE> metavar after the flag
312    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    // A flag is required only if the Usage line lists it outside any [...] group.
324    let required = !is_boolean && required_flags.contains(&long_flag);
325
326    // Description: everything after the flags portion
327    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    // Match --word or --word-word patterns
343    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    // Match -x (single char) at word boundary
350    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    // After --flag, is there a <VALUE> or [VALUE] metavar?
357    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    // Clap metavars are UPPERCASE (e.g. <OUTPUT>, <ORB_PATH>).
375    // Description text may contain lowercase angle-bracket references like
376    // `<output>/<orb-dir>/` — these must NOT truncate the description.
377    // Strategy: find the flag declaration (--flag [<UPPER_METAVAR>]) and take
378    // everything after it as the candidate description.
379    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    // Strip annotations: [default: ...], [possible values: ...], "Possible values: ..."
389    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
400/// True only for top-level section headers (no leading whitespace).
401/// `Possible values:` appears indented inside option blocks and is NOT a section header.
402fn is_section_header(line: &str) -> bool {
403    line.ends_with(':') && !line.starts_with(' ') && !line.starts_with('-')
404}
405
406/// True when the untrimmed `line` is a top-level section header.
407fn 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    // ── top-level parsing ──────────────────────────────────────────────────
416
417    #[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    // ── parameter parsing ──────────────────────────────────────────────────
474
475    #[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        // A param inside [OPTIONS] with no default is optional — the CLI accepts omitting it.
543        // The orb must use a mustache conditional, not pass an empty string.
544        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        // A param listed in the Usage line outside [OPTIONS] is truly required.
567        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        // Some tools use --version as an application-level flag (e.g. "version string
637        // to embed in output"). This has a <VALUE> metavar and must NOT be excluded —
638        // only the clap built-in (no metavar, "Print version") should be skipped.
639        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        // Angle brackets in description text (e.g. `<output>/<orb-dir>/`) must not
664        // cause extract_param_description to truncate the description.
665        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        // clap can render possible values inline: `[possible values: a, b]`
688        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        // The clap built-in -V / --version has no <VALUE> metavar and prints the
714        // binary version.  It must be excluded from generated orb parameters.
715        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    // ── binary name normalisation ──────────────────────────────────────────
759
760    #[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}