apexe 0.6.0

Outside-In CLI-to-Agent Bridge
use std::sync::LazyLock;

use regex::Regex;

use crate::models::{ScannedArg, ScannedFlag, ValueType};
use crate::scanner::protocol::{CliParser, ParsedHelp};

// Precompiled once (parsers run per subcommand on the recursive scan hot path).
// INVARIANT: every pattern is a compile-time constant valid regex.
static FLAG_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(
        r"(?m)^\s{2,}(-([a-zA-Z]),?\s+)?(--([a-z][\w-]*))\s+(?:\s*/\s*--no-[\w-]+\s+)?(TEXT|INTEGER|FLOAT|PATH|FILENAME|DIRECTORY|<[^>]+>)?\s*(.+)",
    )
    .expect("valid static regex")
});
static TOGGLE_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"(?m)^\s{2,}(--([a-z][\w-]*))\s*/\s*(--no-[\w-]+)\s{2,}(.+)")
        .expect("valid static regex")
});
static DEFAULT_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\[default:\s*([^\]]+)\]").expect("valid static regex"));
static ENUM_RE: LazyLock<Regex> = LazyLock::new(|| {
    Regex::new(r"\[([a-zA-Z0-9_]+(?:\|[a-zA-Z0-9_]+)+)\]").expect("valid static regex")
});
static REQUIRED_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"\[required\]").expect("valid static regex"));
static COMMANDS_SECTION_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?mi)^Commands:").expect("valid static regex"));
static CMD_RE: LazyLock<Regex> =
    LazyLock::new(|| Regex::new(r"(?m)^\s{2,}([a-z][\w-]*)\s+\S").expect("valid static regex"));

/// Parser for Click/argparse-style help output.
///
/// Handles tools using Python Click or argparse:
/// - 'Usage: tool [OPTIONS] COMMAND [ARGS]...' header
/// - Options section with '  --flag TEXT  Description'
/// - Commands section with '  command  Description'
pub struct ClickHelpParser;

impl CliParser for ClickHelpParser {
    fn name(&self) -> &str {
        "click"
    }

    fn priority(&self) -> u32 {
        110
    }

    fn can_parse(&self, help_text: &str, _tool_name: &str) -> bool {
        help_text.contains("[OPTIONS]")
            && help_text.contains("Options:")
            && !help_text.contains("Available Commands:")
            && !help_text.contains("SUBCOMMANDS:")
    }

    fn parse(&self, help_text: &str, _tool_name: &str) -> anyhow::Result<ParsedHelp> {
        let description = extract_click_description(help_text);
        let flags = extract_click_flags(help_text);
        let positional_args = extract_click_args(help_text);
        let subcommand_names = extract_click_subcommands(help_text);
        let structured_output =
            super::structured_output::StructuredOutputDetector.detect(&flags, help_text);

        Ok(ParsedHelp {
            description,
            flags,
            positional_args,
            subcommand_names,
            examples: vec![],
            structured_output,
            help_format: crate::models::HelpFormat::Click,
        })
    }
}

fn extract_click_description(help_text: &str) -> String {
    let mut desc_lines = Vec::new();
    for line in help_text.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("Usage:") || trimmed.starts_with("Options:") {
            break;
        }
        if !trimmed.is_empty() {
            desc_lines.push(trimmed);
        }
    }
    let desc = desc_lines.join(" ");
    desc.chars().take(200).collect()
}

/// Build the boolean flag a `--flag / --no-flag` toggle line describes.
fn toggle_flag(long_name: String, description: String) -> ScannedFlag {
    ScannedFlag {
        long_name: Some(long_name),
        short_name: None,
        description,
        value_type: ValueType::Boolean,
        required: false,
        default: None,
        enum_values: None,
        repeatable: false,
        value_name: None,
        ..Default::default()
    }
}

/// Build the flag one `-f, --flag TEXT  Description` line describes.
///
/// Click states the default, the choice list and whether the option is required
/// inside the description prose rather than in the signature, so all three are
/// recovered from `description` rather than from the capture groups.
fn value_flag(
    short_name: Option<String>,
    long_name: Option<String>,
    type_str: Option<&str>,
    description: String,
) -> ScannedFlag {
    let default = DEFAULT_RE
        .captures(&description)
        .and_then(|c| c.get(1))
        .map(|m| m.as_str().trim().to_string());

    let enum_values = ENUM_RE
        .captures(&description)
        .and_then(|c| c.get(1))
        .map(|m| {
            m.as_str()
                .split('|')
                .map(|s| s.trim().to_string())
                .collect::<Vec<_>>()
        });

    // A choice list is the stronger statement: it names the accepted values,
    // where the placeholder only names their shape.
    let value_type = if enum_values.is_some() {
        ValueType::Enum
    } else {
        // One table for the whole scanner; see `scanner::value_placeholder`.
        crate::scanner::value_placeholder::flag_value_type(type_str)
    };

    ScannedFlag {
        long_name,
        short_name,
        required: REQUIRED_RE.is_match(&description),
        description,
        value_type,
        default,
        enum_values,
        repeatable: false,
        value_name: type_str.map(|s| s.trim_matches('<').trim_matches('>').to_string()),
        ..Default::default()
    }
}

