Skip to main content

oxibrowser/search/
engine.rs

1//! Core types: SearchResult, SearchError, SearchEngine trait, shared client builder.
2
3use serde::Serialize;
4
5// ---------------------------------------------------------------------------
6// SearchResult — unified result type across all engines
7// ---------------------------------------------------------------------------
8
9/// A single search result from any engine.
10///
11/// GitHub sources include an optional `extra` field with repo metadata.
12#[derive(Debug, Clone, Serialize)]
13pub struct SearchResult {
14    pub title: String,
15    pub url: String,
16    pub snippet: String,
17    /// Human-readable source label, e.g. "DuckDuckGo", "Wikipedia", "GitHub".
18    pub source: String,
19    /// GitHub-specific metadata (stars, forks, language, topics).
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub extra: Option<GitHubExtra>,
22}
23
24/// GitHub repository metadata attached to SearchResult when source is GitHub.
25#[derive(Debug, Clone, Serialize)]
26pub struct GitHubExtra {
27    pub stars: u64,
28    pub forks: u64,
29    pub language: Option<String>,
30    pub topics: Vec<String>,
31    pub updated_at: String,
32}
33
34// ---------------------------------------------------------------------------
35// SearchOutput — top-level data container
36// ---------------------------------------------------------------------------
37
38#[derive(Debug, Clone, Serialize)]
39pub struct SearchOutput {
40    pub query: String,
41    pub source: String,
42    pub engine: String,
43    pub total_results: usize,
44    pub results: Vec<SearchResult>,
45}
46
47// ---------------------------------------------------------------------------
48// SearchError
49// ---------------------------------------------------------------------------
50
51#[derive(Debug)]
52pub enum SearchError {
53    Network(String),
54    Parse(String),
55    RateLimited(String),
56    Captcha(String),
57}
58
59impl std::fmt::Display for SearchError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::Network(msg) => write!(f, "network error: {msg}"),
63            Self::Parse(msg) => write!(f, "parse error: {msg}"),
64            Self::RateLimited(msg) => write!(f, "rate limited: {msg}"),
65            Self::Captcha(msg) => write!(f, "captcha blocked: {msg}"),
66        }
67    }
68}
69
70impl std::error::Error for SearchError {}
71
72// ---------------------------------------------------------------------------
73// SearchEngine trait
74// ---------------------------------------------------------------------------
75
76#[async_trait::async_trait]
77pub trait SearchEngine: Send + Sync {
78    /// Human-readable engine name (e.g. "DuckDuckGo", "Wikipedia", "Bing").
79    fn name(&self) -> &'static str;
80    /// Execute a search query and return up to max_results results.
81    async fn search(
82        &self,
83        query: &str,
84        max_results: usize,
85    ) -> Result<Vec<SearchResult>, SearchError>;
86}
87
88// ---------------------------------------------------------------------------
89// Shared client builder
90// ---------------------------------------------------------------------------
91
92/// Build a lightweight reqwest::Client for search (no SSRF filter, no cookies).
93pub fn build_search_client(timeout_secs: u64) -> reqwest::Client {
94    reqwest::Client::builder()
95        .timeout(std::time::Duration::from_secs(timeout_secs))
96        .user_agent(format!("oxibrowser/{}", env!("CARGO_PKG_VERSION")))
97        .https_only(true)
98        .build()
99        .expect("reqwest::Client::builder() failed")
100}
101
102// ---------------------------------------------------------------------------
103// Helpers
104// ---------------------------------------------------------------------------
105
106/// Percent-encode a query string for URL inclusion.
107pub fn url_encode(query: &str) -> String {
108    url::form_urlencoded::byte_serialize(query.as_bytes())
109        .collect::<String>()
110        .replace('+', "%20")
111}
112
113/// Simple HTML entity decoder for common entities.
114pub fn decode_html_entities(text: &str) -> String {
115    let mut result = String::with_capacity(text.len());
116    let bytes = text.as_bytes();
117    let mut i = 0;
118    while i < bytes.len() {
119        if bytes[i] == b'&'
120            && let Some(end) = bytes[i..].iter().position(|&b| b == b';')
121        {
122            let entity = &text[i + 1..i + end];
123            let ch = match entity {
124                "amp" => Some('&'),
125                "lt" => Some('<'),
126                "gt" => Some('>'),
127                "quot" => Some('"'),
128                "apos" => Some('\''),
129                // Numeric entities
130                _ if entity.starts_with('#') => {
131                    let num = &entity[1..];
132                    if let Ok(code) = num.parse::<u32>() {
133                        char::from_u32(code)
134                    } else if let Ok(code) = num.parse::<u32>() {
135                        char::from_u32(code)
136                    } else {
137                        None
138                    }
139                }
140                _ => None,
141            };
142            if let Some(c) = ch {
143                result.push(c);
144                i += end + 1; // skip past closing ;
145                continue;
146            }
147        }
148        result.push(bytes[i] as char);
149        i += 1;
150    }
151    result
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_decode_html_entities_basic() {
160        assert_eq!(decode_html_entities("foo &amp; bar"), "foo & bar");
161        assert_eq!(decode_html_entities("&lt;tag&gt;"), "<tag>");
162        assert_eq!(decode_html_entities("&quot;hello&quot;"), "\"hello\"");
163    }
164
165    #[test]
166    fn test_decode_html_entities_numeric() {
167        // 'A' is code point 65
168        assert_eq!(decode_html_entities("&#65;"), "A");
169        // 🙂 is U+1F642
170        assert_eq!(decode_html_entities("&#128578;"), "🙂");
171    }
172
173    #[test]
174    fn test_decode_html_entities_no_entity() {
175        assert_eq!(decode_html_entities("hello world"), "hello world");
176    }
177
178    #[test]
179    fn test_decode_html_entities_partial() {
180        // Invalid entity — leave as-is
181        let result = decode_html_entities("foo &bar; baz");
182        assert_eq!(result, "foo &bar; baz");
183    }
184
185    #[test]
186    fn test_url_encode_simple() {
187        assert_eq!(url_encode("hello world"), "hello%20world");
188        assert_eq!(url_encode("rust"), "rust");
189    }
190
191    #[test]
192    fn test_url_encode_special() {
193        assert_eq!(url_encode("a+b"), "a%2Bb");
194    }
195}