websearch/
lib.rs

1//! # Search SDK - Rust Implementation
2//!
3//! A high-performance Rust SDK for integrating with multiple web search providers through a single, consistent interface.
4//!
5//! Initially based on the [PlustOrg/search-sdk](https://github.com/PlustOrg/search-sdk) TypeScript library,
6//! this Rust implementation has evolved to include additional features such as multi-provider search strategies,
7//! load balancing, failover support, and performance monitoring.
8//!
9//! ## Quick Start
10//!
11//! ```rust
12//! use websearch::{web_search, providers::google::GoogleProvider, SearchOptions};
13//!
14//! #[tokio::main]
15//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
16//!     // Configure the Google search provider
17//!     let google = GoogleProvider::new("YOUR_API_KEY", "YOUR_SEARCH_ENGINE_ID")?;
18//!
19//!     // Perform a search
20//!     let results = web_search(SearchOptions {
21//!         query: "Rust programming language".to_string(),
22//!         max_results: Some(5),
23//!         provider: Box::new(google),
24//!         ..Default::default()
25//!     }).await?;
26//!
27//!     for result in results {
28//!         println!("{}: {}", result.title, result.url);
29//!     }
30//!
31//!     Ok(())
32//! }
33//! ```
34
35pub mod error;
36pub mod multi_provider;
37pub mod providers;
38pub mod types;
39pub mod utils;
40
41// Re-export common types
42pub use error::{SearchError, SearchResult as Result};
43pub use types::{DebugOptions, SearchOptions, SearchProvider, SearchResult};
44
45/// Main search function that queries a web search provider and returns standardized results
46///
47/// # Arguments
48///
49/// * `options` - Search options including provider, query and other parameters
50///
51/// # Returns
52///
53/// A vector of search results or an error
54///
55/// # Examples
56///
57/// ```rust
58/// use websearch::{web_search, providers::google::GoogleProvider, SearchOptions};
59///
60/// # #[tokio::main]
61/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
62/// let provider = GoogleProvider::new("api_key", "cx_id")?;
63/// let results = web_search(SearchOptions {
64///     query: "rust programming".to_string(),
65///     provider: Box::new(provider),
66///     ..Default::default()
67/// }).await?;
68/// # Ok(())
69/// # }
70/// ```
71pub async fn web_search(options: SearchOptions) -> Result<Vec<SearchResult>> {
72    use error::SearchError;
73    use utils::debug;
74
75    // Validate required options
76    if options.query.is_empty() && options.id_list.is_none() {
77        return Err(SearchError::InvalidInput(
78            "A search query or ID list (for Arxiv) is required".to_string(),
79        ));
80    }
81
82    // Log search parameters if debugging is enabled
83    debug::log(
84        &options.debug,
85        "Performing search",
86        &format!(
87            "provider: {}, query: {}",
88            options.provider.name(),
89            options.query
90        ),
91    );
92
93    // Perform the search
94    match options.provider.search(&options).await {
95        Ok(results) => {
96            debug::log_response(
97                &options.debug,
98                &format!("Received {} results", results.len()),
99            );
100            Ok(results)
101        }
102        Err(error) => {
103            let troubleshooting = get_troubleshooting_info(options.provider.name(), &error);
104            let detailed_error = format!(
105                "Search with provider '{}' failed: {}\n\nTroubleshooting: {}",
106                options.provider.name(),
107                error,
108                troubleshooting
109            );
110
111            debug::log(&options.debug, "Search error", &detailed_error);
112            Err(SearchError::ProviderError(detailed_error))
113        }
114    }
115}
116
117/// Get provider-specific troubleshooting information based on error
118fn get_troubleshooting_info(provider_name: &str, error: &SearchError) -> String {
119    let mut suggestions = String::new();
120
121    // Common troubleshooting based on error type
122    match error {
123        SearchError::HttpError {
124            status_code: Some(401 | 403),
125            ..
126        } => {
127            suggestions = "This is likely an authentication issue. Check your API key and make sure it's valid and has the correct permissions.".to_string();
128        }
129        SearchError::HttpError {
130            status_code: Some(400),
131            ..
132        } => {
133            suggestions = "This is likely due to invalid request parameters. Check your query and other search options.".to_string();
134        }
135        SearchError::HttpError {
136            status_code: Some(429),
137            ..
138        } => {
139            suggestions = "You've exceeded the rate limit for this API. Try again later or reduce your request frequency.".to_string();
140        }
141        SearchError::HttpError {
142            status_code: Some(500..=599),
143            ..
144        } => {
145            suggestions =
146                "The search provider is experiencing server issues. Try again later.".to_string();
147        }
148        _ => {}
149    }
150
151    // Provider-specific troubleshooting
152    match provider_name {
153        "google" => {
154            if suggestions.is_empty() {
155                suggestions = "Make sure your Google API key is valid and has the Custom Search API enabled. Also check if your Search Engine ID (cx) is correct.".to_string();
156            }
157        }
158        "serpapi" => {
159            if suggestions.is_empty() {
160                suggestions = "Check that your SerpAPI key is valid. Verify that you have enough credits remaining in your SerpAPI account.".to_string();
161            }
162        }
163        "brave" => {
164            if suggestions.is_empty() {
165                suggestions = "Ensure your Brave Search API token is valid. Check your subscription status in the Brave Developer Hub.".to_string();
166            }
167        }
168        "searxng" => {
169            if suggestions.is_empty() {
170                suggestions = "Check if your SearXNG instance URL is correct and that the server is running. Verify the format of your search URL.".to_string();
171            }
172        }
173        "duckduckgo" => {
174            if suggestions.is_empty() {
175                suggestions = "You may be making too many requests to DuckDuckGo. Try adding a delay between requests or reduce your request frequency.".to_string();
176            }
177        }
178        _ => {
179            if suggestions.is_empty() {
180                suggestions = format!(
181                    "Check your {provider_name} API credentials and make sure your search request is valid."
182                );
183            }
184        }
185    }
186
187    suggestions
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::types::*;
194    use async_trait::async_trait;
195
196    // Mock provider for testing
197    #[derive(Debug)]
198    struct MockProvider {
199        name: String,
200        should_error: bool,
201        error_type: Option<SearchError>,
202        results: Vec<SearchResult>,
203    }
204
205    impl MockProvider {
206        fn new(name: &str) -> Self {
207            Self {
208                name: name.to_string(),
209                should_error: false,
210                error_type: None,
211                results: vec![
212                    SearchResult {
213                        title: "Test Result 1".to_string(),
214                        url: "https://example.com/1".to_string(),
215                        snippet: Some("Test content 1".to_string()),
216                        domain: None,
217                        published_date: None,
218                        provider: Some(name.to_string()),
219                        raw: None,
220                    },
221                    SearchResult {
222                        title: "Test Result 2".to_string(),
223                        url: "https://example.com/2".to_string(),
224                        snippet: Some("Test content 2".to_string()),
225                        domain: None,
226                        published_date: None,
227                        provider: Some(name.to_string()),
228                        raw: None,
229                    },
230                ],
231            }
232        }
233
234        fn with_error(mut self, error: SearchError) -> Self {
235            self.should_error = true;
236            self.error_type = Some(error);
237            self
238        }
239
240        fn with_results(mut self, results: Vec<SearchResult>) -> Self {
241            self.results = results;
242            self
243        }
244    }
245
246    #[async_trait]
247    impl SearchProvider for MockProvider {
248        fn name(&self) -> &str {
249            &self.name
250        }
251
252        async fn search(&self, _options: &SearchOptions) -> Result<Vec<SearchResult>> {
253            if self.should_error {
254                Err(self
255                    .error_type
256                    .clone()
257                    .unwrap_or(SearchError::Other("Mock error".to_string())))
258            } else {
259                Ok(self.results.clone())
260            }
261        }
262    }
263
264    #[tokio::test]
265    async fn test_web_search_success() {
266        let provider = MockProvider::new("test");
267        let options = SearchOptions {
268            query: "test query".to_string(),
269            provider: Box::new(provider),
270            ..Default::default()
271        };
272
273        let results = web_search(options).await.unwrap();
274        assert_eq!(results.len(), 2);
275        assert_eq!(results[0].title, "Test Result 1");
276        assert_eq!(results[0].url, "https://example.com/1");
277        assert_eq!(results[0].provider, Some("test".to_string()));
278    }
279
280    #[tokio::test]
281    async fn test_web_search_empty_query() {
282        let provider = MockProvider::new("test");
283        let options = SearchOptions {
284            query: "".to_string(),
285            provider: Box::new(provider),
286            ..Default::default()
287        };
288
289        let result = web_search(options).await;
290        assert!(result.is_err());
291        match result.unwrap_err() {
292            SearchError::InvalidInput(msg) => {
293                assert!(msg.contains("search query or ID list"));
294            }
295            _ => panic!("Expected InvalidInput error"),
296        }
297    }
298
299    #[tokio::test]
300    async fn test_web_search_provider_error() {
301        let provider = MockProvider::new("test").with_error(SearchError::HttpError {
302            status_code: Some(401),
303            message: "Unauthorized".to_string(),
304            response_body: None,
305        });
306        let options = SearchOptions {
307            query: "test query".to_string(),
308            provider: Box::new(provider),
309            ..Default::default()
310        };
311
312        let result = web_search(options).await;
313        assert!(result.is_err());
314        match result.unwrap_err() {
315            SearchError::ProviderError(msg) => {
316                assert!(msg.contains("failed"));
317                assert!(msg.contains("authentication issue"));
318            }
319            _ => panic!("Expected ProviderError"),
320        }
321    }
322
323    #[tokio::test]
324    async fn test_troubleshooting_info_http_errors() {
325        let test_cases = vec![
326            (
327                SearchError::HttpError {
328                    status_code: Some(401),
329                    message: "Unauthorized".to_string(),
330                    response_body: None,
331                },
332                "authentication issue",
333            ),
334            (
335                SearchError::HttpError {
336                    status_code: Some(403),
337                    message: "Forbidden".to_string(),
338                    response_body: None,
339                },
340                "authentication issue",
341            ),
342            (
343                SearchError::HttpError {
344                    status_code: Some(400),
345                    message: "Bad Request".to_string(),
346                    response_body: None,
347                },
348                "invalid request parameters",
349            ),
350            (
351                SearchError::HttpError {
352                    status_code: Some(429),
353                    message: "Too Many Requests".to_string(),
354                    response_body: None,
355                },
356                "rate limit",
357            ),
358            (
359                SearchError::HttpError {
360                    status_code: Some(500),
361                    message: "Internal Server Error".to_string(),
362                    response_body: None,
363                },
364                "server issues",
365            ),
366        ];
367
368        for (error, expected_text) in test_cases {
369            let info = get_troubleshooting_info("test", &error);
370            assert!(
371                info.to_lowercase().contains(expected_text),
372                "Expected '{info}' to contain '{expected_text}'"
373            );
374        }
375    }
376
377    #[tokio::test]
378    async fn test_troubleshooting_info_providers() {
379        let providers = vec![
380            ("google", "Google API key"),
381            ("serpapi", "SerpAPI key"),
382            ("brave", "Brave Search API token"),
383            ("searxng", "SearXNG instance URL"),
384            ("duckduckgo", "too many requests"),
385        ];
386
387        let generic_error = SearchError::Other("test error".to_string());
388
389        for (provider, expected_text) in providers {
390            let info = get_troubleshooting_info(provider, &generic_error);
391            assert!(
392                info.contains(expected_text),
393                "Expected troubleshooting for '{provider}' to contain '{expected_text}'"
394            );
395        }
396    }
397
398    #[tokio::test]
399    async fn test_web_search_with_arxiv_id_list() {
400        let provider = MockProvider::new("arxiv");
401        let options = SearchOptions {
402            query: "".to_string(), // Empty query is OK for arxiv with id_list
403            id_list: Some("1234.5678,2345.6789".to_string()),
404            provider: Box::new(provider),
405            ..Default::default()
406        };
407
408        let results = web_search(options).await.unwrap();
409        assert_eq!(results.len(), 2);
410    }
411
412    #[tokio::test]
413    async fn test_web_search_max_results() {
414        let results = vec![
415            SearchResult {
416                title: "Result 1".to_string(),
417                url: "https://example.com/1".to_string(),
418                snippet: Some("Content 1".to_string()),
419                domain: None,
420                published_date: None,
421                provider: Some("test".to_string()),
422                raw: None,
423            },
424            SearchResult {
425                title: "Result 2".to_string(),
426                url: "https://example.com/2".to_string(),
427                snippet: Some("Content 2".to_string()),
428                domain: None,
429                published_date: None,
430                provider: Some("test".to_string()),
431                raw: None,
432            },
433            SearchResult {
434                title: "Result 3".to_string(),
435                url: "https://example.com/3".to_string(),
436                snippet: Some("Content 3".to_string()),
437                domain: None,
438                published_date: None,
439                provider: Some("test".to_string()),
440                raw: None,
441            },
442        ];
443
444        let provider = MockProvider::new("test").with_results(results);
445        let options = SearchOptions {
446            query: "test".to_string(),
447            max_results: Some(2),
448            provider: Box::new(provider),
449            ..Default::default()
450        };
451
452        let search_results = web_search(options).await.unwrap();
453        // Note: MockProvider doesn't actually respect max_results, but real providers should
454        assert!(search_results.len() >= 2);
455    }
456}