1use crate::errors::{RalphError, Result};
2use ignore::WalkBuilder;
3use regex::Regex;
4use serde::Deserialize;
5use std::path::Path;
6
7const BRAVE_API_URL: &str = "https://api.search.brave.com/res/v1/web/search";
8const SERP_API_URL: &str = "https://serpapi.com/search.json";
9
10pub fn search_available(brave_key_env: &str, serp_key_env: &str) -> bool {
14 let has_brave = std::env::var(brave_key_env)
15 .map(|v| !v.trim().is_empty())
16 .unwrap_or(false);
17 let has_serp = std::env::var(serp_key_env)
18 .map(|v| !v.trim().is_empty())
19 .unwrap_or(false);
20 has_brave || has_serp
21}
22
23pub async fn search_web(
26 query: &str,
27 brave_key: Option<&str>,
28 serp_key: Option<&str>,
29) -> Result<String> {
30 let brave = brave_key.filter(|k| !k.is_empty());
31 let serp = serp_key.filter(|k| !k.is_empty());
32
33 match (brave, serp) {
34 (Some(key), _) => search_brave(query, key).await,
35 (None, Some(key)) => search_serp(query, key).await,
36 (None, None) => Ok("[search_web] No BRAVE_API_KEY or SERP_API_KEY set. \
37 Cannot perform web search. Proceed with your best knowledge, \
38 but note results may be outdated."
39 .to_string()),
40 }
41}
42
43async fn search_brave(query: &str, key: &str) -> Result<String> {
44 let client = reqwest::Client::new();
45 let resp = client
46 .get(BRAVE_API_URL)
47 .header("Accept", "application/json")
48 .header("Accept-Encoding", "gzip")
49 .header("X-Subscription-Token", key)
50 .query(&[("q", query), ("count", "5")])
51 .send()
52 .await
53 .map_err(|e| RalphError::SearchFailed(e.to_string()))?;
54
55 if !resp.status().is_success() {
56 let status = resp.status();
57 let body = resp.text().await.unwrap_or_default();
58 return Err(RalphError::SearchFailed(format!(
59 "Brave HTTP {}: {}",
60 status, body
61 )));
62 }
63
64 let parsed: BraveResponse = resp
65 .json()
66 .await
67 .map_err(|e| RalphError::SearchFailed(e.to_string()))?;
68
69 let mut output = String::new();
70 for (i, result) in parsed.web.results.iter().enumerate().take(5) {
71 output.push_str(&format!(
72 "{}. **{}**\n {}\n {}\n\n",
73 i + 1,
74 result.title,
75 result.url,
76 result.description.as_deref().unwrap_or(""),
77 ));
78 }
79
80 if output.is_empty() {
81 output = "No results found.".to_string();
82 }
83 Ok(output)
84}
85
86async fn search_serp(query: &str, key: &str) -> Result<String> {
87 let client = reqwest::Client::new();
88 let resp = client
89 .get(SERP_API_URL)
90 .query(&[("q", query), ("api_key", key), ("num", "5")])
91 .send()
92 .await
93 .map_err(|e| RalphError::SearchFailed(e.to_string()))?;
94
95 if !resp.status().is_success() {
96 let status = resp.status();
97 let body = resp.text().await.unwrap_or_default();
98 return Err(RalphError::SearchFailed(format!(
99 "SerpAPI HTTP {}: {}",
100 status, body
101 )));
102 }
103
104 let parsed: SerpResponse = resp
105 .json()
106 .await
107 .map_err(|e| RalphError::SearchFailed(e.to_string()))?;
108
109 let mut output = String::new();
110 for (i, result) in parsed.organic_results.iter().enumerate().take(5) {
111 output.push_str(&format!(
112 "{}. **{}**\n {}\n {}\n\n",
113 i + 1,
114 result.title,
115 result.link,
116 result.snippet.as_deref().unwrap_or(""),
117 ));
118 }
119
120 if output.is_empty() {
121 output = "No results found.".to_string();
122 }
123 Ok(output)
124}
125
126pub fn glob_files(pattern: &str, root: &Path) -> Result<String> {
128 let mut overrides = ignore::overrides::OverrideBuilder::new(root);
129 overrides.add(pattern).map_err(|e| RalphError::ToolFailed {
130 tool: "glob".to_string(),
131 message: format!("Invalid glob pattern: {}", e),
132 })?;
133 let overrides = overrides.build().map_err(|e| RalphError::ToolFailed {
134 tool: "glob".to_string(),
135 message: format!("Failed to build overrides: {}", e),
136 })?;
137
138 let walker = WalkBuilder::new(root)
139 .hidden(false)
140 .ignore(true)
141 .git_ignore(true)
142 .overrides(overrides)
143 .build();
144
145 let mut paths: Vec<String> = walker
146 .flatten()
147 .filter(|e| e.path().is_file())
148 .filter_map(|e| {
149 let rel = e.path().strip_prefix(root).ok()?;
150 Some(rel.display().to_string())
151 })
152 .collect();
153 paths.sort();
154
155 if paths.is_empty() {
156 Ok(format!("No files matched: {}", pattern))
157 } else {
158 Ok(format!("{} file(s):\n{}", paths.len(), paths.join("\n")))
159 }
160}
161
162pub fn search_in_file(pattern: &str, path: &Path, context_lines: usize) -> Result<String> {
165 let re = Regex::new(pattern).map_err(|e| RalphError::ToolFailed {
166 tool: "search_in_file".to_string(),
167 message: format!("Invalid regex: {}", e),
168 })?;
169
170 if !path.exists() {
171 return Err(RalphError::FileNotFound(path.display().to_string()));
172 }
173
174 let content = std::fs::read_to_string(path).map_err(RalphError::Io)?;
175 let lines: Vec<&str> = content.lines().collect();
176 let total = lines.len();
177
178 let match_indices: Vec<usize> = lines
180 .iter()
181 .enumerate()
182 .filter(|(_, l)| re.is_match(l))
183 .map(|(i, _)| i)
184 .collect();
185
186 if match_indices.is_empty() {
187 return Ok(format!(
188 "No matches for '{}' in file ({} lines)",
189 pattern, total
190 ));
191 }
192
193 let mut ranges: Vec<(usize, usize)> = Vec::new();
195 for &m in &match_indices {
196 let start = m.saturating_sub(context_lines);
197 let end = (m + context_lines + 1).min(total);
198 if let Some(last) = ranges.last_mut() {
199 if start <= last.1 {
200 last.1 = end.max(last.1);
201 continue;
202 }
203 }
204 ranges.push((start, end));
205 }
206
207 let mut out = String::new();
208 for (start, end) in &ranges {
209 if !out.is_empty() {
210 out.push_str("---\n");
211 }
212 for i in *start..*end {
213 let marker = if re.is_match(lines[i]) { ">" } else { " " };
214 out.push_str(&format!("{}{:5}: {}\n", marker, i + 1, lines[i]));
215 }
216 }
217 out.push_str(&format!(
218 "\n({} match(es) in {} lines)",
219 match_indices.len(),
220 total
221 ));
222 Ok(out)
223}
224
225pub fn search_codebase(pattern: &str, root: &Path) -> Result<String> {
227 search_codebase_filtered(pattern, root, None, 0)
228}
229
230pub fn search_codebase_filtered(
231 pattern: &str,
232 root: &Path,
233 glob_filter: Option<&str>,
234 context_lines: usize,
235) -> Result<String> {
236 let re = Regex::new(pattern).map_err(|e| RalphError::ToolFailed {
237 tool: "search_codebase".to_string(),
238 message: format!("Invalid regex: {}", e),
239 })?;
240
241 let walker = if let Some(glob) = glob_filter {
242 let mut overrides = ignore::overrides::OverrideBuilder::new(root);
243 if overrides.add(glob).is_ok() {
244 if let Ok(ov) = overrides.build() {
245 WalkBuilder::new(root)
246 .hidden(false)
247 .ignore(true)
248 .git_ignore(true)
249 .overrides(ov)
250 .build()
251 } else {
252 WalkBuilder::new(root)
253 .hidden(false)
254 .ignore(true)
255 .git_ignore(true)
256 .build()
257 }
258 } else {
259 WalkBuilder::new(root)
260 .hidden(false)
261 .ignore(true)
262 .git_ignore(true)
263 .build()
264 }
265 } else {
266 WalkBuilder::new(root)
267 .hidden(false)
268 .ignore(true)
269 .git_ignore(true)
270 .build()
271 };
272
273 let mut matches: Vec<String> = Vec::new();
274
275 for entry in walker.flatten() {
276 let path = entry.path();
277 if !path.is_file() {
278 continue;
279 }
280 let rel = path.strip_prefix(root).unwrap_or(path);
281
282 let content = match std::fs::read_to_string(path) {
283 Ok(c) => c,
284 Err(_) => continue,
285 };
286 let lines: Vec<&str> = content.lines().collect();
287
288 if context_lines == 0 {
289 for (line_no, line) in lines.iter().enumerate() {
290 if re.is_match(line) {
291 matches.push(format!(
292 "{}:{}: {}",
293 rel.display(),
294 line_no + 1,
295 line.trim()
296 ));
297 if matches.len() >= 100 {
298 break;
299 }
300 }
301 }
302 } else {
303 let match_indices: Vec<usize> = lines
305 .iter()
306 .enumerate()
307 .filter(|(_, l)| re.is_match(l))
308 .map(|(i, _)| i)
309 .collect();
310 if match_indices.is_empty() {
311 continue;
312 }
313
314 let mut ranges: Vec<(usize, usize)> = Vec::new();
315 for &m in &match_indices {
316 let s = m.saturating_sub(context_lines);
317 let e = (m + context_lines + 1).min(lines.len());
318 if let Some(last) = ranges.last_mut() {
319 if s <= last.1 {
320 last.1 = e.max(last.1);
321 continue;
322 }
323 }
324 ranges.push((s, e));
325 }
326
327 for (s, e) in ranges {
328 if matches.len() > 0 {
329 matches.push("---".to_string());
330 }
331 matches.push(format!("{}:", rel.display()));
332 for i in s..e {
333 let marker = if re.is_match(lines[i]) { ">" } else { " " };
334 matches.push(format!("{}{:5}: {}", marker, i + 1, lines[i]));
335 }
336 }
337 }
338
339 if matches.len() >= 100 {
340 break;
341 }
342 }
343
344 if matches.is_empty() {
345 Ok(format!("No matches for pattern: {}", pattern))
346 } else {
347 Ok(matches.join("\n"))
348 }
349}
350
351#[derive(Deserialize)]
354struct BraveResponse {
355 web: BraveWeb,
356}
357
358#[derive(Deserialize)]
359struct BraveWeb {
360 results: Vec<BraveResult>,
361}
362
363#[derive(Deserialize)]
364struct BraveResult {
365 title: String,
366 url: String,
367 description: Option<String>,
368}
369
370#[derive(Deserialize)]
373struct SerpResponse {
374 #[serde(default)]
375 organic_results: Vec<SerpResult>,
376}
377
378#[derive(Deserialize)]
379struct SerpResult {
380 title: String,
381 link: String,
382 snippet: Option<String>,
383}