1use serde::{Deserialize, Serialize};
7
8use super::fetch::{mcp_call, strip_na};
9
10const EXA_API: &str = "https://api.exa.ai/search";
11
12#[derive(Debug, Clone, Default)]
16pub struct WebSearch {
17 pub num_results: u32,
19 pub query: String,
20}
21
22#[derive(Serialize)]
23struct ApiReq {
24 query: String,
25 #[serde(rename = "numResults")]
26 num: u32,
27 #[serde(rename = "type")]
28 kind: &'static str,
29 contents: Contents,
30}
31
32#[derive(Serialize)]
33struct Contents {
34 highlights: bool,
35}
36
37#[derive(Deserialize)]
38struct ApiRes {
39 results: Vec<ApiResult>,
40}
41
42#[derive(Deserialize)]
43struct ApiResult {
44 title: Option<String>,
45 url: String,
46 #[serde(default)]
47 highlights: Vec<String>,
48}
49
50pub async fn search(search: &WebSearch) -> Result<String, String> {
60 if search.query.is_empty() {
61 return Err("query required".into());
62 }
63 if search.num_results == 0 {
64 return Err("num_results must be >= 1".into());
65 }
66
67 let q = &search.query;
68 let n = search.num_results.min(10);
69
70 if let Ok(key) = std::env::var("EXA_API_KEY")
71 && !key.trim().is_empty()
72 && let Ok(res) = rest_search(q, n, &key).await
73 {
74 return Ok(res);
75 }
76
77 mcp_call(
78 "web_search_exa",
79 serde_json::json!({
80 "query": q,
81 "numResults": n,
82 }),
83 )
84 .await
85 .map(|c| strip_na(&c))
86}
87
88async fn rest_search(query: &str, num: u32, key: &str) -> Result<String, String> {
89 let body = ApiReq {
90 query: query.to_string(),
91 num,
92 kind: "auto",
93 contents: Contents { highlights: true },
94 };
95
96 let resp = reqwest::Client::new()
97 .post(EXA_API)
98 .header("Content-Type", "application/json")
99 .header("x-api-key", key)
100 .json(&body)
101 .send()
102 .await
103 .map_err(|e| format!("api: {e}"))?;
104
105 let status = resp.status();
106 if !status.is_success() {
107 let text = resp.text().await.unwrap_or_default();
108 return Err(format!("api {status}: {text}"));
109 }
110
111 let data: ApiRes = resp.json().await.map_err(|e| format!("parse: {e}"))?;
112
113 if data.results.is_empty() {
114 return Err("no results".into());
115 }
116
117 Ok(data
118 .results
119 .iter()
120 .map(|r| {
121 let mut parts = vec![];
122 if let Some(t) = &r.title {
123 parts.push(format!("Title: {t}"));
124 }
125 parts.push(format!("URL: {}", r.url));
126 if !r.highlights.is_empty() {
127 parts.push("Highlights:".into());
128 for h in &r.highlights {
129 parts.push(format!(" - {h}"));
130 }
131 }
132 parts.join("\n")
133 })
134 .collect::<Vec<_>>()
135 .join("\n\n---\n\n"))
136}