opi_coding_agent/tool/
find.rs1use std::future::Future;
2use std::path::PathBuf;
3use std::pin::Pin;
4
5use opi_agent::tool::{ExecutionMode, Tool, ToolError, ToolResult};
6use opi_ai::message::{OutputContent, ToolDef};
7use schemars::JsonSchema;
8use serde::Deserialize;
9use tokio_util::sync::CancellationToken;
10
11#[derive(Debug, Deserialize, JsonSchema)]
12pub struct FindArgs {
13 pub pattern: String,
15 #[serde(default)]
17 pub path: Option<String>,
18}
19
20pub struct FindTool {
21 workspace_root: PathBuf,
22 schema: serde_json::Value,
23}
24
25impl FindTool {
26 pub fn new(workspace_root: PathBuf) -> Self {
27 let schema = schemars::schema_for!(FindArgs);
28 Self {
29 workspace_root,
30 schema: serde_json::to_value(&schema).unwrap_or_default(),
31 }
32 }
33}
34
35impl Tool for FindTool {
36 fn definition(&self) -> ToolDef {
37 ToolDef {
38 name: "find".into(),
39 description: "Gitignore-aware file discovery by glob pattern. Optionally scope search to a subdirectory.".into(),
40 input_schema: self.schema.clone(),
41 }
42 }
43
44 fn execute(
45 &self,
46 _call_id: &str,
47 arguments: serde_json::Value,
48 _signal: CancellationToken,
49 _on_update: Option<opi_agent::tool::UpdateCallback>,
50 ) -> Pin<Box<dyn Future<Output = Result<ToolResult, ToolError>> + Send>> {
51 let args: FindArgs = match serde_json::from_value(arguments) {
52 Ok(a) => a,
53 Err(e) => {
54 return Box::pin(async move {
55 Ok(ToolResult {
56 content: vec![OutputContent::Text {
57 text: format!("invalid arguments: {e}"),
58 }],
59 details: None,
60 is_error: true,
61 terminate: false,
62 })
63 });
64 }
65 };
66 let workspace_root = self.workspace_root.clone();
67 let pattern = args.pattern;
68 let scope_path = args.path;
69
70 Box::pin(async move {
71 let glob_matcher = match globset::Glob::new(&pattern) {
72 Ok(g) => g.compile_matcher(),
73 Err(e) => {
74 return Ok(ToolResult {
75 content: vec![OutputContent::Text {
76 text: format!("invalid glob pattern: {e}"),
77 }],
78 details: None,
79 is_error: true,
80 terminate: false,
81 });
82 }
83 };
84
85 let search_root = if let Some(ref p) = scope_path {
86 match super::validate_workspace_path(&workspace_root, p) {
88 Ok(canonical) => {
89 if canonical.is_file() {
91 return Ok(ToolResult {
92 content: vec![OutputContent::Text {
93 text: format!("'{}' is not a directory", p),
94 }],
95 details: None,
96 is_error: true,
97 terminate: false,
98 });
99 }
100 canonical
101 }
102 Err(msg) => {
103 return Ok(ToolResult {
104 content: vec![OutputContent::Text { text: msg }],
105 details: None,
106 is_error: true,
107 terminate: false,
108 });
109 }
110 }
111 } else {
112 workspace_root.clone()
113 };
114
115 let mut matched_paths = Vec::new();
116 let mut builder = ignore::WalkBuilder::new(&search_root);
117 builder
118 .hidden(false)
119 .git_ignore(true)
120 .git_global(false)
121 .git_exclude(false)
122 .add_custom_ignore_filename(".gitignore");
123 let walker = builder.build();
124
125 for entry in walker.flatten() {
126 if entry.file_type().is_some_and(|ft| ft.is_file()) {
127 let path = entry.path();
128 let relative = path.strip_prefix(&workspace_root).unwrap_or(path);
129 if glob_matcher.is_match(relative) || glob_matcher.is_match(path) {
130 matched_paths.push(path.to_string_lossy().into_owned());
131 }
132 }
133 }
134
135 let text = matched_paths.join("\n");
136 let details = serde_json::json!({
137 "workspace_root": workspace_root.to_string_lossy(),
138 "pattern": pattern,
139 "match_count": matched_paths.len(),
140 });
141
142 Ok(ToolResult {
143 content: vec![OutputContent::Text { text }],
144 details: Some(details),
145 is_error: false,
146 terminate: false,
147 })
148 })
149 }
150
151 fn execution_mode(&self) -> ExecutionMode {
152 ExecutionMode::Parallel
153 }
154}