oxibrowser/search/
engine.rs1use serde::Serialize;
4
5#[derive(Debug, Clone, Serialize)]
13pub struct SearchResult {
14 pub title: String,
15 pub url: String,
16 pub snippet: String,
17 pub source: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub extra: Option<GitHubExtra>,
22}
23
24#[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#[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#[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#[async_trait::async_trait]
77pub trait SearchEngine: Send + Sync {
78 fn name(&self) -> &'static str;
80 async fn search(
82 &self,
83 query: &str,
84 max_results: usize,
85 ) -> Result<Vec<SearchResult>, SearchError>;
86}
87
88pub 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
102pub fn url_encode(query: &str) -> String {
108 url::form_urlencoded::byte_serialize(query.as_bytes())
109 .collect::<String>()
110 .replace('+', "%20")
111}
112
113pub 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 _ 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; 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 & bar"), "foo & bar");
161 assert_eq!(decode_html_entities("<tag>"), "<tag>");
162 assert_eq!(decode_html_entities(""hello""), "\"hello\"");
163 }
164
165 #[test]
166 fn test_decode_html_entities_numeric() {
167 assert_eq!(decode_html_entities("A"), "A");
169 assert_eq!(decode_html_entities("🙂"), "🙂");
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 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}