mod search;
#[cfg(test)]
mod tests;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use tau_proto::CborValue;
use crate::argument::{
argument_text, optional_argument_bool, optional_argument_int_strict, optional_argument_text,
};
use crate::display::{ToolFailure, ToolOutput, text_stats};
use crate::tools::CancellableToolRun;
use crate::truncate::{MAX_OUTPUT_BYTES, MAX_OUTPUT_LINES, truncate_head};
pub(crate) const DEFAULT_GREP_LIMIT: usize = 100;
pub(crate) const GREP_MAX_LINE_LENGTH: usize = 500;
const MAX_GREP_LIMIT: usize = MAX_OUTPUT_LINES;
const MAX_GREP_CONTEXT: usize = 20;
pub(crate) fn run_grep(arguments: &CborValue) -> Result<ToolOutput, ToolFailure> {
match run_grep_cancellable(arguments, None)? {
CancellableToolRun::Finished(output) => Ok(*output),
CancellableToolRun::Cancelled => Err(ToolFailure::new("cancelled")),
}
}
pub(crate) fn run_grep_cancellable(
arguments: &CborValue,
cancel_rx: Option<mpsc::Receiver<()>>,
) -> Result<CancellableToolRun, ToolFailure> {
let options = GrepOptions::parse(arguments)?;
let display_args = options.display_args();
let with_args = |f: ToolFailure| f.with_args(display_args.clone());
let stream = match search::search(&options, cancel_rx).map_err(with_args)? {
Some(stream) => stream,
None => return Ok(CancellableToolRun::Cancelled),
};
let status = if stream.match_count > 0 { 0 } else { 1 };
Ok(CancellableToolRun::Finished(Box::new(render_grep_output(
stream,
status,
display_args,
options.limit,
))))
}
struct GrepOptions {
pattern: GrepPattern,
path: Option<PathBuf>,
glob: Option<String>,
ignore_case: bool,
context: Option<usize>,
limit: usize,
}
enum GrepPattern {
Literal(String),
Regex(String),
}
impl GrepPattern {
fn text(&self) -> &str {
match self {
Self::Literal(text) | Self::Regex(text) => text,
}
}
}
impl GrepOptions {
fn parse(arguments: &CborValue) -> Result<Self, ToolFailure> {
let pattern = argument_text(arguments, "pattern")?;
let path = optional_argument_text(arguments, "path")?.map(PathBuf::from);
let glob = optional_argument_text(arguments, "glob")?;
let ignore_case = optional_bool_argument(arguments, "ignoreCase")?;
let pattern = match optional_bool_argument(arguments, "regex")? {
true => GrepPattern::Regex(pattern),
false => GrepPattern::Literal(pattern),
};
let context =
optional_bounded_usize_argument(arguments, "context", 0, MAX_GREP_CONTEXT, None)?;
let limit = optional_bounded_usize_argument(
arguments,
"limit",
1,
MAX_GREP_LIMIT,
Some(DEFAULT_GREP_LIMIT),
)?
.expect("defaulted limit must be present");
Ok(Self {
pattern,
path,
glob,
ignore_case,
context,
limit,
})
}
fn search_path(&self) -> &Path {
self.path.as_deref().unwrap_or_else(|| Path::new("."))
}
fn display_args(&self) -> String {
match self.glob.as_deref() {
Some(g) => format!(
"{:?} in {} [{g}]",
self.pattern.text(),
self.search_path().display()
),
None => format!(
"{:?} in {}",
self.pattern.text(),
self.search_path().display()
),
}
}
}
fn optional_bool_argument(arguments: &CborValue, name: &str) -> Result<bool, ToolFailure> {
Ok(optional_argument_bool(arguments, name)
.map_err(ToolFailure::from)?
.unwrap_or(false))
}
fn optional_bounded_usize_argument(
arguments: &CborValue,
name: &str,
min: usize,
max: usize,
default: Option<usize>,
) -> Result<Option<usize>, ToolFailure> {
let Some(value) = optional_argument_int_strict(arguments, name).map_err(ToolFailure::from)?
else {
return Ok(default);
};
let min_i64 = i64::try_from(min).expect("grep bounds fit in i64");
if value < min_i64 {
return Err(ToolFailure::new(format!("{name} must be >= {min}")));
}
let value =
usize::try_from(value).map_err(|_| ToolFailure::new(format!("{name} is too large")))?;
if max < value {
return Err(ToolFailure::new(format!("{name} must be <= {max}")));
}
Ok(Some(value))
}
fn render_grep_output(
stream: GrepStreamResult,
status: i32,
display_args: String,
limit: usize,
) -> ToolOutput {
let GrepStreamResult {
result_lines,
match_count,
lines_truncated,
match_limit_reached,
} = stream;
if result_lines.is_empty() {
let mut display = crate::display::ok_display(display_args.clone());
display.stats.matches = Some(0);
return ToolOutput {
result: grep_result_map(status, 0, "no matches found".to_owned()),
provider_content: Vec::new(),
display,
};
}
let total_output_lines = result_lines.len();
let full_output_text = result_lines.join("\n");
let byte_truncated = truncate_head(&full_output_text);
let mut output_text = if byte_truncated.was_truncated {
byte_truncated.content
} else {
full_output_text.clone()
};
let mut notices = Vec::new();
if match_limit_reached {
notices.push(limit_reached_notice(limit));
}
if byte_truncated.was_truncated {
notices.push("10 KiB visible output limit reached.".to_owned());
}
if lines_truncated {
notices.push(format!(
"Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines."
));
}
output_text = append_notices_within_cap(output_text, ¬ices);
let mut display = crate::display::ok_display(display_args);
display.stats = text_stats(&output_text);
display.stats.matches = Some(match_count as u64);
let mut result = grep_result_map(status, match_count, output_text);
if byte_truncated.was_truncated
&& let CborValue::Map(entries) = &mut result
{
entries.push((
CborValue::Text("truncated".to_owned()),
CborValue::Bool(true),
));
entries.push((
CborValue::Text("total_lines".to_owned()),
CborValue::Integer((total_output_lines as i64).into()),
));
entries.push((
CborValue::Text("total_bytes".to_owned()),
CborValue::Integer((full_output_text.len() as i64).into()),
));
crate::shell_output_spool::append_metadata(entries, &full_output_text);
}
ToolOutput {
result,
provider_content: Vec::new(),
display,
}
}
fn limit_reached_notice(limit: usize) -> String {
if MAX_GREP_LIMIT <= limit {
format!("{limit} matches limit reached. Maximum limit reached; refine pattern.")
} else {
format!(
"{limit} matches limit reached. Use limit={} for more, or refine pattern.",
(limit * 2).min(MAX_GREP_LIMIT)
)
}
}
struct GrepStreamResult {
result_lines: Vec<String>,
match_count: usize,
lines_truncated: bool,
match_limit_reached: bool,
}
pub(crate) fn grep_result_map(status: i32, matches: usize, output_text: String) -> CborValue {
CborValue::Map(vec![
(
CborValue::Text("status".to_owned()),
CborValue::Integer((status as i64).into()),
),
(
CborValue::Text("matches".to_owned()),
CborValue::Integer((matches as i64).into()),
),
(
CborValue::Text("output".to_owned()),
CborValue::Text(output_text.clone()),
),
(
CborValue::Text("output_lines".to_owned()),
CborValue::Integer((output_text.lines().count() as i64).into()),
),
(
CborValue::Text("output_bytes".to_owned()),
CborValue::Integer((output_text.len() as i64).into()),
),
])
}
fn render_grep_heading(path: &str) -> (String, bool) {
if path.len() <= GREP_MAX_LINE_LENGTH {
return (path.to_owned(), false);
}
let ellipsis = "…";
let mut end = (GREP_MAX_LINE_LENGTH - ellipsis.len()).min(path.len());
while !path.is_char_boundary(end) {
end -= 1;
}
(format!("{}{ellipsis}", &path[..end]), true)
}
fn render_grep_line(lineno: u64, sep: char, text: &str) -> (String, bool) {
let prefix = format!("{lineno}{sep}");
let available = GREP_MAX_LINE_LENGTH - prefix.len();
if text.len() <= available {
return (format!("{prefix}{text}"), false);
}
let mut end = available - "…".len();
while !text.is_char_boundary(end) {
end -= 1;
}
(format!("{prefix}{}…", &text[..end]), true)
}
fn append_notices_within_cap(mut output_text: String, notices: &[String]) -> String {
if notices.is_empty() {
return output_text;
}
let notice = format!("\n\n[{}]", notices.join(" "));
if output_text.len().saturating_add(notice.len()) <= MAX_OUTPUT_BYTES {
output_text.push_str(¬ice);
return output_text;
}
let Some(budget) = MAX_OUTPUT_BYTES.checked_sub(notice.len()) else {
return notice.chars().take(MAX_OUTPUT_BYTES).collect();
};
let mut end = budget.min(output_text.len());
while !output_text.is_char_boundary(end) {
end -= 1;
}
output_text.truncate(end);
output_text.push_str(¬ice);
output_text
}