use clap::{Parser, ValueEnum};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
name = "opengrep",
version,
about = "Advanced AST-aware code search with AI enhancement",
long_about = "OpenGrep is a next-generation code search tool that understands your code structure \
and provides intelligent insights. It combines the power of tree-sitter for AST parsing \
with optional AI integration for code analysis and suggestions."
)]
pub struct Cli {
#[arg(value_name = "PATTERN", help = "The pattern to search for")]
pub pattern: String,
#[arg(value_name = "PATH", help = "Files or directories to search")]
pub paths: Vec<PathBuf>,
#[arg(short, long, help = "Perform case-insensitive matching")]
pub ignore_case: bool,
#[arg(short = 'e', long = "regex", help = "Treat pattern as a regular expression")]
pub regex: bool,
#[arg(short = 'n', long, help = "Show line numbers with matches")]
pub line_numbers: bool,
#[arg(
short = 'B',
long,
default_value = "2",
help = "Number of lines to show before each match"
)]
pub before_context: usize,
#[arg(
short = 'A',
long,
default_value = "2",
help = "Number of lines to show after each match"
)]
pub after_context: usize,
#[arg(
short = 'a',
long = "ast-context",
help = "Show AST context (functions, classes, etc.) around matches"
)]
pub ast_context: bool,
#[arg(
long,
default_value = "3",
help = "Maximum depth of AST context to display"
)]
pub max_ast_depth: usize,
#[arg(
short = 'o',
long,
value_enum,
default_value = "text",
help = "Output format"
)]
pub output_format: OutputFormat,
#[arg(
long,
conflicts_with = "no_color",
help = "Force colored output even when not to a terminal"
)]
pub color: Option<bool>,
#[arg(long, help = "Never use colored output")]
pub no_color: bool,
#[arg(
short = 'j',
long,
help = "Number of search threads (default: number of CPU cores)"
)]
pub threads: Option<usize>,
#[arg(long, help = "Maximum directory traversal depth")]
pub max_depth: Option<usize>,
#[arg(long, help = "Skip files larger than this size")]
pub max_file_size: Option<u64>,
#[arg(short = 'H', long, help = "Search hidden files and directories")]
pub hidden: bool,
#[arg(long, help = "Don't respect .gitignore files")]
pub no_ignore: bool,
#[arg(short = 'L', long, help = "Follow symbolic links")]
pub follow_symlinks: bool,
#[arg(
short = 'l',
long = "lang",
help = "Only search files of specified language(s)"
)]
pub languages: Vec<String>,
#[arg(
short = 'x',
long = "exclude",
help = "Exclude files/directories matching pattern"
)]
pub exclude: Vec<String>,
#[arg(long = "include", help = "Only search files matching pattern")]
pub include: Vec<String>,
#[arg(short = 'i', long, help = "Start in interactive mode")]
pub interactive: bool,
#[arg(short = 'c', long, help = "Only show count of matching lines")]
pub count: bool,
#[arg(short = 'l', long = "files-with-matches", help = "Only show files with matches")]
pub files_with_matches: bool,
#[arg(long, help = "Enable AI-powered insights and analysis")]
pub ai_insights: bool,
#[arg(long, help = "Enable AI code explanation for matches")]
pub ai_explain: bool,
#[arg(
long,
default_value = "gpt-4o-mini",
help = "AI model to use for insights"
)]
pub ai_model: String,
#[arg(long, help = "List all supported programming languages")]
pub list_languages: bool,
#[arg(long, help = "Show search statistics")]
pub stats: bool,
#[arg(long, help = "Show what would be searched without searching")]
pub dry_run: bool,
#[arg(long, help = "Path to configuration file")]
pub config: Option<PathBuf>,
#[arg(
short = 'v',
long,
action = clap::ArgAction::Count,
help = "Verbose output (-v, -vv, -vvv for increasing verbosity)"
)]
pub verbose: u8,
#[arg(long, help = "Print version information")]
pub version: bool,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum OutputFormat {
Text,
Json,
Html,
Xml,
Csv,
}
impl Cli {
pub fn validate(&self) -> anyhow::Result<()> {
if self.pattern.is_empty() && !self.interactive && !self.list_languages && !self.version {
anyhow::bail!("No search pattern provided (use -i for interactive mode)");
}
if self.before_context > 1000 || self.after_context > 1000 {
anyhow::bail!("Context lines must be <= 1000");
}
if self.max_ast_depth > 20 {
anyhow::bail!("AST depth must be <= 20");
}
if let Some(threads) = self.threads {
if threads == 0 || threads > 256 {
anyhow::bail!("Thread count must be between 1 and 256");
}
}
if let Some(max_file_size) = self.max_file_size {
if max_file_size > 1024 * 1024 * 1024 * 10 { anyhow::bail!("Maximum file size cannot exceed 10GB");
}
}
if self.ai_insights || self.ai_explain {
if std::env::var("OPENAI_API_KEY").is_err() {
anyhow::bail!("OPENAI_API_KEY environment variable required for AI features");
}
}
Ok(())
}
pub fn should_use_color(&self) -> bool {
if self.no_color {
false
} else if let Some(color) = self.color {
color
} else {
atty::is(atty::Stream::Stdout)
}
}
pub fn has_ai_features(&self) -> bool {
self.ai_insights || self.ai_explain
}
pub fn effective_threads(&self) -> usize {
self.threads.unwrap_or_else(num_cpus::get)
}
}
pub fn generate_completions(shell: clap_complete::Shell) -> String {
use clap::CommandFactory;
use clap_complete::generate;
let mut cmd = Cli::command();
let mut buf = Vec::new();
generate(shell, &mut cmd, "opengrep", &mut buf);
String::from_utf8(buf).expect("Failed to convert completion to string")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cli_parsing() {
let cli = Cli::parse_from(&["opengrep", "test", "src/"]);
assert_eq!(cli.pattern, "test");
assert_eq!(cli.paths, vec![PathBuf::from("src/")]);
assert!(!cli.ignore_case);
assert!(!cli.regex);
}
#[test]
fn test_cli_validation() {
let mut cli = Cli::parse_from(&["opengrep", "test"]);
assert!(cli.validate().is_ok());
cli.pattern = String::new();
assert!(cli.validate().is_err());
cli.pattern = "test".to_string();
cli.before_context = 2000;
assert!(cli.validate().is_err());
}
#[test]
fn test_color_setting() {
let cli = Cli::parse_from(&["opengrep", "test"]);
assert!(cli.should_use_color() || !atty::is(atty::Stream::Stdout));
let cli = Cli::parse_from(&["opengrep", "test", "--no-color"]);
assert!(!cli.should_use_color());
}
#[test]
fn test_ai_features() {
let cli = Cli::parse_from(&["opengrep", "test"]);
assert!(!cli.has_ai_features());
let cli = Cli::parse_from(&["opengrep", "test", "--ai-insights"]);
assert!(cli.has_ai_features());
}
#[test]
fn test_effective_threads() {
let cli = Cli::parse_from(&["opengrep", "test"]);
assert_eq!(cli.effective_threads(), num_cpus::get());
let cli = Cli::parse_from(&["opengrep", "test", "-j", "4"]);
assert_eq!(cli.effective_threads(), 4);
}
}