help_probe/
lib.rs

1pub mod api_docs;
2pub mod builder;
3pub mod cache;
4pub mod completion;
5pub mod model;
6pub mod parser;
7pub mod runner;
8pub mod validation;
9
10use std::time::Duration;
11
12use anyhow::Result;
13
14use crate::model::ProbeResult;
15use crate::parser::{
16    detect_help_flag, parse_arguments, parse_environment_variables, parse_examples,
17    parse_options_from_sections, parse_options_from_usage_blocks, parse_subcommands, parse_usages,
18    parse_validation_rules,
19};
20use crate::runner::{RunOutcome, run_with_timeout};
21
22/// Configuration for probing a command.
23#[derive(Clone)]
24pub struct ProbeConfig {
25    /// Timeout in seconds for running the target command.
26    pub timeout_secs: u64,
27    /// Whether to require a "help" flag (help/-h/--help/etc).
28    ///
29    /// Currently this is advisory: we still run even if no help flag.
30    pub require_help_flag: bool,
31    /// Cache configuration.
32    pub cache: Option<crate::cache::CacheConfig>,
33}
34
35/// Common help flags to try automatically if none are detected.
36const COMMON_HELP_FLAGS: &[&str] = &["--help", "-h", "help", "--usage", "-?", "/?"];
37
38/// Try to find a working help flag by testing common flags.
39/// Returns the args with a help flag appended, or the original args if a help flag was already present.
40async fn ensure_help_flag(
41    program: &str,
42    args: &[String],
43    timeout: Duration,
44) -> (Vec<String>, bool) {
45    // If a help flag is already present, use the args as-is
46    if detect_help_flag(args) {
47        return (args.to_vec(), true);
48    }
49
50    // Use a shorter timeout for testing help flags (help should be fast)
51    let test_timeout = Duration::from_secs(1).min(timeout);
52
53    // Try each common help flag until one works (exit code 0)
54    for help_flag in COMMON_HELP_FLAGS {
55        let test_args = {
56            let mut new_args = args.to_vec();
57            new_args.push(help_flag.to_string());
58            new_args
59        };
60
61        // Quick test: try running with this help flag
62        if let Ok(RunOutcome::Completed(output)) =
63            run_with_timeout(program, &test_args, test_timeout).await
64        {
65            // If exit code is 0, this help flag works
66            if output.status.code() == Some(0) {
67                return (test_args, true);
68            }
69            // If exit code is non-zero but we got output, it might still be help text
70            // (some programs return non-zero for help, but still print it)
71            if !output.stdout.is_empty() || !output.stderr.is_empty() {
72                // Check if output looks like help text
73                let combined = format!(
74                    "{}\n{}",
75                    String::from_utf8_lossy(&output.stdout),
76                    String::from_utf8_lossy(&output.stderr)
77                );
78                let combined_lower = combined.to_lowercase();
79                if combined_lower.contains("usage")
80                    || combined_lower.contains("options")
81                    || combined_lower.contains("commands")
82                    || combined_lower.contains("help")
83                {
84                    return (test_args, true);
85                }
86            }
87        }
88    }
89
90    // If none worked, just use --help as default (most common)
91    let mut default_args = args.to_vec();
92    default_args.push("--help".to_string());
93    (default_args, false)
94}
95
96/// High-level API: probe a command and return a structured result.
97///
98/// This does NOT handle prompting or user interaction.
99/// Automatically tries common help flags if none are detected.
100pub async fn probe_command(
101    program: &str,
102    args: &[String],
103    config: &ProbeConfig,
104) -> Result<ProbeResult> {
105    let timeout = Duration::from_secs(config.timeout_secs);
106
107    // Automatically ensure a help flag is present
108    let (final_args, help_flag_detected) = ensure_help_flag(program, args, timeout).await;
109
110    // Check cache with final args
111    if let Some(cache_config) = &config.cache {
112        if let Some(cached_result) = crate::cache::read_cache(program, &final_args, cache_config)? {
113            return Ok(cached_result);
114        }
115    }
116
117    if config.require_help_flag && !help_flag_detected {
118        // Caller can decide how to react; we still run the command.
119        // Could also early-return an Err if strict is desired.
120    }
121
122    let outcome = run_with_timeout(program, &final_args, timeout).await?;
123
124    let mut result = ProbeResult {
125        command: program.to_string(),
126        args: final_args.clone(),
127        exit_code: None,
128        timed_out: false,
129        help_flag_detected,
130        usage_blocks: Vec::new(),
131        options: Vec::new(),
132        subcommands: Vec::new(),
133        arguments: Vec::new(),
134        examples: Vec::new(),
135        environment_variables: Vec::new(),
136        validation_rules: Vec::new(),
137        raw_stdout: String::new(),
138        raw_stderr: String::new(),
139    };
140
141    match outcome {
142        RunOutcome::Completed(output) => {
143            result.exit_code = output.status.code();
144            result.raw_stdout = String::from_utf8_lossy(&output.stdout).to_string();
145            result.raw_stderr = String::from_utf8_lossy(&output.stderr).to_string();
146
147            result.usage_blocks = parse_usages(&output.stdout, &output.stderr);
148
149            // Combine options from usage blocks and OPTIONS sections
150            let mut options = parse_options_from_usage_blocks(&result.usage_blocks);
151            let mut section_options =
152                parse_options_from_sections(&result.raw_stdout, &result.raw_stderr);
153            options.append(&mut section_options);
154
155            // Deduplicate options (same flags)
156            options.sort_by(|a, b| {
157                let a_flags = format!("{:?}{:?}", a.short_flags, a.long_flags);
158                let b_flags = format!("{:?}{:?}", b.short_flags, b.long_flags);
159                a_flags.cmp(&b_flags)
160            });
161            options.dedup_by(|a, b| a.short_flags == b.short_flags && a.long_flags == b.long_flags);
162
163            result.options = options;
164            result.subcommands = parse_subcommands(&result.raw_stdout, &result.raw_stderr);
165            result.arguments =
166                parse_arguments(&result.raw_stdout, &result.raw_stderr, &result.usage_blocks);
167            result.examples = parse_examples(&result.raw_stdout, &result.raw_stderr);
168            result.environment_variables = parse_environment_variables(
169                &result.raw_stdout,
170                &result.raw_stderr,
171                &result.options,
172            );
173            result.validation_rules = parse_validation_rules(
174                &result.raw_stdout,
175                &result.raw_stderr,
176                &result.options,
177                &result.arguments,
178            );
179        }
180        RunOutcome::TimedOut => {
181            result.timed_out = true;
182        }
183    }
184
185    // Write to cache if enabled (use final_args, not original args)
186    if let Some(cache_config) = &config.cache {
187        let _ = crate::cache::write_cache(program, &final_args, &result, cache_config);
188    }
189
190    Ok(result)
191}
192
193/// Recursively discover subcommand hierarchy by probing each subcommand.
194///
195/// This function probes each subcommand to discover:
196/// - Subcommand-specific options
197/// - Subcommand-specific arguments
198/// - Nested subcommands
199///
200/// # Arguments
201/// * `program` - The command to probe
202/// * `config` - Configuration for probing
203/// * `max_depth` - Maximum depth to recurse (prevents infinite loops)
204pub async fn discover_subcommand_hierarchy(
205    program: &str,
206    config: &ProbeConfig,
207    max_depth: usize,
208) -> anyhow::Result<Vec<crate::model::SubcommandSpec>> {
209    discover_subcommands_recursive(
210        program.to_string(),
211        Vec::new(),
212        ProbeConfig {
213            timeout_secs: config.timeout_secs,
214            require_help_flag: config.require_help_flag,
215            cache: config.cache.clone(),
216        },
217        max_depth,
218        0,
219    )
220    .await
221}
222
223/// Discover all subcommands recursively and build a complete command tree.
224///
225/// This is a convenience function that:
226/// 1. Probes the root command
227/// 2. Recursively discovers all subcommands
228/// 3. Returns a complete CommandTree structure
229///
230/// # Arguments
231/// * `program` - The command to probe
232/// * `config` - Configuration for probing
233/// * `max_depth` - Maximum depth to recurse (default: 5, prevents infinite loops)
234pub async fn discover_all_subcommands(
235    program: &str,
236    config: &ProbeConfig,
237    max_depth: Option<usize>,
238) -> anyhow::Result<crate::model::CommandTree> {
239    let max_depth = max_depth.unwrap_or(5);
240
241    // First, probe the root command
242    let root_result = probe_command(program, &["--help".to_string()], config).await?;
243
244    // Recursively discover all subcommands
245    let subcommands = discover_subcommands_recursive(
246        program.to_string(),
247        Vec::new(),
248        ProbeConfig {
249            timeout_secs: config.timeout_secs,
250            require_help_flag: config.require_help_flag,
251            cache: config.cache.clone(),
252        },
253        max_depth,
254        0,
255    )
256    .await?;
257
258    // Count total commands (including nested)
259    let total_commands = count_subcommands(&subcommands) + 1; // +1 for root
260
261    Ok(crate::model::CommandTree {
262        command: program.to_string(),
263        options: root_result.options,
264        arguments: root_result.arguments,
265        subcommands,
266        total_commands,
267    })
268}
269
270/// Count total number of subcommands (including nested).
271fn count_subcommands(subcommands: &[crate::model::SubcommandSpec]) -> usize {
272    let mut count = subcommands.len();
273    for subcmd in subcommands {
274        count += count_subcommands(&subcmd.subcommands);
275    }
276    count
277}
278
279/// Recursively discover subcommands with a given path prefix.
280fn discover_subcommands_recursive(
281    program: String,
282    path: Vec<String>,
283    config: ProbeConfig,
284    max_depth: usize,
285    current_depth: usize,
286) -> std::pin::Pin<
287    Box<dyn std::future::Future<Output = anyhow::Result<Vec<crate::model::SubcommandSpec>>> + Send>,
288> {
289    Box::pin(async move {
290        if current_depth >= max_depth {
291            return Ok(Vec::new());
292        }
293
294        // Build command args: path + --help
295        let mut args = path.clone();
296        args.push("--help".to_string());
297
298        // Probe this level
299        let result = probe_command(&program, &args, &config).await?;
300
301        // Parse flat subcommands at this level
302        let flat_subcommands = parse_subcommands(&result.raw_stdout, &result.raw_stderr);
303
304        let mut hierarchical_subcommands = Vec::new();
305
306        for flat_sc in flat_subcommands {
307            let new_path: Vec<String> = path
308                .iter()
309                .cloned()
310                .chain(std::iter::once(flat_sc.name.clone()))
311                .collect();
312            let full_path = new_path.join(" ");
313
314            // Probe this subcommand to get its options, arguments, and nested subcommands
315            let mut subcommand_args = new_path.clone();
316            subcommand_args.push("--help".to_string());
317            let subcommand_result = probe_command(&program, &subcommand_args, &config).await?;
318
319            // Extract subcommand-specific options and arguments
320            let mut subcommand_options =
321                parse_options_from_usage_blocks(&subcommand_result.usage_blocks);
322            let mut section_options = parse_options_from_sections(
323                &subcommand_result.raw_stdout,
324                &subcommand_result.raw_stderr,
325            );
326            subcommand_options.append(&mut section_options);
327
328            // Deduplicate options
329            subcommand_options.sort_by(|a, b| {
330                let a_flags = format!("{:?}{:?}", a.short_flags, a.long_flags);
331                let b_flags = format!("{:?}{:?}", b.short_flags, b.long_flags);
332                a_flags.cmp(&b_flags)
333            });
334            subcommand_options
335                .dedup_by(|a, b| a.short_flags == b.short_flags && a.long_flags == b.long_flags);
336
337            let subcommand_arguments = parse_arguments(
338                &subcommand_result.raw_stdout,
339                &subcommand_result.raw_stderr,
340                &subcommand_result.usage_blocks,
341            );
342
343            // Recursively discover nested subcommands
344            let nested_subcommands = discover_subcommands_recursive(
345                program.clone(),
346                new_path.clone(),
347                config.clone(),
348                max_depth,
349                current_depth + 1,
350            )
351            .await?;
352
353            hierarchical_subcommands.push(crate::model::SubcommandSpec {
354                name: flat_sc.name,
355                description: flat_sc.description,
356                full_path: full_path.clone(),
357                parent: path.last().cloned(),
358                options: subcommand_options,
359                arguments: subcommand_arguments,
360                subcommands: nested_subcommands,
361            });
362        }
363
364        Ok(hierarchical_subcommands)
365    })
366}