Skip to main content

opi_coding_agent/tool/
find.rs

1use 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    /// Glob pattern to search for (e.g. "**/*.rs", "*.toml").
14    pub pattern: String,
15    /// Optional subdirectory to scope the search to (relative to workspace root).
16    #[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                // Validate the scope path is within workspace
87                match super::validate_workspace_path(&workspace_root, p) {
88                    Ok(canonical) => {
89                        // Must be a directory or the path prefix is valid
90                        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}