#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use globset::Glob;
use ignore::WalkBuilder;
use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use serde_json::{json, Value};
use crate::tools::ls::resolve_in_cwd;
use crate::tools::ToolCtx;
const MAX_RESULTS: usize = 1000;
pub struct FindTool {
ctx: Arc<ToolCtx>,
}
impl FindTool {
pub fn new(ctx: Arc<ToolCtx>) -> Self {
Self { ctx }
}
}
impl Tool for FindTool {
fn def(&self) -> ToolDef {
ToolDef {
name: "find".to_string(),
description: "List files or directories under a path, optionally filtered by a glob pattern. Respects .gitignore.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"pattern": { "type": "string", "description": "Glob matched against the file name, e.g. `*.rs`. Defaults to all." },
"path": { "type": "string", "description": "Root directory (absolute or cwd-relative). Defaults to cwd." },
"type": { "type": "string", "enum": ["file", "dir", "any"], "description": "Entry kind filter. Defaults to `any`." }
}
}),
}
}
fn call(
&self,
args: Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
let ctx = Arc::clone(&self.ctx);
Box::pin(async move {
let rel = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
let root = resolve_in_cwd(&ctx.cwd, rel);
if !root.starts_with(&ctx.cwd) {
return ToolResult::error(format!(
"path {} is outside the working directory",
root.display()
));
}
let kind = args
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("any")
.to_string();
let matcher = match args.get("pattern").and_then(|v| v.as_str()) {
Some(p) => match Glob::new(p) {
Ok(g) => Some(g.compile_matcher()),
Err(e) => return ToolResult::error(format!("invalid glob `{p}`: {e}")),
},
None => None,
};
let cwd = ctx.cwd.clone();
let listing = tokio::task::spawn_blocking(move || {
let mut out: Vec<String> = Vec::new();
for entry in WalkBuilder::new(&root).build().flatten() {
if out.len() >= MAX_RESULTS {
out.push(format!("... (truncated at {MAX_RESULTS} entries)"));
break;
}
let path = entry.path();
if path == root {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
match kind.as_str() {
"file" if is_dir => continue,
"dir" if !is_dir => continue,
_ => {}
}
if let Some(m) = &matcher {
let name = path.file_name().unwrap_or_default();
if !m.is_match(name) {
continue;
}
}
let display = path.strip_prefix(&cwd).unwrap_or(path);
out.push(display.display().to_string());
}
out
})
.await;
match listing {
Ok(mut lines) => {
lines.sort();
ToolResult::text(lines.join("\n"))
}
Err(e) => ToolResult::error(format!("find walk failed: {e}")),
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::NoOpPermissionGate;
use tempfile::tempdir;
use tokio::sync::mpsc;
fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
let (tx, _rx) = mpsc::channel(8);
Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
}
#[tokio::test]
async fn finds_files_by_glob() {
let dir = tempdir().expect("tempdir");
tokio::fs::write(dir.path().join("a.rs"), "x")
.await
.unwrap();
tokio::fs::write(dir.path().join("b.rs"), "x")
.await
.unwrap();
tokio::fs::write(dir.path().join("c.txt"), "x")
.await
.unwrap();
let tool = FindTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "pattern": "*.rs" }), &ToolContext::default())
.await;
let text = result.as_text().unwrap_or_default();
assert!(text.contains("a.rs"));
assert!(text.contains("b.rs"));
assert!(!text.contains("c.txt"));
}
#[tokio::test]
async fn type_dir_lists_only_directories() {
let dir = tempdir().expect("tempdir");
tokio::fs::write(dir.path().join("file.txt"), "x")
.await
.unwrap();
tokio::fs::create_dir(dir.path().join("subdir"))
.await
.unwrap();
let tool = FindTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "type": "dir" }), &ToolContext::default())
.await;
let text = result.as_text().unwrap_or_default();
assert!(text.contains("subdir"));
assert!(!text.contains("file.txt"));
}
#[tokio::test]
async fn respects_gitignore() {
let dir = tempdir().expect("tempdir");
tokio::fs::create_dir(dir.path().join(".git"))
.await
.unwrap();
tokio::fs::write(dir.path().join(".gitignore"), "ignored.txt\n")
.await
.unwrap();
tokio::fs::write(dir.path().join("ignored.txt"), "x")
.await
.unwrap();
tokio::fs::write(dir.path().join("kept.txt"), "x")
.await
.unwrap();
let tool = FindTool::new(test_ctx(dir.path()));
let result = tool.call(json!({}), &ToolContext::default()).await;
let text = result.as_text().unwrap_or_default();
assert!(text.contains("kept.txt"));
assert!(
!text.contains("ignored.txt"),
"gitignored file leaked: {text}"
);
}
#[tokio::test]
async fn rejects_path_outside_cwd() {
let dir = tempdir().expect("tempdir");
let tool = FindTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "path": "../.." }), &ToolContext::default())
.await;
assert!(result.is_error);
}
#[tokio::test]
async fn invalid_glob_errors() {
let dir = tempdir().expect("tempdir");
let tool = FindTool::new(test_ctx(dir.path()));
let result = tool
.call(json!({ "pattern": "[" }), &ToolContext::default())
.await;
assert!(result.is_error);
}
}