use anyhow::{bail, Context, Result};
use clap::Args;
use colored::Colorize;
use regex::Regex;
use serde::Serialize;
use std::path::PathBuf;
use super::OutputConfig;
use crate::access::RepoFilter;
use crate::config::Config;
use crate::storage::VectorStore;
use crate::types::{ChunkType, SearchResult};
#[derive(Args)]
pub struct GrepArgs {
pattern: String,
#[arg(long, short = 'i')]
ignore_case: bool,
#[arg(long, short = 'E')]
regex: bool,
#[arg(long, short = 't')]
r#type: Option<String>,
#[arg(long, short = 'n', default_value = "10")]
limit: usize,
#[arg(long, short = 'C', default_value = "0")]
context: usize,
#[arg(long, short = 'r')]
repo: Option<String>,
#[arg(default_value = ".")]
path: PathBuf,
}
#[derive(Serialize)]
struct GrepOutput {
pattern: String,
#[serde(skip_serializing_if = "Option::is_none")]
r#type: Option<String>,
ignore_case: bool,
regex: bool,
limit: usize,
count: usize,
results: Vec<GrepResultOutput>,
}
#[derive(Serialize)]
struct GrepResultOutput {
file_path: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
chunk_type: String,
start_line: u32,
end_line: u32,
score: f32,
language: String,
#[serde(skip_serializing_if = "Option::is_none")]
content_preview: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
matching_lines: Vec<MatchingLine>,
}
#[derive(Serialize)]
struct MatchingLine {
line_number: u32,
content: String,
}
pub async fn run(args: GrepArgs, output: OutputConfig) -> Result<()> {
let repo_root = args
.path
.canonicalize()
.with_context(|| format!("Invalid path: {}", args.path.display()))?;
let config_path = Config::config_path(&repo_root);
if !config_path.exists() {
bail!("{}", super::not_initialized_error(&repo_root));
}
let type_filter = args
.r#type
.as_ref()
.map(|t| parse_chunk_type(t))
.transpose()?;
let regex_pattern = if args.regex {
let pattern = if args.ignore_case {
format!("(?i){}", args.pattern)
} else {
args.pattern.clone()
};
Some(
Regex::new(&pattern)
.with_context(|| format!("Invalid regex pattern: {}", args.pattern))?,
)
} else {
None
};
let lance_path = Config::lance_path(&repo_root);
let vector_store = VectorStore::open(&lance_path)
.await
.context("Failed to open vector store")?;
let stats = vector_store.get_stats(None).await?;
if stats.total_chunks == 0 {
if output.json {
println!(
r#"{{"error": "empty_index", "message": "No indexed content. Run `bobbin index` first."}}"#
);
} else if !output.quiet {
println!(
"{} No indexed content. Run `bobbin index` first.",
"!".yellow()
);
}
return Ok(());
}
let fts_query = if args.regex {
extract_fts_terms(&args.pattern)
} else {
args.pattern.clone()
};
let search_limit = if type_filter.is_some() || args.regex || !args.ignore_case {
args.limit * 5
} else {
args.limit
};
let results = vector_store
.search_fts(&fts_query, search_limit, args.repo.as_deref())
.await
.context("FTS search failed")?;
let config = Config::load(&config_path).unwrap_or_default();
let access_filter = RepoFilter::from_config(&config.access, &output.role);
let results =
access_filter.filter_vec(results, |r| RepoFilter::repo_from_path(&r.chunk.file_path));
let filtered_results: Vec<SearchResult> = results
.into_iter()
.filter(|r| {
if let Some(ref chunk_type) = type_filter {
&r.chunk.chunk_type == chunk_type
} else {
true
}
})
.filter(|r| {
if let Some(ref re) = regex_pattern {
re.is_match(&r.chunk.content)
|| r.chunk.name.as_ref().is_some_and(|n| re.is_match(n))
} else {
true
}
})
.filter(|r| {
if !args.ignore_case && regex_pattern.is_none() {
r.chunk.content.contains(&args.pattern)
|| r.chunk
.name
.as_ref()
.is_some_and(|n| n.contains(&args.pattern))
} else {
true
}
})
.take(args.limit)
.collect();
if output.json {
print_json_output(&args, &filtered_results, regex_pattern.as_ref())?;
} else if !output.quiet {
print_human_output(
&args,
&filtered_results,
regex_pattern.as_ref(),
output.verbose,
);
}
Ok(())
}
fn extract_fts_terms(pattern: &str) -> String {
let cleaned: String = pattern
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == ' ' {
c
} else {
' '
}
})
.collect();
let words: Vec<&str> = cleaned
.split_whitespace()
.filter(|w| w.len() >= 2)
.collect();
if words.is_empty() {
pattern.to_string()
} else {
words.join(" OR ")
}
}
fn parse_chunk_type(s: &str) -> Result<ChunkType> {
match s.to_lowercase().as_str() {
"function" | "func" | "fn" => Ok(ChunkType::Function),
"method" => Ok(ChunkType::Method),
"class" => Ok(ChunkType::Class),
"struct" => Ok(ChunkType::Struct),
"enum" => Ok(ChunkType::Enum),
"interface" => Ok(ChunkType::Interface),
"module" | "mod" => Ok(ChunkType::Module),
"impl" => Ok(ChunkType::Impl),
"trait" => Ok(ChunkType::Trait),
"doc" | "documentation" => Ok(ChunkType::Doc),
"section" => Ok(ChunkType::Section),
"table" => Ok(ChunkType::Table),
"code_block" | "codeblock" => Ok(ChunkType::CodeBlock),
"commit" => Ok(ChunkType::Commit),
"issue" | "bead" => Ok(ChunkType::Issue),
"other" => Ok(ChunkType::Other),
_ => bail!(
"Unknown chunk type '{}'. Valid types: function, method, class, struct, enum, interface, module, impl, trait, doc, section, table, code_block, commit, issue, other",
s
),
}
}
fn find_matching_lines(
content: &str,
pattern: &str,
regex: Option<&Regex>,
ignore_case: bool,
context: usize,
start_line: u32,
) -> Vec<MatchingLine> {
let lines: Vec<&str> = content.lines().collect();
let mut matching_indices = Vec::new();
for (idx, line) in lines.iter().enumerate() {
let matches = if let Some(re) = regex {
re.is_match(line)
} else if ignore_case {
line.to_lowercase().contains(&pattern.to_lowercase())
} else {
line.contains(pattern)
};
if matches {
matching_indices.push(idx);
}
}
let mut included: std::collections::HashSet<usize> = std::collections::HashSet::new();
for &idx in &matching_indices {
let start = idx.saturating_sub(context);
let end = (idx + context + 1).min(lines.len());
for i in start..end {
included.insert(i);
}
}
let mut result: Vec<MatchingLine> = included
.into_iter()
.map(|idx| MatchingLine {
line_number: start_line + idx as u32,
content: lines[idx].to_string(),
})
.collect();
result.sort_by_key(|m| m.line_number);
result
}
fn print_json_output(
args: &GrepArgs,
results: &[SearchResult],
regex: Option<&Regex>,
) -> Result<()> {
let output = GrepOutput {
pattern: args.pattern.clone(),
r#type: args.r#type.clone(),
ignore_case: args.ignore_case,
regex: args.regex,
limit: args.limit,
count: results.len(),
results: results
.iter()
.map(|r| {
let matching_lines = if args.context > 0 || args.regex {
find_matching_lines(
&r.chunk.content,
&args.pattern,
regex,
args.ignore_case,
args.context,
r.chunk.start_line,
)
} else {
Vec::new()
};
GrepResultOutput {
file_path: r.chunk.file_path.clone(),
name: r.chunk.name.clone(),
chunk_type: r.chunk.chunk_type.to_string(),
start_line: r.chunk.start_line,
end_line: r.chunk.end_line,
score: normalize_bm25_score(r.score),
language: r.chunk.language.clone(),
content_preview: Some(truncate_content(&r.chunk.content, 200)),
matching_lines,
}
})
.collect(),
};
println!("{}", serde_json::to_string_pretty(&output)?);
Ok(())
}
fn print_human_output(
args: &GrepArgs,
results: &[SearchResult],
regex: Option<&Regex>,
verbose: bool,
) {
if results.is_empty() {
println!(
"{} No results found for: {}",
"!".yellow(),
args.pattern.cyan()
);
return;
}
println!(
"{} Found {} results for: {}",
"✓".green(),
results.len(),
args.pattern.cyan()
);
println!();
for (i, result) in results.iter().enumerate() {
let chunk = &result.chunk;
let score = normalize_bm25_score(result.score);
let score_pct = (score * 100.0) as u32;
let name_display = chunk
.name
.as_ref()
.map(|n| format!(" ({})", n.cyan()))
.unwrap_or_default();
println!(
"{}. {}:{}{}",
(i + 1).to_string().bold(),
chunk.file_path.blue(),
chunk.start_line,
name_display
);
println!(
" {} {} · lines {}-{} · {}% relevance",
chunk.chunk_type.to_string().magenta(),
chunk.language.dimmed(),
chunk.start_line,
chunk.end_line,
score_pct
);
if args.context > 0 || verbose {
let matching_lines = find_matching_lines(
&chunk.content,
&args.pattern,
regex,
args.ignore_case,
if args.context > 0 { args.context } else { 2 },
chunk.start_line,
);
if !matching_lines.is_empty() {
for ml in matching_lines.iter().take(10) {
let highlighted =
highlight_match(&ml.content, &args.pattern, regex, args.ignore_case);
println!(
" {}: {}",
ml.line_number.to_string().dimmed(),
highlighted
);
}
if matching_lines.len() > 10 {
println!(
" {}",
format!("... {} more lines", matching_lines.len() - 10).dimmed()
);
}
}
}
println!();
}
}
fn highlight_match(line: &str, pattern: &str, regex: Option<&Regex>, ignore_case: bool) -> String {
if let Some(re) = regex {
re.replace_all(line, |caps: ®ex::Captures| {
format!("{}", caps[0].to_string().red().bold())
})
.to_string()
} else if ignore_case {
let lower_line = line.to_lowercase();
let lower_pattern = pattern.to_lowercase();
let mut result = String::new();
let mut last_end = 0;
for (start, _) in lower_line.match_indices(&lower_pattern) {
result.push_str(&line[last_end..start]);
result.push_str(&format!(
"{}",
line[start..start + pattern.len()].red().bold()
));
last_end = start + pattern.len();
}
result.push_str(&line[last_end..]);
result
} else {
line.replace(pattern, &format!("{}", pattern.red().bold()))
}
}
fn normalize_bm25_score(bm25_score: f32) -> f32 {
if bm25_score < 0.001 {
0.0
} else {
1.0 - (1.0 / (1.0 + bm25_score / 5.0))
}
}
fn truncate_content(content: &str, max_len: usize) -> String {
if content.len() <= max_len {
content.to_string()
} else {
let truncated: String = content.chars().take(max_len).collect();
format!("{}...", truncated.trim_end())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_chunk_type_valid() {
assert_eq!(parse_chunk_type("function").unwrap(), ChunkType::Function);
assert_eq!(parse_chunk_type("func").unwrap(), ChunkType::Function);
assert_eq!(parse_chunk_type("fn").unwrap(), ChunkType::Function);
assert_eq!(parse_chunk_type("method").unwrap(), ChunkType::Method);
assert_eq!(parse_chunk_type("class").unwrap(), ChunkType::Class);
assert_eq!(parse_chunk_type("struct").unwrap(), ChunkType::Struct);
assert_eq!(parse_chunk_type("enum").unwrap(), ChunkType::Enum);
assert_eq!(parse_chunk_type("interface").unwrap(), ChunkType::Interface);
assert_eq!(parse_chunk_type("module").unwrap(), ChunkType::Module);
assert_eq!(parse_chunk_type("mod").unwrap(), ChunkType::Module);
assert_eq!(parse_chunk_type("impl").unwrap(), ChunkType::Impl);
assert_eq!(parse_chunk_type("trait").unwrap(), ChunkType::Trait);
assert_eq!(parse_chunk_type("other").unwrap(), ChunkType::Other);
}
#[test]
fn test_parse_chunk_type_case_insensitive() {
assert_eq!(parse_chunk_type("FUNCTION").unwrap(), ChunkType::Function);
assert_eq!(parse_chunk_type("Function").unwrap(), ChunkType::Function);
assert_eq!(parse_chunk_type("STRUCT").unwrap(), ChunkType::Struct);
assert_eq!(parse_chunk_type("Trait").unwrap(), ChunkType::Trait);
}
#[test]
fn test_parse_chunk_type_invalid() {
assert!(parse_chunk_type("invalid").is_err());
assert!(parse_chunk_type("").is_err());
assert!(parse_chunk_type("functon").is_err()); }
#[test]
fn test_extract_fts_terms() {
assert_eq!(extract_fts_terms("hello"), "hello");
assert_eq!(extract_fts_terms("hello.*world"), "hello OR world");
assert_eq!(extract_fts_terms("foo_bar"), "foo_bar");
assert_eq!(extract_fts_terms("fn\\s+\\w+"), "fn");
}
#[test]
fn test_normalize_bm25_score() {
let score1 = normalize_bm25_score(20.0);
let score2 = normalize_bm25_score(5.0);
assert!(score1 > 0.0 && score1 <= 1.0);
assert!(score2 > 0.0 && score2 <= 1.0);
assert!(
score1 > score2,
"score1={} should be greater than score2={} because 20 > 5",
score1,
score2
);
assert!(normalize_bm25_score(0.0) < 0.01);
}
#[test]
fn test_truncate_content_short() {
let content = "short content";
let result = truncate_content(content, 100);
assert_eq!(result, "short content");
}
#[test]
fn test_truncate_content_long() {
let content = "This is a very long piece of content that should be truncated";
let result = truncate_content(content, 20);
assert_eq!(result, "This is a very long...");
}
#[test]
fn test_find_matching_lines_basic() {
let content = "line 1\nline 2 with pattern\nline 3";
let result = find_matching_lines(content, "pattern", None, false, 0, 1);
assert_eq!(result.len(), 1);
assert_eq!(result[0].line_number, 2);
assert!(result[0].content.contains("pattern"));
}
#[test]
fn test_find_matching_lines_with_context() {
let content = "line 1\nline 2\nline 3 with pattern\nline 4\nline 5";
let result = find_matching_lines(content, "pattern", None, false, 1, 1);
assert_eq!(result.len(), 3);
assert_eq!(result[0].line_number, 2);
assert_eq!(result[1].line_number, 3);
assert_eq!(result[2].line_number, 4);
}
#[test]
fn test_find_matching_lines_case_insensitive() {
let content = "line 1\nline 2 with PATTERN\nline 3";
let result = find_matching_lines(content, "pattern", None, true, 0, 1);
assert_eq!(result.len(), 1);
assert_eq!(result[0].line_number, 2);
}
#[test]
fn test_find_matching_lines_with_regex() {
let content = "fn foo()\nfn bar()\nlet x = 1";
let re = Regex::new(r"fn\s+\w+").unwrap();
let result = find_matching_lines(content, "", Some(&re), false, 0, 1);
assert_eq!(result.len(), 2);
}
#[test]
fn test_highlight_match_simple() {
let line = "hello world";
let result = highlight_match(line, "world", None, false);
assert!(result.contains("world"));
}
#[test]
fn test_highlight_match_case_insensitive() {
let line = "Hello WORLD";
let result = highlight_match(line, "world", None, true);
assert!(result.contains("WORLD"));
}
}