use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::Deserialize;
use crate::tools::truncate_output;
const MAX_OUTPUT_BYTES: usize = 102_400;
const DEFAULT_RESULT_COUNT: u32 = 10;
const BRAVE_SEARCH_URL: &str = "https://api.search.brave.com/res/v1/web/search";
#[derive(Debug, Deserialize)]
pub struct WebSearchArgs {
pub query: String,
#[serde(default)]
pub count: Option<u32>,
}
#[derive(Debug, thiserror::Error)]
pub enum WebSearchError {
#[error("Brave Search API key not configured. Add brave_api_key to ~/.seval/config.toml")]
NoApiKey,
#[error("Search request failed: {0}")]
RequestError(String),
#[error("Failed to parse search response: {0}")]
ParseError(String),
}
#[derive(Debug, Deserialize)]
struct BraveResponse {
#[serde(default)]
web: Option<BraveWeb>,
}
#[derive(Debug, Deserialize)]
struct BraveWeb {
#[serde(default)]
results: Vec<BraveResult>,
}
#[derive(Debug, Deserialize)]
struct BraveResult {
#[serde(default)]
title: String,
#[serde(default)]
url: String,
#[serde(default)]
description: String,
}
pub struct WebSearchTool {
api_key: Option<String>,
}
impl WebSearchTool {
pub fn new(api_key: Option<String>) -> Self {
Self { api_key }
}
}
impl Tool for WebSearchTool {
const NAME: &'static str = "web_search";
type Error = WebSearchError;
type Args = WebSearchArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: "web_search".to_string(),
description: "Search the web using Brave Search API. Returns structured results \
with titles, URLs, and descriptions. Requires a Brave Search API key \
configured in ~/.seval/config.toml."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
},
"count": {
"type": "integer",
"description": "Number of results to return (default: 10, max: 20)"
}
},
"required": ["query"]
}),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let api_key = self.api_key.as_ref().ok_or(WebSearchError::NoApiKey)?;
let count = args.count.unwrap_or(DEFAULT_RESULT_COUNT).min(20);
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| WebSearchError::RequestError(e.to_string()))?;
let response = client
.get(BRAVE_SEARCH_URL)
.header("X-Subscription-Token", api_key)
.header("Accept", "application/json")
.query(&[("q", args.query.as_str()), ("count", &count.to_string())])
.send()
.await
.map_err(|e| WebSearchError::RequestError(e.to_string()))?;
if !response.status().is_success() {
return Err(WebSearchError::RequestError(format!(
"HTTP {}",
response.status()
)));
}
let brave_response: BraveResponse = response
.json()
.await
.map_err(|e| WebSearchError::ParseError(e.to_string()))?;
let results = brave_response.web.map(|w| w.results).unwrap_or_default();
if results.is_empty() {
return Ok("No results found.".to_string());
}
let formatted = format_results(&results);
Ok(truncate_output(&formatted, MAX_OUTPUT_BYTES))
}
}
fn format_results(results: &[BraveResult]) -> String {
use std::fmt::Write;
let mut output = String::new();
for (i, result) in results.iter().enumerate() {
if i > 0 {
output.push('\n');
}
let _ = write!(
output,
"{}. {}\n {}\n {}",
i + 1,
result.title,
result.url,
result.description
);
}
output
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_definition_has_required_fields() {
let tool = WebSearchTool::new(None);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let def = rt.block_on(tool.definition(String::new()));
assert_eq!(def.name, "web_search");
assert!(!def.description.is_empty());
let params = &def.parameters;
assert_eq!(params["type"], "object");
assert!(params["properties"]["query"].is_object());
assert!(params["properties"]["count"].is_object());
let required = params["required"].as_array().unwrap();
assert!(required.iter().any(|v| v == "query"));
}
#[tokio::test]
async fn test_no_api_key_returns_error() {
let tool = WebSearchTool::new(None);
let result = tool
.call(WebSearchArgs {
query: "test query".to_string(),
count: None,
})
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Brave Search API key not configured"),
"got: {err}"
);
assert!(
err.contains("config.toml"),
"should mention config file, got: {err}"
);
}
#[test]
fn test_result_formatting_single() {
let results = vec![BraveResult {
title: "Example Page".to_string(),
url: "https://example.com".to_string(),
description: "An example page for testing".to_string(),
}];
let formatted = format_results(&results);
assert!(formatted.contains("1. Example Page"));
assert!(formatted.contains("https://example.com"));
assert!(formatted.contains("An example page for testing"));
}
#[test]
fn test_result_formatting_multiple() {
let results = vec![
BraveResult {
title: "First".to_string(),
url: "https://first.com".to_string(),
description: "First result".to_string(),
},
BraveResult {
title: "Second".to_string(),
url: "https://second.com".to_string(),
description: "Second result".to_string(),
},
BraveResult {
title: "Third".to_string(),
url: "https://third.com".to_string(),
description: "Third result".to_string(),
},
];
let formatted = format_results(&results);
assert!(formatted.contains("1. First"));
assert!(formatted.contains("2. Second"));
assert!(formatted.contains("3. Third"));
}
#[test]
fn test_result_formatting_empty() {
let results: Vec<BraveResult> = vec![];
let formatted = format_results(&results);
assert!(formatted.is_empty());
}
}