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}