use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct SearchResult {
pub title: String,
pub url: String,
pub snippet: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<GitHubExtra>,
}
#[derive(Debug, Clone, Serialize)]
pub struct GitHubExtra {
pub stars: u64,
pub forks: u64,
pub language: Option<String>,
pub topics: Vec<String>,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchOutput {
pub query: String,
pub source: String,
pub engine: String,
pub total_results: usize,
pub results: Vec<SearchResult>,
}
#[derive(Debug)]
pub enum SearchError {
Network(String),
Parse(String),
RateLimited(String),
Captcha(String),
}
impl std::fmt::Display for SearchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Network(msg) => write!(f, "network error: {msg}"),
Self::Parse(msg) => write!(f, "parse error: {msg}"),
Self::RateLimited(msg) => write!(f, "rate limited: {msg}"),
Self::Captcha(msg) => write!(f, "captcha blocked: {msg}"),
}
}
}
impl std::error::Error for SearchError {}
#[async_trait::async_trait]
pub trait SearchEngine: Send + Sync {
fn name(&self) -> &'static str;
async fn search(&self, query: &str, max_results: usize) -> Result<Vec<SearchResult>, SearchError>;
}
pub fn build_search_client(timeout_secs: u64) -> reqwest::Client {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.user_agent(&format!("oxibrowser/{}", env!("CARGO_PKG_VERSION")))
.https_only(true)
.build()
.expect("reqwest::Client::builder() failed")
}
pub fn url_encode(query: &str) -> String {
url::form_urlencoded::byte_serialize(query.as_bytes())
.collect::<String>()
.replace('+', "%20")
}
pub fn decode_html_entities(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let bytes = text.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'&' {
if let Some(end) = bytes[i..].iter().position(|&b| b == b';') {
let entity = &text[i + 1..i + end];
let ch = match entity {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
_ if entity.starts_with('#') => {
let num = &entity[1..];
if let Ok(code) = num.parse::<u32>() {
char::from_u32(code)
} else if let Ok(code) = num.parse::<u32>() {
char::from_u32(code)
} else {
None
}
}
_ => None,
};
if let Some(c) = ch {
result.push(c);
i += end + 1; continue;
}
}
}
result.push(bytes[i] as char);
i += 1;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_decode_html_entities_basic() {
assert_eq!(decode_html_entities("foo & bar"), "foo & bar");
assert_eq!(decode_html_entities("<tag>"), "<tag>");
assert_eq!(decode_html_entities(""hello""), "\"hello\"");
}
#[test]
fn test_decode_html_entities_numeric() {
assert_eq!(decode_html_entities("A"), "A");
assert_eq!(decode_html_entities("🙂"), "🙂");
}
#[test]
fn test_decode_html_entities_no_entity() {
assert_eq!(decode_html_entities("hello world"), "hello world");
}
#[test]
fn test_decode_html_entities_partial() {
let result = decode_html_entities("foo &bar; baz");
assert_eq!(result, "foo &bar; baz");
}
#[test]
fn test_url_encode_simple() {
assert_eq!(url_encode("hello world"), "hello%20world");
assert_eq!(url_encode("rust"), "rust");
}
#[test]
fn test_url_encode_special() {
assert_eq!(url_encode("a+b"), "a%2Bb");
}
}