Skip to main content

phi_core/tools/
search.rs

1//! Search tool — grep/ripgrep-style search across files.
2/*
3ARCHITECTURE: SearchTool — delegate to native system tools
4
5Rather than implementing regex search in Rust, we delegate to `rg` (ripgrep)
6or `grep` — battle-tested, fast, and already installed on most systems.
7
8Why not implement search natively?
9  - ripgrep is dramatically faster for large codebases (parallel, SIMD, smart ignore rules)
10  - `.gitignore` / `.ignore` support comes for free with `rg`
11  - No need to maintain regex engine, file walker, and output formatting ourselves
12
13Strategy: try `rg` first (faster, smarter), fall back to `grep -r`.
14`which rg` detects availability; we check at runtime rather than compile time.
15
16RUST QUIRK: `tokio::process::Command` — spawning system processes asynchronously
17  Same pattern as BashTool: build the command, `.output().await`, parse stdout.
18  The key difference is that the command is tightly scoped (specific arguments) rather
19  than an arbitrary shell command — no security risk from user input in shell expansion.
20*/
21
22use crate::types::*;
23use async_trait::async_trait;
24use std::time::Duration;
25use tokio::process::Command;
26
27/// Search files using grep (or ripgrep if available).
28pub struct SearchTool {
29    /// Root directory to search in
30    pub root: Option<String>,
31    /// Max results to return
32    pub max_results: usize,
33    /// Timeout
34    pub timeout: Duration,
35}
36
37impl Default for SearchTool {
38    fn default() -> Self {
39        Self {
40            root: None,
41            max_results: 50,
42            timeout: Duration::from_secs(30),
43        }
44    }
45}
46
47impl SearchTool {
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    pub fn with_root(mut self, root: impl Into<String>) -> Self {
53        self.root = Some(root.into());
54        self
55    }
56}
57
58#[async_trait]
59impl AgentTool for SearchTool {
60    fn name(&self) -> &str {
61        "search"
62    }
63
64    fn label(&self) -> &str {
65        "Search Files"
66    }
67
68    fn description(&self) -> &str {
69        "Search for a pattern across files using grep. Returns matching lines with file paths and line numbers. Supports regex patterns."
70    }
71
72    fn parameters_schema(&self) -> serde_json::Value {
73        serde_json::json!({
74            "type": "object",
75            "properties": {
76                "pattern": {
77                    "type": "string",
78                    "description": "Search pattern (regex supported)"
79                },
80                "path": {
81                    "type": "string",
82                    "description": "Directory or file to search in (optional, defaults to working directory)"
83                },
84                "include": {
85                    "type": "string",
86                    "description": "File glob pattern to include, e.g. '*.rs' (optional)"
87                },
88                "case_sensitive": {
89                    "type": "boolean",
90                    "description": "Case sensitive search (default: false)"
91                }
92            },
93            "required": ["pattern"]
94        })
95    }
96
97    async fn execute(
98        &self,
99        params: serde_json::Value,
100        ctx: ToolContext,
101    ) -> Result<ToolResult, ToolError> {
102        let cancel = ctx.cancel;
103        let pattern = params["pattern"]
104            .as_str()
105            .ok_or_else(|| ToolError::InvalidArgs("missing 'pattern' parameter".into()))?;
106
107        let search_path = params["path"]
108            .as_str()
109            .map(|s| s.to_string())
110            .or_else(|| self.root.clone())
111            .unwrap_or_else(|| ".".into());
112
113        let include = params["include"].as_str();
114        let case_sensitive = params["case_sensitive"].as_bool().unwrap_or(false);
115
116        if cancel.is_cancelled() {
117            return Err(ToolError::Cancelled);
118        }
119
120        // Try ripgrep first, fall back to grep
121        let (cmd_name, args) = if which_exists("rg") {
122            build_rg_args(
123                pattern,
124                &search_path,
125                include,
126                case_sensitive,
127                self.max_results,
128            )
129        } else {
130            build_grep_args(
131                pattern,
132                &search_path,
133                include,
134                case_sensitive,
135                self.max_results,
136            )
137        };
138
139        let mut cmd = Command::new(&cmd_name);
140        cmd.args(&args);
141        cmd.stdout(std::process::Stdio::piped());
142        cmd.stderr(std::process::Stdio::piped());
143
144        let timeout = self.timeout;
145
146        let result = tokio::select! {
147            _ = cancel.cancelled() => {
148                return Err(ToolError::Cancelled);
149            }
150            _ = tokio::time::sleep(timeout) => {
151                return Err(ToolError::Failed("Search timed out".into()));
152            }
153            result = cmd.output() => {
154                result.map_err(|e| ToolError::Failed(format!("Search failed: {}", e)))?
155            }
156        };
157
158        let stdout = String::from_utf8_lossy(&result.stdout).to_string();
159        let stderr = String::from_utf8_lossy(&result.stderr).to_string();
160
161        // grep returns exit code 1 for "no matches" — that's not an error
162        if result.status.code() == Some(2)
163            || (!stderr.is_empty() && result.status.code() != Some(1))
164        {
165            return Err(ToolError::Failed(format!("Search error: {}", stderr)));
166        }
167
168        if stdout.trim().is_empty() {
169            return Ok(ToolResult {
170                content: vec![Content::Text {
171                    text: format!("No matches found for '{}'", pattern),
172                }],
173                details: serde_json::json!({ "matches": 0 }),
174                child_loop_id: None,
175            });
176        }
177
178        let match_count = stdout.lines().count();
179        let text = if match_count >= self.max_results {
180            format!(
181                "{}\n... (showing first {} matches)",
182                stdout.trim(),
183                self.max_results
184            )
185        } else {
186            format!("{}\n({} matches)", stdout.trim(), match_count)
187        };
188
189        Ok(ToolResult {
190            content: vec![Content::Text { text }],
191            details: serde_json::json!({ "matches": match_count }),
192            child_loop_id: None,
193        })
194    }
195}
196
197fn which_exists(name: &str) -> bool {
198    std::process::Command::new("which")
199        .arg(name)
200        .output()
201        .map(|o| o.status.success())
202        .unwrap_or(false)
203}
204
205fn build_rg_args(
206    pattern: &str,         // REGEX — the search pattern passed to rg
207    path: &str,            // ROOT — directory or file to search in
208    include: Option<&str>, // GLOB FILTER — e.g. "*.rs"; None = search all files
209    case_sensitive: bool,  // CASE — false adds --ignore-case flag
210    max_results: usize,    // LIMIT — passed as --max-count to bound output size
211) -> (String, Vec<String>) {
212    // returns (command_name, args_vec) — caller invokes tokio::process::Command
213    let mut args = vec![
214        "--line-number".into(),
215        "--no-heading".into(),
216        format!("--max-count={}", max_results),
217    ];
218
219    if !case_sensitive {
220        args.push("--ignore-case".into());
221    }
222
223    if let Some(glob) = include {
224        args.push(format!("--glob={}", glob));
225    }
226
227    args.push(pattern.into());
228    args.push(path.into());
229
230    ("rg".into(), args)
231}
232
233fn build_grep_args(
234    pattern: &str,         // REGEX — the search pattern passed to grep
235    path: &str,            // ROOT — directory or file to search in
236    include: Option<&str>, // GLOB FILTER — e.g. "*.rs"; None = search all files
237    case_sensitive: bool,  // CASE — false adds -i flag
238    max_results: usize,    // LIMIT — passed as -m to bound output size
239) -> (String, Vec<String>) {
240    // returns (command_name, args_vec)
241    let mut args = vec!["-r".into(), "-n".into(), format!("-m{}", max_results)];
242
243    if !case_sensitive {
244        args.push("-i".into());
245    }
246
247    if let Some(glob) = include {
248        args.push(format!("--include={}", glob));
249    }
250
251    args.push(pattern.into());
252    args.push(path.into());
253
254    ("grep".into(), args)
255}