/// Extract flags from Click's OPTIONS block.
///
/// Two passes over the same text: `--flag / --no-flag` boolean toggles first,
/// then ordinary options. The toggles go first because a toggle's two halves
/// also match the ordinary pattern, and `seen` is what stops the second pass
/// re-reporting them.
fn extract_click_flags(help_text: &str) -> Vec<ScannedFlag> {
    let mut flags = Vec::new();
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();

    for cap in TOGGLE_RE.captures_iter(help_text) {
        let long_name = format!("--{}", &cap[2]);
        if !seen.insert(long_name.clone()) {
            continue;
        }
        flags.push(toggle_flag(long_name, cap[4].trim().to_string()));
    }

    for cap in FLAG_RE.captures_iter(help_text) {
        let long_name = format!("--{}", &cap[4]);
        if !seen.insert(long_name.clone()) {
            continue;
        }
        flags.push(value_flag(
            cap.get(2).map(|m| format!("-{}", m.as_str())),
            Some(long_name),
            cap.get(5).map(|m| m.as_str()),
            cap.get(6)
                .map(|m| m.as_str().trim().to_string())
                .unwrap_or_default(),
        ));
    }

    flags
}

fn extract_click_args(help_text: &str) -> Vec<ScannedArg> {
    let mut args = Vec::new();

    for line in help_text.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with("Usage:") {
            args.extend(super::positional_args::extract_args_from_usage_line(
                trimmed,
            ));
        }
    }

    args
}

fn extract_click_subcommands(help_text: &str) -> Vec<String> {
    let mut names = Vec::new();

    if let Some(section_match) = COMMANDS_SECTION_RE.find(help_text) {
        let after_section = &help_text[section_match.end()..];
        for line in after_section.lines() {
            if line.trim().is_empty() || (!line.starts_with(' ') && !line.is_empty()) {
                if !names.is_empty() {
                    break;
                }
                continue;
            }
            if let Some(cap) = CMD_RE.captures(line) {
                names.push(cap[1].to_string());
            }
        }
    }

    names
}

// Structured output detection delegated to shared StructuredOutputDetector

#[cfg(test)]
mod tests {
    use super::*;

    const CLICK_HELP: &str = r#"Usage: flask [OPTIONS] COMMAND [ARGS]...

  A general utility script for Flask applications.

Options:
  --version          Show the flask version.
  -e, --env TEXT     The environment to use. [default: production]
  --debug / --no-debug
                     Enable debug mode.
  --help             Show this message and exit.

Commands:
  routes  Show the routes for the app.
  run     Run a development server.
  shell   Run a shell in the app context.
"#;

    #[test]
    fn test_click_can_parse() {
        let parser = ClickHelpParser;
        assert!(parser.can_parse(CLICK_HELP, "flask"));
    }

    #[test]
    fn test_click_rejects_cobra() {
        let parser = ClickHelpParser;
        let cobra = "Available Commands:\n  apply  Apply\n\nOptions:\n  [OPTIONS]\n";
        assert!(!parser.can_parse(cobra, "tool"));
    }

    #[test]
    fn test_click_parse_subcommands() {
        let parser = ClickHelpParser;
        let result = parser.parse(CLICK_HELP, "flask").unwrap();
        assert!(result.subcommand_names.contains(&"routes".to_string()));
        assert!(result.subcommand_names.contains(&"run".to_string()));
        assert!(result.subcommand_names.contains(&"shell".to_string()));
    }

    #[test]
    fn test_click_parse_flags_text_type() {
        let parser = ClickHelpParser;
        let result = parser.parse(CLICK_HELP, "flask").unwrap();
        let env_flag = result
            .flags
            .iter()
            .find(|f| f.long_name.as_deref() == Some("--env"));
        assert!(env_flag.is_some());
        let env_flag = env_flag.unwrap();
        assert_eq!(env_flag.value_type, ValueType::String);
        assert_eq!(env_flag.default.as_deref(), Some("production"));
    }

    #[test]
    fn test_click_parse_toggle_flag() {
        let parser = ClickHelpParser;
        let result = parser.parse(CLICK_HELP, "flask").unwrap();
        let debug_flag = result
            .flags
            .iter()
            .find(|f| f.long_name.as_deref() == Some("--debug"));
        assert!(debug_flag.is_some());
        assert_eq!(debug_flag.unwrap().value_type, ValueType::Boolean);
    }

    #[test]
    fn test_click_integer_type() {
        let help = "Usage: tool [OPTIONS]\n\nOptions:\n  --count INTEGER  Number of items\n";
        let flags = extract_click_flags(help);
        let count = flags
            .iter()
            .find(|f| f.long_name.as_deref() == Some("--count"));
        assert!(count.is_some());
        assert_eq!(count.unwrap().value_type, ValueType::Integer);
    }

    #[test]
    fn test_click_path_type() {
        let help = "Usage: tool [OPTIONS]\n\nOptions:\n  --input PATH  Input file\n";
        let flags = extract_click_flags(help);
        let input = flags
            .iter()
            .find(|f| f.long_name.as_deref() == Some("--input"));
        assert!(input.is_some());
        assert_eq!(input.unwrap().value_type, ValueType::Path);
    }

    #[test]
    fn test_click_required_detection() {
        let help = "Usage: tool [OPTIONS]\n\nOptions:\n  --name TEXT  Your name [required]\n";
        let flags = extract_click_flags(help);
        let name = flags
            .iter()
            .find(|f| f.long_name.as_deref() == Some("--name"));
        assert!(name.is_some());
        assert!(name.unwrap().required);
    }

    #[test]
    fn test_click_enum_detection() {
        let help =
            "Usage: tool [OPTIONS]\n\nOptions:\n  --format TEXT  Output format [json|text|csv]\n";
        let flags = extract_click_flags(help);
        let fmt = flags
            .iter()
            .find(|f| f.long_name.as_deref() == Some("--format"));
        assert!(fmt.is_some());
        let fmt = fmt.unwrap();
        assert_eq!(fmt.value_type, ValueType::Enum);
        assert_eq!(
            fmt.enum_values,
            Some(vec!["json".into(), "text".into(), "csv".into()])
        );
    }
}