help-probe 0.1.0

CLI tool discovery and automation framework that extracts structured information from command help text
Documentation
pub mod api_docs;
pub mod builder;
pub mod cache;
pub mod completion;
pub mod model;
pub mod parser;
pub mod runner;
pub mod validation;

use std::time::Duration;

use anyhow::Result;

use crate::model::ProbeResult;
use crate::parser::{
    detect_help_flag, parse_arguments, parse_environment_variables, parse_examples,
    parse_options_from_sections, parse_options_from_usage_blocks, parse_subcommands, parse_usages,
    parse_validation_rules,
};
use crate::runner::{RunOutcome, run_with_timeout};

/// Configuration for probing a command.
#[derive(Clone)]
pub struct ProbeConfig {
    /// Timeout in seconds for running the target command.
    pub timeout_secs: u64,
    /// Whether to require a "help" flag (help/-h/--help/etc).
    ///
    /// Currently this is advisory: we still run even if no help flag.
    pub require_help_flag: bool,
    /// Cache configuration.
    pub cache: Option<crate::cache::CacheConfig>,
}

/// Common help flags to try automatically if none are detected.
const COMMON_HELP_FLAGS: &[&str] = &["--help", "-h", "help", "--usage", "-?", "/?"];

/// Try to find a working help flag by testing common flags.
/// Returns the args with a help flag appended, or the original args if a help flag was already present.
async fn ensure_help_flag(
    program: &str,
    args: &[String],
    timeout: Duration,
) -> (Vec<String>, bool) {
    // If a help flag is already present, use the args as-is
    if detect_help_flag(args) {
        return (args.to_vec(), true);
    }

    // Use a shorter timeout for testing help flags (help should be fast)
    let test_timeout = Duration::from_secs(1).min(timeout);

    // Try each common help flag until one works (exit code 0)
    for help_flag in COMMON_HELP_FLAGS {
        let test_args = {
            let mut new_args = args.to_vec();
            new_args.push(help_flag.to_string());
            new_args
        };

        // Quick test: try running with this help flag
        if let Ok(RunOutcome::Completed(output)) =
            run_with_timeout(program, &test_args, test_timeout).await
        {
            // If exit code is 0, this help flag works
            if output.status.code() == Some(0) {
                return (test_args, true);
            }
            // If exit code is non-zero but we got output, it might still be help text
            // (some programs return non-zero for help, but still print it)
            if !output.stdout.is_empty() || !output.stderr.is_empty() {
                // Check if output looks like help text
                let combined = format!(
                    "{}\n{}",
                    String::from_utf8_lossy(&output.stdout),
                    String::from_utf8_lossy(&output.stderr)
                );
                let combined_lower = combined.to_lowercase();
                if combined_lower.contains("usage")
                    || combined_lower.contains("options")
                    || combined_lower.contains("commands")
                    || combined_lower.contains("help")
                {
                    return (test_args, true);
                }
            }
        }
    }

    // If none worked, just use --help as default (most common)
    let mut default_args = args.to_vec();
    default_args.push("--help".to_string());
    (default_args, false)
}

