//! SearXNG JSON search implementation with a fixed configured destination.
use std::time::Duration;
use reqwest::{Client, header::{AUTHORIZATION, HeaderMap, HeaderValue}};
use serde_json::{Map, Value};
use url::Url;
use crate::config::SearchConfig;
use super::{SourceRegistry, ToolResult};
#[derive(Clone)]
pub(super) struct WebSearchTool
{
client: Client,
config: SearchConfig,
}
impl WebSearchTool
{
pub(super) fn new(config: SearchConfig) -> Result<Self, reqwest::Error>
{
let mut headers = HeaderMap::new();
if let Some(key) = &config.api_key
&& let Ok(value) = HeaderValue::from_str(&format!("Bearer {}", key.expose()))
{
headers.insert(AUTHORIZATION, value);
}
let client = Client::builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(config.timeout_seconds))
.build()?;
Ok(Self { client, config })
}
pub(super) async fn execute(
&self,
arguments: Value,
sources: &mut SourceRegistry,
) -> ToolResult
{
let Some(object) = arguments.as_object() else
{
return ToolResult::failure("invalid_arguments", "Tool arguments must be an object.");
};
if object.keys().any(|key| key != "query" && key != "result_count")
{
return ToolResult::failure("invalid_arguments", "The search arguments contain an unknown field.");
}
let Some(query) = object.get("query").and_then(Value::as_str).map(str::trim) else
{
return ToolResult::failure("invalid_arguments", "A non-empty search query is required.");
};
if query.is_empty() || query.chars().count() > 500
{
return ToolResult::failure("invalid_arguments", "The search query is outside the allowed length.");
}
let count = object.get("result_count").and_then(Value::as_u64)
.unwrap_or(u64::from(self.config.max_results));
if count == 0 || count > u64::from(self.config.max_results)
{
return ToolResult::failure("invalid_arguments", "The requested result count is outside the configured limit.");
}
let mut endpoint = self.config.endpoint.clone();
endpoint.query_pairs_mut().append_pair("q", query)
.append_pair("format", "json").append_pair("categories", "general");
let response = match self.client.get(endpoint).send().await
{
Ok(response) if response.status().is_success() => response,
Ok(_) => return ToolResult::failure("search_unavailable", "The search service did not accept the request."),
Err(_) => return ToolResult::failure("search_unavailable", "The search service is unavailable."),
};
let payload: Value = match response.json().await
{
Ok(payload) => payload,
Err(_) => return ToolResult::failure("search_unavailable", "The search service returned invalid JSON."),
};
let mut lines = Vec::new();
if let Some(results) = payload.get("results").and_then(Value::as_array)
{
let count = usize::try_from(count).unwrap_or(usize::MAX);
for (index, result) in results.iter().take(count).enumerate()
{
let Some(url) = result.get("url").and_then(Value::as_str).and_then(parse_public_url) else { continue; };
let title = result.get("title").and_then(Value::as_str).unwrap_or("Untitled");
let snippet = result.get("content").and_then(Value::as_str).unwrap_or("");
let title: String = title.chars().take(200).collect();
let snippet: String = snippet.chars().take(500).collect();
sources.register(url.clone(), title.clone());
lines.push(format!("{}. {}\n{}\n{}", index + 1, title, url, snippet));
}
}
let content = lines.join("\n\n");
ToolResult::success("search_ok", content, Map::new())
}
}
fn parse_public_url(value: &str) -> Option<Url>
{
let mut url = Url::parse(value).ok()?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|| !url.username().is_empty() || url.password().is_some()
{
return None;
}
url.set_fragment(None);
Some(url)
}