use crate::errors::{RalphError, Result};
use ignore::WalkBuilder;
use regex::Regex;
use serde::Deserialize;
use std::path::Path;
const BRAVE_API_URL: &str = "https://api.search.brave.com/res/v1/web/search";
const SERP_API_URL: &str = "https://serpapi.com/search.json";
pub fn search_available(brave_key_env: &str, serp_key_env: &str) -> bool {
let has_brave = std::env::var(brave_key_env)
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
let has_serp = std::env::var(serp_key_env)
.map(|v| !v.trim().is_empty())
.unwrap_or(false);
has_brave || has_serp
}
pub async fn search_web(
query: &str,
brave_key: Option<&str>,
serp_key: Option<&str>,
) -> Result<String> {
let brave = brave_key.filter(|k| !k.is_empty());
let serp = serp_key.filter(|k| !k.is_empty());
match (brave, serp) {
(Some(key), _) => search_brave(query, key).await,
(None, Some(key)) => search_serp(query, key).await,
(None, None) => Ok("[search_web] No BRAVE_API_KEY or SERP_API_KEY set. \
Cannot perform web search. Proceed with your best knowledge, \
but note results may be outdated."
.to_string()),
}
}
async fn search_brave(query: &str, key: &str) -> Result<String> {
let client = reqwest::Client::new();
let resp = client
.get(BRAVE_API_URL)
.header("Accept", "application/json")
.header("Accept-Encoding", "gzip")
.header("X-Subscription-Token", key)
.query(&[("q", query), ("count", "5")])
.send()
.await
.map_err(|e| RalphError::SearchFailed(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(RalphError::SearchFailed(format!(
"Brave HTTP {}: {}",
status, body
)));
}
let parsed: BraveResponse = resp
.json()
.await
.map_err(|e| RalphError::SearchFailed(e.to_string()))?;
let mut output = String::new();
for (i, result) in parsed.web.results.iter().enumerate().take(5) {
output.push_str(&format!(
"{}. **{}**\n {}\n {}\n\n",
i + 1,
result.title,
result.url,
result.description.as_deref().unwrap_or(""),
));
}
if output.is_empty() {
output = "No results found.".to_string();
}
Ok(output)
}
async fn search_serp(query: &str, key: &str) -> Result<String> {
let client = reqwest::Client::new();
let resp = client
.get(SERP_API_URL)
.query(&[("q", query), ("api_key", key), ("num", "5")])
.send()
.await
.map_err(|e| RalphError::SearchFailed(e.to_string()))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(RalphError::SearchFailed(format!(
"SerpAPI HTTP {}: {}",
status, body
)));
}
let parsed: SerpResponse = resp
.json()
.await
.map_err(|e| RalphError::SearchFailed(e.to_string()))?;
let mut output = String::new();
for (i, result) in parsed.organic_results.iter().enumerate().take(5) {
output.push_str(&format!(
"{}. **{}**\n {}\n {}\n\n",
i + 1,
result.title,
result.link,
result.snippet.as_deref().unwrap_or(""),
));
}
if output.is_empty() {
output = "No results found.".to_string();
}
Ok(output)
}
pub fn glob_files(pattern: &str, root: &Path) -> Result<String> {
let mut overrides = ignore::overrides::OverrideBuilder::new(root);
overrides.add(pattern).map_err(|e| RalphError::ToolFailed {
tool: "glob".to_string(),
message: format!("Invalid glob pattern: {}", e),
})?;
let overrides = overrides.build().map_err(|e| RalphError::ToolFailed {
tool: "glob".to_string(),
message: format!("Failed to build overrides: {}", e),
})?;
let walker = WalkBuilder::new(root)
.hidden(false)
.ignore(true)
.git_ignore(true)
.overrides(overrides)
.build();
let mut paths: Vec<String> = walker
.flatten()
.filter(|e| e.path().is_file())
.filter_map(|e| {
let rel = e.path().strip_prefix(root).ok()?;
Some(rel.display().to_string())
})
.collect();
paths.sort();
if paths.is_empty() {
Ok(format!("No files matched: {}", pattern))
} else {
Ok(format!("{} file(s):\n{}", paths.len(), paths.join("\n")))
}
}
pub fn search_in_file(pattern: &str, path: &Path, context_lines: usize) -> Result<String> {
let re = Regex::new(pattern).map_err(|e| RalphError::ToolFailed {
tool: "search_in_file".to_string(),
message: format!("Invalid regex: {}", e),
})?;
if !path.exists() {
return Err(RalphError::FileNotFound(path.display().to_string()));
}
let content = std::fs::read_to_string(path).map_err(RalphError::Io)?;
let lines: Vec<&str> = content.lines().collect();
let total = lines.len();
let match_indices: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| re.is_match(l))
.map(|(i, _)| i)
.collect();
if match_indices.is_empty() {
return Ok(format!(
"No matches for '{}' in file ({} lines)",
pattern, total
));
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
for &m in &match_indices {
let start = m.saturating_sub(context_lines);
let end = (m + context_lines + 1).min(total);
if let Some(last) = ranges.last_mut() {
if start <= last.1 {
last.1 = end.max(last.1);
continue;
}
}
ranges.push((start, end));
}
let mut out = String::new();
for (start, end) in &ranges {
if !out.is_empty() {
out.push_str("---\n");
}
for i in *start..*end {
let marker = if re.is_match(lines[i]) { ">" } else { " " };
out.push_str(&format!("{}{:5}: {}\n", marker, i + 1, lines[i]));
}
}
out.push_str(&format!(
"\n({} match(es) in {} lines)",
match_indices.len(),
total
));
Ok(out)
}
pub fn search_codebase(pattern: &str, root: &Path) -> Result<String> {
search_codebase_filtered(pattern, root, None, 0)
}
pub fn search_codebase_filtered(
pattern: &str,
root: &Path,
glob_filter: Option<&str>,
context_lines: usize,
) -> Result<String> {
let re = Regex::new(pattern).map_err(|e| RalphError::ToolFailed {
tool: "search_codebase".to_string(),
message: format!("Invalid regex: {}", e),
})?;
let walker = if let Some(glob) = glob_filter {
let mut overrides = ignore::overrides::OverrideBuilder::new(root);
if overrides.add(glob).is_ok() {
if let Ok(ov) = overrides.build() {
WalkBuilder::new(root)
.hidden(false)
.ignore(true)
.git_ignore(true)
.overrides(ov)
.build()
} else {
WalkBuilder::new(root)
.hidden(false)
.ignore(true)
.git_ignore(true)
.build()
}
} else {
WalkBuilder::new(root)
.hidden(false)
.ignore(true)
.git_ignore(true)
.build()
}
} else {
WalkBuilder::new(root)
.hidden(false)
.ignore(true)
.git_ignore(true)
.build()
};
let mut matches: Vec<String> = Vec::new();
for entry in walker.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let rel = path.strip_prefix(root).unwrap_or(path);
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let lines: Vec<&str> = content.lines().collect();
if context_lines == 0 {
for (line_no, line) in lines.iter().enumerate() {
if re.is_match(line) {
matches.push(format!(
"{}:{}: {}",
rel.display(),
line_no + 1,
line.trim()
));
if matches.len() >= 100 {
break;
}
}
}
} else {
let match_indices: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| re.is_match(l))
.map(|(i, _)| i)
.collect();
if match_indices.is_empty() {
continue;
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
for &m in &match_indices {
let s = m.saturating_sub(context_lines);
let e = (m + context_lines + 1).min(lines.len());
if let Some(last) = ranges.last_mut() {
if s <= last.1 {
last.1 = e.max(last.1);
continue;
}
}
ranges.push((s, e));
}
for (s, e) in ranges {
if matches.len() > 0 {
matches.push("---".to_string());
}
matches.push(format!("{}:", rel.display()));
for i in s..e {
let marker = if re.is_match(lines[i]) { ">" } else { " " };
matches.push(format!("{}{:5}: {}", marker, i + 1, lines[i]));
}
}
}
if matches.len() >= 100 {
break;
}
}
if matches.is_empty() {
Ok(format!("No matches for pattern: {}", pattern))
} else {
Ok(matches.join("\n"))
}
}
#[derive(Deserialize)]
struct BraveResponse {
web: BraveWeb,
}
#[derive(Deserialize)]
struct BraveWeb {
results: Vec<BraveResult>,
}
#[derive(Deserialize)]
struct BraveResult {
title: String,
url: String,
description: Option<String>,
}
#[derive(Deserialize)]
struct SerpResponse {
#[serde(default)]
organic_results: Vec<SerpResult>,
}
#[derive(Deserialize)]
struct SerpResult {
title: String,
link: String,
snippet: Option<String>,
}