Skip to main content

ralph/tools/
search_tools.rs

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
10/// Returns true when web search is available:
11/// either BRAVE_API_KEY or SERP_API_KEY is set in the environment.
12/// Web search is provider-agnostic — the user must supply their own search API key.
13pub 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
23/// Perform a web search using whichever API key is available.
24/// Brave takes priority over SerpAPI if both are set.
25pub 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
126/// List files matching a glob pattern. Returns sorted relative paths.
127pub 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
162/// Search a single file for a regex pattern, returning matching lines with
163/// optional context. Marks matching lines with `>`.
164pub 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    // Collect matching line indices.
179    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    // Build non-overlapping context ranges.
194    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
225/// Search workspace with optional glob filter (e.g. `"*.py"`) and context lines.
226pub 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            // With context: emit file header + annotated blocks.
304            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// ── Brave API types ───────────────────────────────────────────────────────────
352
353#[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// ── SerpAPI types ─────────────────────────────────────────────────────────────
371
372#[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}