Skip to main content

cosh_tools/web/
search.rs

1//! Web search via Exa AI, with local fallback.
2//!
3//! Requires `EXA_API_KEY` to use the Exa REST API (`api.exa.ai/search`).
4//! Falls back to the free MCP endpoint (`mcp.exa.ai/mcp`) when the key
5//! is absent.
6use serde::{Deserialize, Serialize};
7
8use super::fetch::{mcp_call, strip_na};
9
10const EXA_API: &str = "https://api.exa.ai/search";
11
12/// Configuration for web search.
13///
14/// `num_results` is silently capped at 10.
15#[derive(Debug, Clone, Default)]
16pub struct WebSearch {
17    /// Number of results to request (max 10).
18    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
50/// Search the web, returning clean markdown for LLM context.
51///
52/// Exa search is used (REST API if `EXA_API_KEY` is set, otherwise MCP
53/// `web_search_exa`).
54///
55/// # Errors
56///
57/// Returns `Err` if validation fails, the search fails, or all fallback
58/// methods are exhausted.
59pub 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}