use std::collections::BTreeMap;
use std::path::Path;
use crate::context::AppContext;
use crate::grep_executor::{self, GrepParams};
use crate::pattern_compile::{self, CompileOpts, CompileResult};
use crate::protocol::{RawRequest, Response};
use crate::search_index::{build_path_filters, GrepMatch, GrepResult, IndexStatus};
pub(crate) use crate::grep_executor::ripgrep_glob;
const DEFAULT_MAX_RESULTS: usize = 100;
const MAX_LINE_CHARS: usize = 200;
const MAX_MATCHES_PER_FILE: usize = 10;
const MAX_DISPLAY_MATCHES_PER_FILE: usize = 5;
pub fn handle_grep(req: &RawRequest, ctx: &AppContext) -> Response {
let pattern = match req.params.get("pattern").and_then(|value| value.as_str()) {
Some(pattern) => pattern,
None => {
return Response::error(
&req.id,
"invalid_request",
"grep: missing required param 'pattern'",
);
}
};
let case_sensitive = req
.params
.get("case_sensitive")
.and_then(|value| value.as_bool())
.unwrap_or(true);
let include = string_array_param(&req.params, "include");
let exclude = string_array_param(&req.params, "exclude");
let max_results = req
.params
.get("max_results")
.and_then(|value| value.as_u64())
.map(|value| value as usize)
.unwrap_or(DEFAULT_MAX_RESULTS);
let compiled = match pattern_compile::compile(
pattern,
CompileOpts {
case_insensitive: !case_sensitive,
..CompileOpts::default()
},
) {
CompileResult::Ok(compiled) => compiled,
CompileResult::InvalidPattern { message, .. } => {
return Response::error_with_data(
&req.id,
"invalid_pattern",
message,
serde_json::json!({"pattern": pattern}),
);
}
CompileResult::UnsupportedSyntax { feature, .. } => {
return Response::error_with_data(
&req.id,
"invalid_pattern",
format!(
"Pattern uses regex syntax not supported by AFT's engine: {feature}. Use hint:'literal' or rewrite without {feature}."
),
serde_json::json!({"pattern": pattern, "feature": feature}),
);
}
};
if let Err(error) = build_path_filters(&include, &exclude) {
return Response::error(
&req.id,
"invalid_request",
format!("grep: invalid include/exclude glob: {}", error),
);
}
let scope = match grep_executor::resolve_grep_scope(
ctx,
req.params.get("path"),
max_results,
&req.id,
) {
Ok(scope) => scope,
Err(response) => return response,
};
let project_root = grep_executor::project_root(ctx);
let scope_has_files = grep_executor::scope_has_files(&project_root, &scope);
let search_start = std::time::Instant::now();
let params = GrepParams {
include,
exclude,
max_results,
};
let result = grep_executor::execute(ctx, &compiled, &scope, ¶ms);
let search_ms = search_start.elapsed().as_secs_f64() * 1000.0;
let text = format_grep_text(&result, &project_root);
Response::success(
&req.id,
serde_json::json!({
"text": text,
"complete": true,
"no_files_matched_scope": !scope_has_files,
"matches": result.matches.iter().map(match_to_json).collect::<Vec<_>>(),
"total_matches": result.total_matches,
"files_searched": result.files_searched,
"files_with_matches": result.files_with_matches,
"index_status": result.index_status.as_str(),
"truncated": result.truncated,
"search_ms": (search_ms * 1000.0).round() / 1000.0,
}),
)
}
pub(crate) fn format_grep_text(result: &GrepResult, project_root: &Path) -> String {
let mut groups: BTreeMap<String, Vec<&GrepMatch>> = BTreeMap::new();
for grep_match in &result.matches {
let display_path = grep_match
.file
.strip_prefix(project_root)
.unwrap_or(&grep_match.file)
.display()
.to_string();
groups.entry(display_path).or_default().push(grep_match);
}
let mut sections = Vec::new();
for (file, matches) in groups {
let mut section = file.clone();
let display_count = if matches.len() > MAX_MATCHES_PER_FILE {
MAX_DISPLAY_MATCHES_PER_FILE
} else {
matches.len()
};
for grep_match in matches.iter().take(display_count) {
section.push_str(&format!(
"\n{}: {}",
grep_match.line,
truncate_line_text(&grep_match.line_text)
));
}
if matches.len() > MAX_MATCHES_PER_FILE {
section.push_str(&format!(
"\n... and {} more matches",
matches.len() - MAX_DISPLAY_MATCHES_PER_FILE
));
}
sections.push(section);
}
let footer = format!(
"Found {} match(es) across {} file(s). [index: {}]",
result.total_matches,
result.files_with_matches,
index_status_label(result.index_status),
);
if sections.is_empty() {
footer
} else {
format!("{}\n\n{}", sections.join("\n\n"), footer)
}
}
pub(crate) fn truncate_line_text(text: &str) -> String {
let char_count = text.chars().count();
if char_count <= MAX_LINE_CHARS {
return text.to_string();
}
let truncated: String = text.chars().take(MAX_LINE_CHARS).collect();
format!("{}…", truncated)
}
fn index_status_label(status: IndexStatus) -> &'static str {
match status {
IndexStatus::Ready => "ready",
IndexStatus::Building => "building",
IndexStatus::Fallback => "fallback",
IndexStatus::Disabled => "disabled",
}
}
fn match_to_json(grep_match: &GrepMatch) -> serde_json::Value {
serde_json::json!({
"file": grep_match.file.display().to_string(),
"line": grep_match.line,
"column": grep_match.column,
"line_text": grep_match.line_text,
"match_text": grep_match.match_text,
})
}
fn string_array_param(params: &serde_json::Value, key: &str) -> Vec<String> {
let Some(value) = params.get(key) else {
return Vec::new();
};
if let Some(values) = value.as_array() {
return values
.iter()
.filter_map(|item| item.as_str().map(ToOwned::to_owned))
.flat_map(|raw| split_brace_aware(&raw))
.filter(|item| !item.is_empty())
.collect();
}
if let Some(raw) = value.as_str() {
return split_brace_aware(raw)
.into_iter()
.filter(|item| !item.is_empty())
.collect();
}
Vec::new()
}
fn split_brace_aware(raw: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut buf = String::new();
let mut depth = 0i32;
for ch in raw.chars() {
match ch {
'{' => {
depth += 1;
buf.push(ch);
}
'}' => {
if depth > 0 {
depth -= 1;
}
buf.push(ch);
}
',' if depth == 0 => {
let trimmed = buf.trim();
if !trimmed.is_empty() {
out.push(trimmed.to_string());
}
buf.clear();
}
_ => buf.push(ch),
}
}
let tail = buf.trim();
if !tail.is_empty() {
out.push(tail.to_string());
}
out
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn grep_match(file: &str, line: u32, line_text: &str) -> GrepMatch {
GrepMatch {
file: PathBuf::from(file),
line,
column: 1,
line_text: line_text.to_string(),
match_text: "needle".to_string(),
}
}
fn grep_result(
matches: Vec<GrepMatch>,
total_matches: usize,
files_searched: usize,
files_with_matches: usize,
index_status: IndexStatus,
truncated: bool,
) -> GrepResult {
GrepResult {
matches,
total_matches,
files_searched,
files_with_matches,
index_status,
truncated,
fully_degraded: false,
engine_capped: false,
}
}
fn root() -> PathBuf {
PathBuf::from("/project")
}
#[test]
fn grep_groups_truncates_and_adds_footer() {
let long_line = format!("{}xyz", "a".repeat(220));
let result = grep_result(
vec![
grep_match(
"/project/crates/aft/src/commands/grep.rs",
14,
"pub fn handle_grep(req: &RawRequest, ctx: &AppContext) -> Response {",
),
grep_match("/project/crates/aft/src/commands/grep.rs", 116, &long_line),
grep_match(
"/project/crates/aft/src/main.rs",
116,
" \"grep\" => aft::commands::grep::handle_grep(&req, ctx),",
),
],
3,
2,
2,
IndexStatus::Ready,
false,
);
let text = format_grep_text(&result, &root());
assert!(text.contains("crates/aft/src/commands/grep.rs\n"));
assert!(text
.contains("14: pub fn handle_grep(req: &RawRequest, ctx: &AppContext) -> Response {"));
assert!(text.contains("116: aaaaaaa"));
assert!(text.contains("…"));
assert!(text.contains("crates/aft/src/main.rs\n"));
assert!(text.ends_with("Found 3 match(es) across 2 file(s). [index: ready]"));
}
#[test]
fn grep_caps_large_file_sections() {
let matches = (1..=11)
.map(|line| grep_match("/project/src/large.rs", line, &format!("line {line}")))
.collect::<Vec<_>>();
let result = grep_result(matches, 11, 1, 1, IndexStatus::Fallback, false);
let text = format_grep_text(&result, &root());
assert!(text.contains("src/large.rs\n"));
assert!(text.contains("1: line 1"));
assert!(text.contains("5: line 5"));
assert!(!text.contains("6: line 6"));
assert!(text.contains("... and 6 more matches"));
}
#[test]
fn grep_returns_zero_results_footer() {
let result = grep_result(Vec::new(), 0, 0, 0, IndexStatus::Fallback, false);
let text = format_grep_text(&result, &root());
assert_eq!(
text,
"Found 0 match(es) across 0 file(s). [index: fallback]"
);
}
#[test]
fn split_preserves_single_brace_group() {
assert_eq!(split_brace_aware("**/*.{ts,tsx}"), vec!["**/*.{ts,tsx}"]);
}
#[test]
fn split_handles_top_level_commas_with_braces() {
assert_eq!(
split_brace_aware("**/*.{ts,tsx},**/*.{js,jsx}"),
vec!["**/*.{ts,tsx}", "**/*.{js,jsx}"],
);
}
#[test]
fn split_strips_whitespace_around_top_level_separators() {
assert_eq!(
split_brace_aware("**/*.{ts,tsx}, **/*.{js,jsx}"),
vec!["**/*.{ts,tsx}", "**/*.{js,jsx}"],
);
}
#[test]
fn split_handles_nested_braces() {
assert_eq!(
split_brace_aware("**/{a,{b,c},d}.ts"),
vec!["**/{a,{b,c},d}.ts"],
);
}
#[test]
fn split_tolerates_unbalanced_brace_without_panic() {
let result = split_brace_aware("**/*.{ts,tsx");
assert_eq!(result, vec!["**/*.{ts,tsx"]);
}
#[test]
fn split_returns_empty_for_blank_input() {
assert!(split_brace_aware("").is_empty());
assert!(split_brace_aware(" ").is_empty());
}
#[test]
fn string_array_param_accepts_string_with_braces() {
let params = serde_json::json!({"include": "**/*.{ts,tsx},**/*.{js,jsx}"});
let result = string_array_param(¶ms, "include");
assert_eq!(result, vec!["**/*.{ts,tsx}", "**/*.{js,jsx}"]);
}
#[test]
fn string_array_param_accepts_array_input() {
let params = serde_json::json!({"include": ["**/*.ts", "**/*.tsx"]});
let result = string_array_param(¶ms, "include");
assert_eq!(result, vec!["**/*.ts", "**/*.tsx"]);
}
#[test]
fn string_array_param_normalizes_array_with_brace_strings() {
let params = serde_json::json!({"include": ["**/*.{ts,tsx}", "*.json"]});
let result = string_array_param(¶ms, "include");
assert_eq!(result, vec!["**/*.{ts,tsx}", "*.json"]);
}
}