use super::{
ToolResult, ToolResultDisplay, ToolRuntime,
args::{FIND_DEFAULT_LIMIT, FindArgs, FindKindArg},
contract::{metadata_key as meta, tool_name},
workspace,
};
use serde_json::json;
use std::path::{Path, PathBuf};
#[derive(Debug, Default)]
pub(super) struct FindSearchService;
impl FindSearchService {
pub(super) fn path_search(
&self,
walker: &workspace::WorkspaceWalker,
request: FindPathSearchRequest,
) -> anyhow::Result<FindPathSearchResult> {
let cwd = request.cwd.canonicalize()?;
let (search_root, output_prefix) = match request.path_filter.as_ref() {
Some(path_filter) => {
let resolved = cwd.join(path_filter).canonicalize()?;
if !resolved.is_dir() {
anyhow::bail!("find path search path must be a directory");
}
let prefix = match resolved.strip_prefix(&cwd) {
Ok(relative) => normalize_filter_prefix(relative),
Err(_) => resolved.to_string_lossy().replace('\\', "/"),
};
(resolved, prefix)
}
None => (cwd.clone(), String::new()),
};
let scoped_query = if output_prefix.is_empty() {
request.query.clone()
} else {
query_without_filter_prefix(&request.query, &output_prefix)
.unwrap_or_else(|| request.query.clone())
};
let mut matches = collect_path_matches(
walker,
&cwd,
&search_root,
&output_prefix,
&scoped_query,
request.kind,
)?;
matches.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| left.item.relative_path.cmp(&right.item.relative_path))
.then_with(|| path_kind_rank(left.item.kind).cmp(&path_kind_rank(right.item.kind)))
});
let total_matched = matches.len();
let items = matches
.into_iter()
.skip(request.offset)
.take(request.limit)
.map(|matched| matched.item)
.collect::<Vec<_>>();
let truncated = request.offset.saturating_add(items.len()) < total_matched;
Ok(FindPathSearchResult {
items,
total_matched,
truncated,
index_ready: true,
})
}
pub(super) fn index_count(&self) -> usize {
0
}
}
#[derive(Debug)]
pub(super) struct FindPathSearchRequest {
pub(super) cwd: PathBuf,
pub(super) query: String,
pub(super) path_filter: Option<PathBuf>,
pub(super) kind: FindKind,
pub(super) offset: usize,
pub(super) limit: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FindKind {
Files,
Directories,
Mixed,
}
impl FindKind {
fn as_str(self) -> &'static str {
match self {
Self::Files => "files",
Self::Directories => "directories",
Self::Mixed => "mixed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct FindPathSearchResult {
pub(super) items: Vec<FindPathMatch>,
pub(super) total_matched: usize,
pub(super) truncated: bool,
pub(super) index_ready: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct FindPathMatch {
pub(super) relative_path: String,
pub(super) kind: FindPathKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum FindPathKind {
File,
Directory,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ScoredPathMatch {
item: FindPathMatch,
score: usize,
}
impl ToolRuntime {
pub(super) fn find(&self, args: FindArgs) -> anyhow::Result<ToolResult> {
let kind = match args.kind.unwrap_or(FindKindArg::Files) {
FindKindArg::Files => FindKind::Files,
FindKindArg::Directories => FindKind::Directories,
FindKindArg::Mixed => FindKind::Mixed,
};
let limit = args.limit.unwrap_or(FIND_DEFAULT_LIMIT);
let offset = args.offset.unwrap_or(0);
let path_filter = args
.path
.as_deref()
.map(|path| self.find_path_filter(path))
.transpose()?;
let result = self.path_search.path_search(
&self.workspace_walker,
FindPathSearchRequest {
cwd: self.cwd_canonical.clone(),
query: args.query.clone(),
path_filter,
kind,
offset,
limit,
},
)?;
let output = result
.items
.iter()
.map(|item| match item.kind {
FindPathKind::File => item.relative_path.clone(),
FindPathKind::Directory => format!("{}/", item.relative_path),
})
.collect::<Vec<_>>()
.join("\n");
Ok(ToolResult {
tool_name: tool_name::FIND.to_string(),
success: true,
content: output,
metadata: json!({
(meta::ENGINE): "rust",
(meta::QUERY): args.query,
(meta::PATH): args.path,
(meta::KIND): kind.as_str(),
(meta::LIMIT): limit,
(meta::OFFSET): offset,
(meta::MATCHES_RETURNED): result.items.len(),
(meta::TOTAL_MATCHED): result.total_matched,
(meta::TRUNCATED): result.truncated,
(meta::INDEX_READY): result.index_ready,
}),
display: ToolResultDisplay::default(),
})
}
fn find_path_filter(&self, path: &str) -> anyhow::Result<PathBuf> {
let resolved = self.resolve_existing_path(
path,
super::fs::ExistingPathPolicy::find(self.find_absolute_paths),
)?;
if !resolved.is_dir() {
anyhow::bail!("find path must be a directory");
}
if !resolved.starts_with(&self.cwd_canonical) && !self.find_absolute_paths {
self.ensure_inside_with_setting(&resolved, "tools.find.absolute_paths")?;
}
Ok(resolved
.strip_prefix(&self.cwd_canonical)
.unwrap_or(&resolved)
.to_path_buf())
}
}
fn collect_path_matches(
walker: &workspace::WorkspaceWalker,
cwd: &Path,
search_root: &Path,
output_prefix: &str,
query: &str,
kind: FindKind,
) -> anyhow::Result<Vec<ScoredPathMatch>> {
let mut matches = Vec::new();
let walk = walker.walk(
workspace::WorkspaceWalkOptions {
root: search_root,
include_files: true,
include_dirs: true,
skip_dirs: &[".git", "target"],
cancel_interval: 32,
},
None,
)?;
for entry in walk.entries {
let file_type = entry.file_type;
let path = entry.path.as_path();
let item_kind = if file_type.is_file() {
FindPathKind::File
} else if file_type.is_dir() {
FindPathKind::Directory
} else {
continue;
};
if !kind_includes(kind, item_kind) {
continue;
}
let search_root_relative = search_root.starts_with(cwd);
let relative = if search_root_relative {
normalize_relative_path(path.strip_prefix(cwd).unwrap_or(path))
} else {
path.to_string_lossy().replace('\\', "/")
};
if relative.is_empty()
|| (search_root_relative && relative.starts_with('/'))
|| (search_root_relative && relative.contains(":/"))
{
continue;
}
if !output_prefix.is_empty() && !path_matches_filter(&relative, output_prefix) {
continue;
}
let Some(score) = fuzzy_score(&relative, query) else {
continue;
};
matches.push(ScoredPathMatch {
item: FindPathMatch {
relative_path: relative,
kind: item_kind,
},
score,
});
}
Ok(matches)
}
fn kind_includes(kind: FindKind, item_kind: FindPathKind) -> bool {
matches!(
(kind, item_kind),
(FindKind::Files, FindPathKind::File)
| (FindKind::Directories, FindPathKind::Directory)
| (FindKind::Mixed, _)
)
}
fn path_kind_rank(kind: FindPathKind) -> u8 {
match kind {
FindPathKind::File => 0,
FindPathKind::Directory => 1,
}
}
fn fuzzy_score(path: &str, query: &str) -> Option<usize> {
let query = query.trim().to_lowercase();
if query.is_empty() {
return Some(0);
}
let path_lower = path.to_lowercase();
let mut score = 0usize;
for token in query.split_whitespace() {
if token.is_empty() {
continue;
}
if let Some(index) = path_lower.find(token) {
score = score.saturating_add(10_000usize.saturating_sub(index));
} else if fuzzy_subsequence_match(&path_lower, token) {
score = score.saturating_add(token.len());
} else {
return None;
}
}
Some(score)
}
fn fuzzy_subsequence_match(path: &str, token: &str) -> bool {
let mut chars = token.chars();
let mut current = chars.next();
if current.is_none() {
return true;
}
for path_char in path.chars() {
if Some(path_char) == current {
current = chars.next();
if current.is_none() {
return true;
}
}
}
false
}
fn normalize_relative_path(path: &Path) -> String {
path.to_string_lossy()
.replace('\\', "/")
.trim_matches('/')
.to_string()
}
fn normalize_filter_prefix(path: &Path) -> String {
normalize_relative_path(path)
}
fn path_matches_filter(relative_path: &str, prefix: &str) -> bool {
prefix.is_empty()
|| relative_path == prefix
|| relative_path
.strip_prefix(prefix)
.is_some_and(|suffix| suffix.starts_with('/'))
}
fn query_without_filter_prefix(query: &str, prefix: &str) -> Option<String> {
let prefix = prefix.trim_matches('/');
if prefix.is_empty() {
return None;
}
let query = query.trim_start();
let rest = query.strip_prefix(prefix)?;
if rest.is_empty() {
return Some(String::new());
}
if let Some(rest) = rest.strip_prefix('/') {
return Some(rest.trim_start_matches('/').trim_start().to_string());
}
if rest.chars().next().is_some_and(char::is_whitespace) {
return Some(rest.trim_start().to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::args::ListFilesArgs;
use std::fs;
#[test]
fn find_does_not_contaminate_list_files_cache_with_gitignored_entries() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join(".git")).unwrap();
fs::write(temp.path().join(".gitignore"), "ignored.txt\n").unwrap();
fs::write(temp.path().join("ignored.txt"), "target").unwrap();
fs::write(temp.path().join("visible.txt"), "target").unwrap();
let runtime = ToolRuntime::new(temp.path()).unwrap();
let result = runtime
.find(FindArgs {
query: "txt".to_string(),
path: None,
kind: Some(FindKindArg::Files),
limit: Some(10),
offset: Some(0),
})
.unwrap();
let list_result = runtime
.list_files(ListFilesArgs {
path: ".".to_string(),
include_directories: false,
})
.unwrap();
assert!(result.content.contains("visible.txt"), "{}", result.content);
assert!(
!result.content.contains("ignored.txt"),
"{}",
result.content
);
assert!(
list_result.content.contains("ignored.txt"),
"{}",
list_result.content
);
}
#[test]
fn find_path_filter_allows_absolute_outside_cwd_when_setting_true() {
let cwd = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let mut runtime = ToolRuntime::new(cwd.path()).unwrap();
runtime.find_absolute_paths = true;
let filter = runtime
.find_path_filter(outside.path().to_str().unwrap())
.unwrap();
assert_eq!(filter, outside.path().canonicalize().unwrap());
}
#[test]
fn find_path_filter_rejects_absolute_outside_cwd_when_setting_false() {
let cwd = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
let mut runtime = ToolRuntime::new(cwd.path()).unwrap();
runtime.find_absolute_paths = false;
let error = runtime
.find_path_filter(outside.path().to_str().unwrap())
.unwrap_err()
.to_string();
assert!(error.contains("tools.find.absolute_paths"), "{error}");
}
#[test]
fn path_search_outside_cwd_returns_absolute_matches() {
let root = tempfile::TempDir::new().unwrap();
let cwd = root.path().join("cwd");
let outside = root.path().join("outside");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(outside.join("outside_target.txt"), "").unwrap();
let service = FindSearchService;
let result = service
.path_search(
&workspace::WorkspaceWalker,
FindPathSearchRequest {
cwd,
query: "outside".to_string(),
path_filter: Some(outside.canonicalize().unwrap()),
kind: FindKind::Files,
offset: 0,
limit: 10,
},
)
.unwrap();
assert_eq!(result.items.len(), 1);
assert_eq!(
result.items[0].relative_path,
outside
.join("outside_target.txt")
.canonicalize()
.unwrap()
.to_string_lossy()
.replace('\\', "/")
);
}
#[test]
fn path_search_outside_cwd_keeps_absolute_paths_containing_drive_separator() {
let root = tempfile::TempDir::new().unwrap();
let cwd = root.path().join("cwd");
let outside = root.path().join("C:/outside");
fs::create_dir_all(&cwd).unwrap();
fs::create_dir_all(&outside).unwrap();
fs::write(outside.join("target.txt"), "").unwrap();
let service = FindSearchService;
let result = service
.path_search(
&workspace::WorkspaceWalker,
FindPathSearchRequest {
cwd,
query: "target".to_string(),
path_filter: Some(outside.canonicalize().unwrap()),
kind: FindKind::Files,
offset: 0,
limit: 10,
},
)
.unwrap();
assert_eq!(result.items.len(), 1);
assert!(
result.items[0]
.relative_path
.contains("C:/outside/target.txt")
);
}
#[test]
fn find_path_filter_uses_scoped_search_before_broad_root() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("aaa_scoped/deep")).unwrap();
fs::write(temp.path().join("aaa_scoped/deep/target.txt"), "").unwrap();
fs::create_dir_all(temp.path().join("zzz_broad")).unwrap();
for index in 0..1100 {
fs::write(
temp.path().join(format!("zzz_broad/target_{index:04}.txt")),
"",
)
.unwrap();
}
let service = FindSearchService;
let scoped = service
.path_search(
&workspace::WorkspaceWalker,
FindPathSearchRequest {
cwd: temp.path().to_path_buf(),
query: "target".to_string(),
path_filter: Some(PathBuf::from("aaa_scoped")),
kind: FindKind::Files,
offset: 0,
limit: 2,
},
)
.unwrap();
assert_eq!(
scoped.items,
vec![FindPathMatch {
relative_path: "aaa_scoped/deep/target.txt".to_string(),
kind: FindPathKind::File,
}]
);
assert_eq!(scoped.total_matched, 1);
assert!(scoped.index_ready);
}
#[test]
fn find_scoped_query_prefix_is_removed_for_matching() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("src/target_dir")).unwrap();
fs::write(temp.path().join("src/target_file.txt"), "").unwrap();
let service = FindSearchService;
let result = service
.path_search(
&workspace::WorkspaceWalker,
FindPathSearchRequest {
cwd: temp.path().to_path_buf(),
query: "src target_file".to_string(),
path_filter: Some(PathBuf::from("src")),
kind: FindKind::Files,
offset: 0,
limit: 10,
},
)
.unwrap();
assert_eq!(result.items[0].relative_path, "src/target_file.txt");
}
}