#[cfg(test)]
mod tests;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::WalkBuilder;
use tau_proto::CborValue;
use crate::argument::{argument_text, 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, Truncated, truncate_line_oriented_lines_with_byte_limit,
};
pub(crate) const DEFAULT_FIND_LIMIT: usize = 1000;
const MAX_FIND_LIMIT: usize = MAX_OUTPUT_LINES;
pub(crate) fn run_find(arguments: &CborValue) -> Result<ToolOutput, ToolFailure> {
match run_find_cancellable(arguments, None)? {
CancellableToolRun::Finished(output) => Ok(*output),
CancellableToolRun::Cancelled => Err(ToolFailure::new("cancelled")),
}
}
pub(crate) fn run_find_cancellable(
arguments: &CborValue,
cancel_rx: Option<&mpsc::Receiver<()>>,
) -> Result<CancellableToolRun, ToolFailure> {
let request = parse_find_request(arguments)?;
let search = prepare_find_search(&request)?;
let mut cancelled = || cancel_rx.is_some_and(|rx| rx.try_recv().is_ok());
let Some(matches) = collect_find_matches(&search, &mut cancelled)? else {
return Ok(CancellableToolRun::Cancelled);
};
Ok(CancellableToolRun::Finished(Box::new(render_find_output(
request, matches,
))))
}
struct FindRequest {
pattern: String,
path: PathBuf,
limit: usize,
display_args: String,
}
struct FindSearch {
path: PathBuf,
glob: GlobSet,
collection_cap: usize,
display_args: String,
}
fn parse_find_request(arguments: &CborValue) -> Result<FindRequest, ToolFailure> {
let pattern = argument_text(arguments, "pattern").map_err(ToolFailure::from)?;
let path = optional_argument_text(arguments, "path")
.map_err(ToolFailure::from)?
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let limit = parse_find_limit(arguments)?;
let display_args = format!("{pattern} in {}", path.display());
Ok(FindRequest {
pattern,
path,
limit,
display_args,
})
}
fn parse_find_limit(arguments: &CborValue) -> Result<usize, ToolFailure> {
let Some(value) =
optional_argument_int_strict(arguments, "limit").map_err(ToolFailure::from)?
else {
return Ok(DEFAULT_FIND_LIMIT);
};
if value < 1 {
return Err(ToolFailure::new("limit must be >= 1"));
}
let limit = usize::try_from(value).map_err(|_| ToolFailure::new("limit is too large"))?;
if MAX_FIND_LIMIT < limit {
return Err(ToolFailure::new(format!(
"limit must be <= {MAX_FIND_LIMIT}"
)));
}
Ok(limit)
}
fn prepare_find_search(request: &FindRequest) -> Result<FindSearch, ToolFailure> {
let path = request.path.as_path();
let metadata = fs::metadata(path).map_err(|e| {
find_failure_with_args(
&request.display_args,
format!("failed to access {}: {e}", path.display()),
)
})?;
if !metadata.is_dir() {
return Err(find_failure_with_args(
&request.display_args,
format!("not a directory: {}", path.display()),
));
}
let glob = compile_find_glob(&request.pattern)
.map_err(|e| ToolFailure::from(e).with_args(request.display_args.clone()))?;
Ok(FindSearch {
path: path.to_owned(),
glob,
collection_cap: request.limit.saturating_add(1),
display_args: request.display_args.clone(),
})
}
fn find_failure_with_args(args: &str, message: impl Into<String>) -> ToolFailure {
ToolFailure::new(message).with_args(args.to_owned())
}
fn collect_find_matches(
search: &FindSearch,
cancelled: &mut impl FnMut() -> bool,
) -> Result<Option<Vec<String>>, ToolFailure> {
let mut matches = Vec::new();
for entry in WalkBuilder::new(search.path.as_path())
.hidden(false)
.parents(true)
.ignore(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.build()
{
if cancelled() {
return Ok(None);
}
let entry = entry.map_err(|e| {
find_failure_with_args(
&search.display_args,
format!("failed to walk {}: {e}", search.path.display()),
)
})?;
let file_type = match entry.file_type() {
Some(file_type) => file_type,
None => continue,
};
if !file_type.is_file() {
continue;
}
let Ok(relative_path) = entry.path().strip_prefix(search.path.as_path()) else {
continue;
};
if search.glob.is_match(relative_path) {
matches.push(path_to_slash(relative_path));
if search.collection_cap <= matches.len() {
break;
}
}
}
matches.sort_by_key(|entry| entry.to_lowercase());
Ok(Some(matches))
}
fn render_find_output(request: FindRequest, matches: Vec<String>) -> ToolOutput {
if matches.is_empty() {
let mut display = crate::display::ok_display(request.display_args);
display.stats.matches = Some(0);
return ToolOutput {
result: CborValue::Map(vec![
(
CborValue::Text("matches".to_owned()),
CborValue::Integer(0.into()),
),
(
CborValue::Text("output".to_owned()),
CborValue::Text("no files found matching pattern".to_owned()),
),
]),
provider_content: Vec::new(),
display,
};
}
let observed_matches = matches.len();
let displayed: Vec<String> = matches.into_iter().take(request.limit).collect();
let limit_reached = observed_matches > displayed.len();
let full_output_text = displayed.join("\n");
let mut notices = Vec::new();
if limit_reached {
notices.push(limit_reached_notice(request.limit));
}
let mut truncated = truncate_find_records(&displayed, full_output_text.len(), ¬ices);
if truncated.was_truncated {
notices.push("10 KiB/2000 line visible output limit reached.".to_owned());
truncated = truncate_find_records(&displayed, full_output_text.len(), ¬ices);
}
let output_text = append_notices(truncated.content, ¬ices);
let mut display = crate::display::ok_display(request.display_args);
display.stats = text_stats(&output_text);
let mut result_entries = vec![
(
CborValue::Text("matches".to_owned()),
CborValue::Integer((displayed.len() as i64).into()),
),
(
CborValue::Text("output".to_owned()),
CborValue::Text(output_text),
),
];
if truncated.was_truncated {
result_entries.push((
CborValue::Text("truncated".to_owned()),
CborValue::Bool(true),
));
result_entries.push((
CborValue::Text("total_lines".to_owned()),
CborValue::Integer((displayed.len() as i64).into()),
));
result_entries.push((
CborValue::Text("total_bytes".to_owned()),
CborValue::Integer((full_output_text.len() as i64).into()),
));
crate::shell_output_spool::append_metadata(&mut result_entries, &full_output_text);
}
if limit_reached {
result_entries.push((
CborValue::Text("limit_reached".to_owned()),
CborValue::Bool(true),
));
}
ToolOutput {
result: CborValue::Map(result_entries),
provider_content: Vec::new(),
display,
}
}
fn limit_reached_notice(limit: usize) -> String {
if MAX_FIND_LIMIT <= limit {
format!("{limit} results limit reached. Maximum limit reached; refine pattern/path.")
} else {
format!(
"{limit} results limit reached. Use limit={} for more, or refine pattern.",
(limit * 2).min(MAX_FIND_LIMIT)
)
}
}
fn truncate_find_records(
displayed: &[String],
total_bytes: usize,
notices: &[String],
) -> Truncated {
let notice_bytes = rendered_notices(notices).len();
let record_budget = MAX_OUTPUT_BYTES
.checked_sub(notice_bytes)
.expect("find notices fit the visible output budget");
truncate_line_oriented_lines_with_byte_limit(
displayed.iter().map(String::as_str),
displayed.len(),
total_bytes,
record_budget,
)
}
fn rendered_notices(notices: &[String]) -> String {
if notices.is_empty() {
return String::new();
}
format!("\n\n[{}]", notices.join(" "))
}
fn append_notices(mut output_text: String, notices: &[String]) -> String {
output_text.push_str(&rendered_notices(notices));
output_text
}
fn compile_find_glob(pattern: &str) -> Result<GlobSet, String> {
let glob = Glob::new(pattern).map_err(|e| format!("invalid glob pattern {pattern:?}: {e}"))?;
let mut builder = GlobSetBuilder::new();
builder.add(glob);
builder
.build()
.map_err(|e| format!("failed to compile glob pattern {pattern:?}: {e}"))
}
fn path_to_slash(path: &Path) -> String {
render_path(path)
}
pub(crate) fn render_path(path: &Path) -> String {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
render_path_bytes(path.as_os_str().as_bytes())
}
#[cfg(not(unix))]
{
escape_path_text(&path.to_string_lossy())
}
}
pub(crate) fn render_path_bytes(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(text) => escape_path_text(text),
Err(_) => format!(
"(invalid-utf8) {}",
escape_path_text(&String::from_utf8_lossy(bytes))
),
}
}
pub(crate) fn escape_path_text(text: &str) -> String {
let mut escaped = String::new();
for ch in text.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
ch if ch.is_control() => escaped.extend(ch.escape_default()),
ch => escaped.push(ch),
}
}
escaped
}