/// High-level API: probe a command and return a structured result.
///
/// This does NOT handle prompting or user interaction.
/// Automatically tries common help flags if none are detected.
pub async fn probe_command(
    program: &str,
    args: &[String],
    config: &ProbeConfig,
) -> Result<ProbeResult> {
    let timeout = Duration::from_secs(config.timeout_secs);

    // Automatically ensure a help flag is present
    let (final_args, help_flag_detected) = ensure_help_flag(program, args, timeout).await;

    // Check cache with final args
    if let Some(cache_config) = &config.cache {
        if let Some(cached_result) = crate::cache::read_cache(program, &final_args, cache_config)? {
            return Ok(cached_result);
        }
    }

    if config.require_help_flag && !help_flag_detected {
        // Caller can decide how to react; we still run the command.
        // Could also early-return an Err if strict is desired.
    }

    let outcome = run_with_timeout(program, &final_args, timeout).await?;

    let mut result = ProbeResult {
        command: program.to_string(),
        args: final_args.clone(),
        exit_code: None,
        timed_out: false,
        help_flag_detected,
        usage_blocks: Vec::new(),
        options: Vec::new(),
        subcommands: Vec::new(),
        arguments: Vec::new(),
        examples: Vec::new(),
        environment_variables: Vec::new(),
        validation_rules: Vec::new(),
        raw_stdout: String::new(),
        raw_stderr: String::new(),
    };

    match outcome {
        RunOutcome::Completed(output) => {
            result.exit_code = output.status.code();
            result.raw_stdout = String::from_utf8_lossy(&output.stdout).to_string();
            result.raw_stderr = String::from_utf8_lossy(&output.stderr).to_string();

            result.usage_blocks = parse_usages(&output.stdout, &output.stderr);

            // Combine options from usage blocks and OPTIONS sections
            let mut options = parse_options_from_usage_blocks(&result.usage_blocks);
            let mut section_options =
                parse_options_from_sections(&result.raw_stdout, &result.raw_stderr);
            options.append(&mut section_options);

            // Deduplicate options (same flags)
            options.sort_by(|a, b| {
                let a_flags = format!("{:?}{:?}", a.short_flags, a.long_flags);
                let b_flags = format!("{:?}{:?}", b.short_flags, b.long_flags);
                a_flags.cmp(&b_flags)
            });
            options.dedup_by(|a, b| a.short_flags == b.short_flags && a.long_flags == b.long_flags);

            result.options = options;
            result.subcommands = parse_subcommands(&result.raw_stdout, &result.raw_stderr);
            result.arguments =
                parse_arguments(&result.raw_stdout, &result.raw_stderr, &result.usage_blocks);
            result.examples = parse_examples(&result.raw_stdout, &result.raw_stderr);
            result.environment_variables = parse_environment_variables(
                &result.raw_stdout,
                &result.raw_stderr,
                &result.options,
            );
            result.validation_rules = parse_validation_rules(
                &result.raw_stdout,
                &result.raw_stderr,
                &result.options,
                &result.arguments,
            );
        }
        RunOutcome::TimedOut => {
            result.timed_out = true;
        }
    }

    // Write to cache if enabled (use final_args, not original args)
    if let Some(cache_config) = &config.cache {
        let _ = crate::cache::write_cache(program, &final_args, &result, cache_config);
    }

    Ok(result)
}

/// Recursively discover subcommand hierarchy by probing each subcommand.
///
/// This function probes each subcommand to discover:
/// - Subcommand-specific options
/// - Subcommand-specific arguments
/// - Nested subcommands
///
/// # Arguments
/// * `program` - The command to probe
/// * `config` - Configuration for probing
/// * `max_depth` - Maximum depth to recurse (prevents infinite loops)
pub async fn discover_subcommand_hierarchy(
    program: &str,
    config: &ProbeConfig,
    max_depth: usize,
) -> anyhow::Result<Vec<crate::model::SubcommandSpec>> {
    discover_subcommands_recursive(
        program.to_string(),
        Vec::new(),
        ProbeConfig {
            timeout_secs: config.timeout_secs,
            require_help_flag: config.require_help_flag,
            cache: config.cache.clone(),
        },
        max_depth,
        0,
    )
    .await
}

