Skip to main content

grapheme_stdlib/
web.rs

1use serde_json::{json, Value as JsonValue};
2use websearch::{providers::DuckDuckGoProvider, web_search, SearchOptions};
3
4pub fn search(args: &JsonValue) -> JsonValue {
5    let request = SearchRequest::from_args(args);
6    if request.query.trim().is_empty() {
7        return json!({ "error": "missing required arg: query" });
8    }
9
10    let provider = request.provider.clone();
11
12    if provider != "duckduckgo" {
13        return json!({
14            "error": format!("unsupported websearch provider '{}'; currently supported: duckduckgo", provider)
15        });
16    }
17
18    let runtime = match tokio::runtime::Builder::new_current_thread()
19        .enable_all()
20        .build()
21    {
22        Ok(rt) => rt,
23        Err(err) => {
24            return json!({ "error": format!("websearch runtime init failed: {err}") });
25        }
26    };
27
28    let search_result = runtime.block_on(async {
29        let provider = DuckDuckGoProvider::new();
30        web_search(SearchOptions {
31            query: request.query.clone(),
32            max_results: request.max_results,
33            provider: Box::new(provider),
34            ..Default::default()
35        })
36        .await
37    });
38
39    match search_result {
40        Ok(results) => {
41            let results_json = serde_json::to_value(results)
42                .unwrap_or_else(|_| JsonValue::Array(Vec::new()));
43            SearchResponse::success(request.query, provider, results_json).to_json()
44        }
45        Err(err) => SearchResponse::failure(request.query, provider, err.to_string()).to_json(),
46    }
47}
48
49pub fn search_provider(args: &JsonValue, provider: &str) -> JsonValue {
50    let request = SearchRequest::from_args(args).with_provider(provider);
51    search(&request.to_json())
52}
53
54pub fn providers() -> JsonValue {
55    let providers = provider_catalog()
56        .into_iter()
57        .map(|provider| provider.to_json())
58        .collect::<Vec<_>>();
59
60    json!({
61        "count": providers.len(),
62        "providers": providers,
63    })
64}
65
66pub fn capabilities(args: &JsonValue) -> JsonValue {
67    let Some(target) = args
68        .get("provider")
69        .and_then(|v| v.as_str())
70        .map(|s| s.to_ascii_lowercase())
71    else {
72        return providers();
73    };
74
75    let Some(provider) = provider_catalog()
76        .into_iter()
77        .find(|p| p.id == target)
78    else {
79        return json!({
80            "error": format!("unknown provider '{}'", target),
81            "available_providers": provider_catalog().into_iter().map(|p| p.id).collect::<Vec<_>>()
82        });
83    };
84
85    json!({ "provider": provider.to_json() })
86}
87
88#[derive(Debug, Clone)]
89struct SearchRequest {
90    query: String,
91    provider: String,
92    max_results: Option<u32>,
93}
94
95#[derive(Debug, Clone)]
96struct SearchResponse {
97    query: String,
98    provider: String,
99    results: JsonValue,
100    error: Option<String>,
101}
102
103impl SearchResponse {
104    fn success(query: String, provider: String, results: JsonValue) -> Self {
105        Self {
106            query,
107            provider,
108            results,
109            error: None,
110        }
111    }
112
113    fn failure(query: String, provider: String, error: String) -> Self {
114        Self {
115            query,
116            provider,
117            results: JsonValue::Array(Vec::new()),
118            error: Some(error),
119        }
120    }
121
122    fn to_json(&self) -> JsonValue {
123        let count = self
124            .results
125            .as_array()
126            .map(|items| items.len())
127            .unwrap_or(0);
128        if let Some(error) = &self.error {
129            json!({
130                "query": self.query,
131                "provider": self.provider,
132                "error": error,
133                "results": self.results,
134            })
135        } else {
136            json!({
137                "query": self.query,
138                "provider": self.provider,
139                "count": count,
140                "results": self.results,
141            })
142        }
143    }
144}
145
146impl SearchRequest {
147    fn from_args(args: &JsonValue) -> Self {
148        Self {
149            query: arg_text(args, "query"),
150            provider: args
151                .get("provider")
152                .and_then(|v| v.as_str())
153                .unwrap_or("duckduckgo")
154                .to_ascii_lowercase(),
155            max_results: args
156                .get("max_results")
157                .and_then(|v| v.as_u64())
158                .map(|v| v.min(20) as u32),
159        }
160    }
161
162    fn with_provider(mut self, provider: &str) -> Self {
163        self.provider = provider.to_ascii_lowercase();
164        self
165    }
166
167    fn to_json(&self) -> JsonValue {
168        json!({
169            "query": self.query,
170            "provider": self.provider,
171            "max_results": self.max_results,
172        })
173    }
174}
175
176#[derive(Debug, Clone)]
177struct WebProvider {
178    id: &'static str,
179    status: &'static str,
180    supports_search: bool,
181    supports_research_flow: bool,
182    note: &'static str,
183}
184
185impl WebProvider {
186    fn to_json(&self) -> JsonValue {
187        json!({
188            "id": self.id,
189            "status": self.status,
190            "supports": {
191                "search": self.supports_search,
192                "research_materials": self.supports_research_flow,
193                "research_report": self.supports_research_flow,
194            },
195            "note": self.note,
196        })
197    }
198}
199
200fn provider_catalog() -> Vec<WebProvider> {
201    vec![
202        WebProvider {
203            id: "duckduckgo",
204            status: "available",
205            supports_search: true,
206            supports_research_flow: true,
207            note: "Native provider is wired and active.",
208        },
209        WebProvider {
210            id: "google",
211            status: "planned",
212            supports_search: false,
213            supports_research_flow: false,
214            note: "Provider namespace is reserved; backend not yet wired.",
215        },
216        WebProvider {
217            id: "xaviv",
218            status: "planned",
219            supports_search: false,
220            supports_research_flow: false,
221            note: "Provider namespace is reserved; backend not yet wired.",
222        },
223    ]
224}
225
226fn arg_text(args: &JsonValue, key: &str) -> String {
227    args.get(key)
228        .and_then(|v| v.as_str())
229        .map(ToOwned::to_owned)
230        .or_else(|| args.get("__input").and_then(|v| v.as_str()).map(ToOwned::to_owned))
231        .unwrap_or_default()
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use serde_json::json;
238
239    #[test]
240    fn search_rejects_unsupported_provider() {
241        let out = search(&json!({
242            "query": "rust",
243            "provider": "google"
244        }));
245        assert!(out.get("error").and_then(|v| v.as_str()).is_some());
246    }
247
248    #[test]
249    fn search_provider_alias_routes_semantics() {
250        let out = search_provider(&json!({ "query": "rust" }), "xaviv");
251        let err = out.get("error").and_then(|v| v.as_str()).unwrap_or("");
252        assert!(err.contains("unsupported websearch provider 'xaviv'"));
253    }
254
255    #[test]
256    fn providers_lists_known_catalog() {
257        let out = providers();
258        let providers = out
259            .get("providers")
260            .and_then(|v| v.as_array())
261            .cloned()
262            .unwrap_or_default();
263
264        assert!(providers.iter().any(|item| {
265            item.get("id")
266                .and_then(|v| v.as_str())
267                .map(|id| id == "duckduckgo")
268                .unwrap_or(false)
269        }));
270    }
271
272    #[test]
273    fn capabilities_rejects_unknown_provider() {
274        let out = capabilities(&json!({ "provider": "unknown" }));
275        assert!(out.get("error").and_then(|v| v.as_str()).is_some());
276    }
277}