Skip to main content

fraiseql_cli/
introspection.rs

1//! CLI introspection for AI agents
2//!
3//! Extracts command metadata from clap to enable machine-readable help output.
4
5use clap::Command;
6
7use crate::output::{ArgumentHelp, CliHelp, CommandHelp, CommandSummary, get_exit_codes};
8
9/// Extract complete CLI help from a clap Command
10pub fn extract_cli_help(cmd: &Command, version: &str) -> CliHelp {
11    CliHelp {
12        name:           cmd.get_name().to_string(),
13        version:        version.to_string(),
14        about:          cmd.get_about().map_or_else(String::new, ToString::to_string),
15        global_options: extract_global_options(cmd),
16        subcommands:    cmd
17            .get_subcommands()
18            .filter(|sub| !sub.is_hide_set())
19            .map(extract_command_help)
20            .collect(),
21        exit_codes:     get_exit_codes(),
22    }
23}
24
25/// Extract help for a single command
26pub fn extract_command_help(cmd: &Command) -> CommandHelp {
27    let (arguments, options) = extract_arguments(cmd);
28
29    CommandHelp {
30        name: cmd.get_name().to_string(),
31        about: cmd.get_about().map_or_else(String::new, ToString::to_string),
32        arguments,
33        options,
34        subcommands: cmd
35            .get_subcommands()
36            .filter(|sub| !sub.is_hide_set())
37            .map(extract_command_help)
38            .collect(),
39        examples: extract_examples(cmd),
40    }
41}
42
43/// List all available commands with summaries
44pub fn list_commands(cmd: &Command) -> Vec<CommandSummary> {
45    cmd.get_subcommands()
46        .filter(|sub| !sub.is_hide_set())
47        .map(|sub| CommandSummary {
48            name:            sub.get_name().to_string(),
49            description:     sub.get_about().map_or_else(String::new, ToString::to_string),
50            has_subcommands: sub.get_subcommands().count() > 0,
51        })
52        .collect()
53}
54
55/// Extract global options from the root command
56fn extract_global_options(cmd: &Command) -> Vec<ArgumentHelp> {
57    cmd.get_arguments()
58        .filter(|arg| arg.is_global_set())
59        .map(|arg| ArgumentHelp {
60            name:            arg.get_id().to_string(),
61            short:           arg.get_short().map(|c| format!("-{c}")),
62            long:            arg.get_long().map(|s| format!("--{s}")),
63            help:            arg.get_help().map_or_else(String::new, ToString::to_string),
64            required:        arg.is_required_set(),
65            default_value:   arg
66                .get_default_values()
67                .first()
68                .and_then(|v| v.to_str())
69                .map(String::from),
70            takes_value:     arg.get_num_args().is_some_and(|n| n.min_values() > 0),
71            possible_values: arg
72                .get_possible_values()
73                .iter()
74                .map(|v| v.get_name().to_string())
75                .collect(),
76        })
77        .collect()
78}
79
80/// Extract arguments and options from a command
81fn extract_arguments(cmd: &Command) -> (Vec<ArgumentHelp>, Vec<ArgumentHelp>) {
82    let mut arguments = Vec::new();
83    let mut options = Vec::new();
84
85    for arg in cmd.get_arguments() {
86        // Skip global arguments (they're listed separately)
87        if arg.is_global_set() {
88            continue;
89        }
90
91        // Skip the built-in help and version flags
92        let id = arg.get_id().as_str();
93        if id == "help" || id == "version" {
94            continue;
95        }
96
97        let arg_help = ArgumentHelp {
98            name:            arg.get_id().to_string(),
99            short:           arg.get_short().map(|c| format!("-{c}")),
100            long:            arg.get_long().map(|s| format!("--{s}")),
101            help:            arg.get_help().map_or_else(String::new, ToString::to_string),
102            required:        arg.is_required_set(),
103            default_value:   arg
104                .get_default_values()
105                .first()
106                .and_then(|v| v.to_str())
107                .map(String::from),
108            takes_value:     arg.get_num_args().is_some_and(|n| n.min_values() > 0),
109            possible_values: arg
110                .get_possible_values()
111                .iter()
112                .map(|v| v.get_name().to_string())
113                .collect(),
114        };
115
116        // Positional arguments have no short or long flag
117        if arg.get_short().is_none() && arg.get_long().is_none() {
118            arguments.push(arg_help);
119        } else {
120            options.push(arg_help);
121        }
122    }
123
124    (arguments, options)
125}
126
127/// Extract examples from command's after_help text
128fn extract_examples(cmd: &Command) -> Vec<String> {
129    // Look for EXAMPLES section in after_help
130    if let Some(after_help) = cmd.get_after_help() {
131        let text = after_help.to_string();
132        if let Some(examples_start) = text.find("EXAMPLES:") {
133            let examples_section = &text[examples_start + 9..];
134            return examples_section
135                .lines()
136                .map(str::trim)
137                .filter(|line| !line.is_empty() && line.starts_with("fraiseql"))
138                .map(String::from)
139                .collect();
140        }
141    }
142    Vec::new()
143}
144
145#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
146#[cfg(test)]
147mod tests {
148    use clap::{Arg, Command as ClapCommand};
149
150    use super::*;
151
152    fn create_test_cli() -> ClapCommand {
153        ClapCommand::new("test-cli")
154            .version("1.0.0")
155            .about("Test CLI for unit tests")
156            .arg(
157                Arg::new("verbose")
158                    .short('v')
159                    .long("verbose")
160                    .help("Enable verbose mode")
161                    .global(true)
162                    .action(clap::ArgAction::SetTrue),
163            )
164            .subcommand(
165                ClapCommand::new("compile")
166                    .about("Compile files")
167                    .arg(Arg::new("input").help("Input file").required(true))
168                    .arg(
169                        Arg::new("output")
170                            .short('o')
171                            .long("output")
172                            .help("Output file")
173                            .default_value("out.json"),
174                    )
175                    .after_help("EXAMPLES:\n    fraiseql compile input.json\n    fraiseql compile input.json -o output.json"),
176            )
177            .subcommand(
178                ClapCommand::new("hidden")
179                    .about("Hidden command")
180                    .hide(true),
181            )
182    }
183
184    #[test]
185    fn test_extract_cli_help() {
186        let cmd = create_test_cli();
187        let help = extract_cli_help(&cmd, "1.0.0");
188
189        assert_eq!(help.name, "test-cli");
190        assert_eq!(help.version, "1.0.0");
191        assert_eq!(help.about, "Test CLI for unit tests");
192        assert!(!help.exit_codes.is_empty());
193    }
194
195    #[test]
196    fn test_extract_global_options() {
197        let cmd = create_test_cli();
198        let help = extract_cli_help(&cmd, "1.0.0");
199
200        assert!(!help.global_options.is_empty());
201        let verbose = help.global_options.iter().find(|a| a.name == "verbose");
202        assert!(verbose.is_some());
203        let verbose = verbose.unwrap();
204        assert_eq!(verbose.short, Some("-v".to_string()));
205        assert_eq!(verbose.long, Some("--verbose".to_string()));
206    }
207
208    #[test]
209    fn test_extract_command_help() {
210        let cmd = create_test_cli();
211        let compile = cmd.get_subcommands().find(|c| c.get_name() == "compile").unwrap();
212        let help = extract_command_help(compile);
213
214        assert_eq!(help.name, "compile");
215        assert_eq!(help.about, "Compile files");
216        assert_eq!(help.arguments.len(), 1);
217        assert_eq!(help.arguments[0].name, "input");
218        assert!(help.arguments[0].required);
219    }
220
221    #[test]
222    fn test_extract_options() {
223        let cmd = create_test_cli();
224        let compile = cmd.get_subcommands().find(|c| c.get_name() == "compile").unwrap();
225        let help = extract_command_help(compile);
226
227        let output_opt = help.options.iter().find(|o| o.name == "output");
228        assert!(output_opt.is_some());
229        let output_opt = output_opt.unwrap();
230        assert_eq!(output_opt.default_value, Some("out.json".to_string()));
231    }
232
233    #[test]
234    fn test_extract_examples() {
235        let cmd = create_test_cli();
236        let compile = cmd.get_subcommands().find(|c| c.get_name() == "compile").unwrap();
237        let help = extract_command_help(compile);
238
239        assert_eq!(help.examples.len(), 2);
240        assert!(help.examples[0].contains("fraiseql compile"));
241    }
242
243    #[test]
244    fn test_list_commands() {
245        let cmd = create_test_cli();
246        let commands = list_commands(&cmd);
247
248        // Should only list non-hidden commands
249        assert_eq!(commands.len(), 1);
250        assert_eq!(commands[0].name, "compile");
251        assert!(!commands[0].has_subcommands);
252    }
253
254    #[test]
255    fn test_hidden_commands_excluded() {
256        let cmd = create_test_cli();
257        let help = extract_cli_help(&cmd, "1.0.0");
258
259        // Hidden command should not appear in subcommands
260        let hidden = help.subcommands.iter().find(|s| s.name == "hidden");
261        assert!(hidden.is_none());
262    }
263}