/// Discover all subcommands recursively and build a complete command tree.
///
/// This is a convenience function that:
/// 1. Probes the root command
/// 2. Recursively discovers all subcommands
/// 3. Returns a complete CommandTree structure
///
/// # Arguments
/// * `program` - The command to probe
/// * `config` - Configuration for probing
/// * `max_depth` - Maximum depth to recurse (default: 5, prevents infinite loops)
pub async fn discover_all_subcommands(
    program: &str,
    config: &ProbeConfig,
    max_depth: Option<usize>,
) -> anyhow::Result<crate::model::CommandTree> {
    let max_depth = max_depth.unwrap_or(5);

    // First, probe the root command
    let root_result = probe_command(program, &["--help".to_string()], config).await?;

    // Recursively discover all subcommands
    let subcommands = discover_subcommands_recursive(
        program.to_string(),
        Vec::new(),
        ProbeConfig {
            timeout_secs: config.timeout_secs,
            require_help_flag: config.require_help_flag,
            cache: config.cache.clone(),
        },
        max_depth,
        0,
    )
    .await?;

    // Count total commands (including nested)
    let total_commands = count_subcommands(&subcommands) + 1; // +1 for root

    Ok(crate::model::CommandTree {
        command: program.to_string(),
        options: root_result.options,
        arguments: root_result.arguments,
        subcommands,
        total_commands,
    })
}

/// Count total number of subcommands (including nested).
fn count_subcommands(subcommands: &[crate::model::SubcommandSpec]) -> usize {
    let mut count = subcommands.len();
    for subcmd in subcommands {
        count += count_subcommands(&subcmd.subcommands);
    }
    count
}

/// Recursively discover subcommands with a given path prefix.
fn discover_subcommands_recursive(
    program: String,
    path: Vec<String>,
    config: ProbeConfig,
    max_depth: usize,
    current_depth: usize,
) -> std::pin::Pin<
    Box<dyn std::future::Future<Output = anyhow::Result<Vec<crate::model::SubcommandSpec>>> + Send>,
> {
    Box::pin(async move {
        if current_depth >= max_depth {
            return Ok(Vec::new());
        }

        // Build command args: path + --help
        let mut args = path.clone();
        args.push("--help".to_string());

        // Probe this level
        let result = probe_command(&program, &args, &config).await?;

        // Parse flat subcommands at this level
        let flat_subcommands = parse_subcommands(&result.raw_stdout, &result.raw_stderr);

        let mut hierarchical_subcommands = Vec::new();

        for flat_sc in flat_subcommands {
            let new_path: Vec<String> = path
                .iter()
                .cloned()
                .chain(std::iter::once(flat_sc.name.clone()))
                .collect();
            let full_path = new_path.join(" ");

            // Probe this subcommand to get its options, arguments, and nested subcommands
            let mut subcommand_args = new_path.clone();
            subcommand_args.push("--help".to_string());
            let subcommand_result = probe_command(&program, &subcommand_args, &config).await?;

            // Extract subcommand-specific options and arguments
            let mut subcommand_options =
                parse_options_from_usage_blocks(&subcommand_result.usage_blocks);
            let mut section_options = parse_options_from_sections(
                &subcommand_result.raw_stdout,
                &subcommand_result.raw_stderr,
            );
            subcommand_options.append(&mut section_options);

            // Deduplicate options
            subcommand_options.sort_by(|a, b| {
                let a_flags = format!("{:?}{:?}", a.short_flags, a.long_flags);
                let b_flags = format!("{:?}{:?}", b.short_flags, b.long_flags);
                a_flags.cmp(&b_flags)
            });
            subcommand_options
                .dedup_by(|a, b| a.short_flags == b.short_flags && a.long_flags == b.long_flags);

            let subcommand_arguments = parse_arguments(
                &subcommand_result.raw_stdout,
                &subcommand_result.raw_stderr,
                &subcommand_result.usage_blocks,
            );

            // Recursively discover nested subcommands
            let nested_subcommands = discover_subcommands_recursive(
                program.clone(),
                new_path.clone(),
                config.clone(),
                max_depth,
                current_depth + 1,
            )
            .await?;

            hierarchical_subcommands.push(crate::model::SubcommandSpec {
                name: flat_sc.name,
                description: flat_sc.description,
                full_path: full_path.clone(),
                parent: path.last().cloned(),
                options: subcommand_options,
                arguments: subcommand_arguments,
                subcommands: nested_subcommands,
            });
        }

        Ok(hierarchical_subcommands)
    })
}