1use imp_llm::auth::AuthStore;
8#[cfg(test)]
9use imp_llm::auth::StoredCredential;
10use reqwest::Client;
11use serde_json::{json, Value};
12use std::path::Path;
13
14use super::types::{SearchProvider, SearchResponse, SearchResult};
15
16pub async fn search(
18 client: &Client,
19 provider: SearchProvider,
20 query: &str,
21 max_results: usize,
22) -> Result<SearchResponse, SearchError> {
23 let api_key = resolve_api_key(provider, std::env::var(provider.env_key_name()).ok(), None)?;
24
25 let response = match provider {
26 SearchProvider::Tavily => tavily_search(client, &api_key, query, max_results).await,
27 SearchProvider::Exa => exa_search(client, &api_key, query, max_results).await,
28 SearchProvider::Linkup => linkup_search(client, &api_key, query, max_results).await,
29 SearchProvider::Perplexity => perplexity_search(client, &api_key, query, max_results).await,
30 }?;
31
32 Ok(response)
33}
34
35fn resolve_api_key(
38 provider: SearchProvider,
39 env_value: Option<String>,
40 auth_path: Option<&Path>,
41) -> Result<String, SearchError> {
42 if let Some(key) = env_value.filter(|value| !value.trim().is_empty()) {
43 return Ok(key);
44 }
45
46 let auth_path = auth_path
47 .map(Path::to_path_buf)
48 .or_else(crate::storage::existing_global_auth_path)
49 .unwrap_or_else(crate::storage::global_auth_path);
50 let auth_store = AuthStore::load(&auth_path).unwrap_or_else(|_| AuthStore::new(auth_path));
51
52 auth_store
53 .resolve_api_key_only(provider.name())
54 .map_err(|_| SearchError::MissingApiKey(provider))
55}
56
57async fn tavily_search(
60 client: &Client,
61 api_key: &str,
62 query: &str,
63 max_results: usize,
64) -> Result<SearchResponse, SearchError> {
65 let body = json!({
66 "api_key": api_key,
67 "query": query,
68 "search_depth": "basic",
69 "include_answer": true,
70 "max_results": max_results.min(10),
71 });
72
73 let resp = client
74 .post("https://api.tavily.com/search")
75 .json(&body)
76 .send()
77 .await
78 .map_err(|e| SearchError::Request(e.to_string()))?;
79
80 let status = resp.status();
81 let data: Value = resp
82 .json()
83 .await
84 .map_err(|e| SearchError::Parse(e.to_string()))?;
85
86 if !status.is_success() {
87 return Err(SearchError::Api(format!(
88 "Tavily {status}: {}",
89 data.get("detail")
90 .or(data.get("error"))
91 .and_then(Value::as_str)
92 .unwrap_or("unknown error")
93 )));
94 }
95
96 let answer = data.get("answer").and_then(Value::as_str).map(String::from);
97 let results = data
98 .get("results")
99 .and_then(Value::as_array)
100 .map(|arr| {
101 arr.iter()
102 .map(|r| SearchResult {
103 title: r["title"].as_str().unwrap_or("").to_string(),
104 url: r["url"].as_str().unwrap_or("").to_string(),
105 snippet: r["content"].as_str().map(String::from),
106 date: None,
107 })
108 .collect()
109 })
110 .unwrap_or_default();
111
112 Ok(SearchResponse {
113 results,
114 answer,
115 provider: SearchProvider::Tavily,
116 })
117}
118
119async fn exa_search(
122 client: &Client,
123 api_key: &str,
124 query: &str,
125 max_results: usize,
126) -> Result<SearchResponse, SearchError> {
127 let body = json!({
128 "query": query,
129 "numResults": max_results.min(20),
130 "type": "auto",
131 });
132
133 let resp = client
134 .post("https://api.exa.ai/search")
135 .header("x-api-key", api_key)
136 .json(&body)
137 .send()
138 .await
139 .map_err(|e| SearchError::Request(e.to_string()))?;
140
141 let status = resp.status();
142 let data: Value = resp
143 .json()
144 .await
145 .map_err(|e| SearchError::Parse(e.to_string()))?;
146
147 if !status.is_success() {
148 return Err(SearchError::Api(format!(
149 "Exa {status}: {}",
150 data.get("error")
151 .and_then(Value::as_str)
152 .unwrap_or("unknown error")
153 )));
154 }
155
156 let results = data
157 .get("results")
158 .and_then(Value::as_array)
159 .map(|arr| {
160 arr.iter()
161 .map(|r| SearchResult {
162 title: r["title"].as_str().unwrap_or("").to_string(),
163 url: r["url"].as_str().unwrap_or("").to_string(),
164 snippet: r["text"].as_str().map(|t| truncate(t, 500)),
165 date: r["publishedDate"].as_str().map(String::from),
166 })
167 .collect()
168 })
169 .unwrap_or_default();
170
171 Ok(SearchResponse {
172 results,
173 answer: None,
174 provider: SearchProvider::Exa,
175 })
176}
177
178async fn linkup_search(
181 client: &Client,
182 api_key: &str,
183 query: &str,
184 max_results: usize,
185) -> Result<SearchResponse, SearchError> {
186 let body = json!({
187 "q": query,
188 "depth": "standard",
189 "outputType": "sourcedAnswer",
190 "includeSources": true,
191 "maxResults": max_results.min(10),
192 });
193
194 let resp = client
195 .post("https://api.linkup.so/v1/search")
196 .bearer_auth(api_key)
197 .json(&body)
198 .send()
199 .await
200 .map_err(|e| SearchError::Request(e.to_string()))?;
201
202 let status = resp.status();
203 let data: Value = resp
204 .json()
205 .await
206 .map_err(|e| SearchError::Parse(e.to_string()))?;
207
208 if !status.is_success() {
209 return Err(SearchError::Api(format!(
210 "Linkup {status}: {}",
211 data.get("error")
212 .or(data.get("message"))
213 .and_then(Value::as_str)
214 .unwrap_or("unknown error")
215 )));
216 }
217
218 let answer = data.get("answer").and_then(Value::as_str).map(String::from);
219 let results = data
220 .get("sources")
221 .and_then(Value::as_array)
222 .map(|arr| {
223 arr.iter()
224 .map(|r| SearchResult {
225 title: r["name"].as_str().unwrap_or("").to_string(),
226 url: r["url"].as_str().unwrap_or("").to_string(),
227 snippet: r["snippet"].as_str().map(String::from),
228 date: None,
229 })
230 .collect()
231 })
232 .unwrap_or_default();
233
234 Ok(SearchResponse {
235 results,
236 answer,
237 provider: SearchProvider::Linkup,
238 })
239}
240
241async fn perplexity_search(
244 client: &Client,
245 api_key: &str,
246 query: &str,
247 max_results: usize,
248) -> Result<SearchResponse, SearchError> {
249 let body = json!({
250 "query": query,
251 "max_results": max_results.min(20),
252 });
253
254 let resp = client
255 .post("https://api.perplexity.ai/search")
256 .bearer_auth(api_key)
257 .header("Content-Type", "application/json")
258 .json(&body)
259 .send()
260 .await
261 .map_err(|e| SearchError::Request(e.to_string()))?;
262
263 let status = resp.status();
264 let data: Value = resp
265 .json()
266 .await
267 .map_err(|e| SearchError::Parse(e.to_string()))?;
268
269 if !status.is_success() {
270 return Err(SearchError::Api(format!(
271 "Perplexity {status}: {}",
272 data.get("error")
273 .or(data.get("detail"))
274 .and_then(Value::as_str)
275 .unwrap_or("unknown error")
276 )));
277 }
278
279 let results = data
280 .get("results")
281 .and_then(Value::as_array)
282 .map(|arr| {
283 arr.iter()
284 .map(|r| SearchResult {
285 title: r["title"].as_str().unwrap_or("").to_string(),
286 url: r["url"].as_str().unwrap_or("").to_string(),
287 snippet: r["snippet"].as_str().map(String::from),
288 date: r["date"].as_str().map(String::from),
289 })
290 .collect()
291 })
292 .unwrap_or_default();
293
294 Ok(SearchResponse {
295 results,
296 answer: None,
297 provider: SearchProvider::Perplexity,
298 })
299}
300
301fn truncate(s: &str, max_chars: usize) -> String {
304 if s.len() <= max_chars {
305 s.to_string()
306 } else {
307 let truncated: String = s.chars().take(max_chars).collect();
308 format!("{truncated}...")
309 }
310}
311
312#[derive(Debug)]
313pub enum SearchError {
314 MissingApiKey(SearchProvider),
315 Request(String),
316 Api(String),
317 Parse(String),
318}
319
320impl std::fmt::Display for SearchError {
321 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322 match self {
323 Self::MissingApiKey(provider) => write!(
324 f,
325 "{} not set. Run `imp login {}` or set {} in your environment.",
326 provider.env_key_name(),
327 provider.name(),
328 provider.env_key_name()
329 ),
330 Self::Request(msg) => write!(f, "Request failed: {msg}"),
331 Self::Api(msg) => write!(f, "API error: {msg}"),
332 Self::Parse(msg) => write!(f, "Failed to parse response: {msg}"),
333 }
334 }
335}
336
337impl std::error::Error for SearchError {}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use tempfile::tempdir;
343
344 #[test]
345 fn resolve_api_key_uses_explicit_env_value() {
346 let key =
347 resolve_api_key(SearchProvider::Exa, Some("exa-env-key".to_string()), None).unwrap();
348
349 assert_eq!(key, "exa-env-key");
350 }
351
352 #[test]
353 fn resolve_api_key_reads_imp_auth_store() {
354 let dir = tempdir().unwrap();
355 let auth_path = dir.path().join("auth.json");
356 let mut auth_store = AuthStore::new(auth_path.clone());
357 auth_store
358 .store(
359 SearchProvider::Tavily.name(),
360 StoredCredential::ApiKey {
361 key: "tvly-saved-key".to_string(),
362 },
363 )
364 .unwrap();
365
366 let key = resolve_api_key(SearchProvider::Tavily, None, Some(&auth_path)).unwrap();
367 assert_eq!(key, "tvly-saved-key");
368 }
369
370 #[test]
371 fn resolve_api_key_missing_reports_provider() {
372 let dir = tempdir().unwrap();
373 let auth_path = dir.path().join("auth.json");
374 let err = resolve_api_key(SearchProvider::Exa, None, Some(&auth_path)).unwrap_err();
375 let msg = err.to_string();
376 assert!(msg.contains("EXA_API_KEY"));
377 assert!(msg.contains("imp login exa"));
378 }
379}