use serde_json::{json, Value};
use super::external_http_client;
pub(super) fn schemas() -> Vec<Value> {
vec![json!({
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web via Brave Search. Returns results with title, URL, snippet, and extra context. Use for any current-information question.",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "Search query" },
"count": { "type": "number", "description": "Number of results (default 5, max 20)" }
},
"required": ["query"]
}
}
})]
}
pub(super) fn dispatch(name: &str, input: &str) -> Option<Result<String, String>> {
let result = match name {
"web_search" => run_web_search(input),
_ => return None,
};
Some(result)
}
fn run_web_search(input: &str) -> Result<String, String> {
let v: Value = serde_json::from_str(input)
.map_err(|e| format!("web_search: invalid JSON ({e}): {input}"))?;
let query = v
.get("query")
.and_then(Value::as_str)
.ok_or("web_search: missing 'query'")?
.to_string();
let count = v
.get("count")
.and_then(Value::as_i64)
.unwrap_or(5)
.clamp(1, 20) as usize;
let api_key = crate::secrets::read_secret("brave")
.or_else(|_| {
std::env::var("BRAVE_API_KEY")
.map(|v| v.trim().to_string())
.map_err(|_| String::new())
})
.map_err(|_| {
format!(
"web_search: Brave API key not found. Get one at https://brave.com/search/api/ \
and either export BRAVE_API_KEY or save it to {}",
crate::secrets::secret_file_path("brave").display()
)
})?;
let count_str = count.to_string();
let client = external_http_client()?;
let resp = client
.get("https://api.search.brave.com/res/v1/web/search")
.query(&[("q", query.as_str()), ("count", count_str.as_str())])
.header("Accept", "application/json")
.header("X-Subscription-Token", &api_key)
.send()
.map_err(|e| format!("web_search: request failed: {e}"))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().unwrap_or_default();
return Err(format!(
"web_search: HTTP {status}: {}",
text.chars().take(300).collect::<String>()
));
}
let data: Value = resp
.json()
.map_err(|e| format!("web_search: parse failed: {e}"))?;
let results: Vec<Value> = data
.pointer("/web/results")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.take(count)
.map(|r| {
let mut result = json!({
"title": r.get("title").and_then(Value::as_str).unwrap_or(""),
"url": r.get("url").and_then(Value::as_str).unwrap_or(""),
"description": r.get("description").and_then(Value::as_str).unwrap_or(""),
});
if let Some(extras) = r.get("extra_snippets").and_then(Value::as_array) {
let snippets: Vec<&str> =
extras.iter().filter_map(Value::as_str).take(2).collect();
if !snippets.is_empty() {
result["extra_snippets"] = json!(snippets);
}
}
if let Some(age) = r.get("age").and_then(Value::as_str) {
result["age"] = json!(age);
}
result
})
.collect()
})
.unwrap_or_default();
let mut response = json!({
"query": query,
"count": results.len(),
"results": results,
});
if let Some(infobox) = data.pointer("/infobox") {
if let Some(title) = infobox.pointer("/results/0/title").and_then(Value::as_str) {
let desc = infobox
.pointer("/results/0/long_desc")
.or_else(|| infobox.pointer("/results/0/description"))
.and_then(Value::as_str)
.unwrap_or("");
response["infobox"] = json!({
"title": title,
"description": desc,
});
}
}
Ok(response.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn web_search_rejects_missing_query() {
let err = run_web_search("{}").unwrap_err();
assert!(err.contains("missing"), "got: {err}");
assert!(err.contains("query"), "got: {err}");
}
#[test]
fn web_search_rejects_invalid_json() {
let err = run_web_search("not json").unwrap_err();
assert!(err.contains("invalid JSON"), "got: {err}");
}
#[test]
fn schemas_lists_one_tool() {
let schemas = schemas();
assert_eq!(schemas.len(), 1);
let name = schemas[0]
.pointer("/function/name")
.and_then(Value::as_str)
.unwrap();
assert_eq!(name, "web_search");
}
}