use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
#[derive(Debug, Clone)]
pub struct SearchQuery {
pub raw: String,
pub terms: Vec<String>,
pub limit: usize,
}
impl SearchQuery {
pub fn parse(query: &str, limit: usize) -> Self {
let terms = tokenize_query(query);
Self {
raw: query.to_string(),
terms,
limit,
}
}
pub fn is_empty(&self) -> bool {
self.terms.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub url: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub score: f32,
#[serde(skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResults {
pub query: String,
pub total: usize,
pub results: Vec<SearchResult>,
pub duration_ms: u32,
}
impl SearchResults {
pub fn empty(query: &str) -> Self {
Self {
query: query.to_string(),
total: 0,
results: Vec::new(),
duration_ms: 0,
}
}
pub fn to_js(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(self).map_err(|e| JsValue::from_str(&e.to_string()))
}
}
fn tokenize_query(query: &str) -> Vec<String> {
query
.split(|c: char| !c.is_alphanumeric())
.filter(|s| s.len() >= 2) .map(|s| s.to_lowercase())
.collect()
}
pub fn score_document(query_terms: &[String], title: &str, body_terms: &[String]) -> f32 {
let title_lower = title.to_lowercase();
let title_terms: Vec<String> = tokenize_query(&title_lower);
let mut score = 0.0f32;
for query_term in query_terms {
for title_term in &title_terms {
if title_term.contains(query_term) {
score += 10.0;
}
if title_term == query_term {
score += 5.0; }
}
for body_term in body_terms {
if body_term == query_term {
score += 1.0;
} else if body_term.contains(query_term) {
score += 0.5;
}
}
}
score
}
pub fn generate_snippet(text: &str, query_terms: &[String], max_length: usize) -> Option<String> {
if text.is_empty() || query_terms.is_empty() {
return None;
}
let text_lower = text.to_lowercase();
let mut best_pos = None;
for term in query_terms {
if let Some(pos) = text_lower.find(term) {
match best_pos {
None => best_pos = Some(pos),
Some(current) if pos < current => best_pos = Some(pos),
_ => {}
}
}
}
let start_pos = best_pos.unwrap_or(0);
let snippet_start = if start_pos > 50 {
text[..start_pos]
.rfind(char::is_whitespace)
.map(|p| p + 1)
.unwrap_or(start_pos.saturating_sub(50))
} else {
0
};
let snippet_end = (snippet_start + max_length).min(text.len());
let snippet_end = text[..snippet_end]
.rfind(char::is_whitespace)
.unwrap_or(snippet_end);
let mut snippet = text[snippet_start..snippet_end].to_string();
if snippet_start > 0 {
snippet = format!("...{}", snippet.trim_start());
}
if snippet_end < text.len() {
snippet = format!("{}...", snippet.trim_end());
}
Some(snippet)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_query() {
let query = SearchQuery::parse("hello world", 10);
assert_eq!(query.terms, vec!["hello", "world"]);
assert_eq!(query.limit, 10);
}
#[test]
fn test_parse_query_filters_short() {
let query = SearchQuery::parse("a test b query c", 10);
assert_eq!(query.terms, vec!["test", "query"]);
}
#[test]
fn test_empty_query() {
let query = SearchQuery::parse("", 10);
assert!(query.is_empty());
let query = SearchQuery::parse("a b c", 10);
assert!(query.is_empty()); }
#[test]
fn test_score_document() {
let query_terms = vec!["rust".to_string()];
let body_terms = vec!["rust".to_string(), "programming".to_string()];
let score_with_title = score_document(&query_terms, "Learning Rust", &body_terms);
let score_without_title = score_document(&query_terms, "Programming Guide", &body_terms);
assert!(score_with_title > score_without_title);
}
#[test]
fn test_generate_snippet() {
let text = "Rust is a systems programming language. It provides memory safety without garbage collection.";
let terms = vec!["rust".to_string()];
let snippet = generate_snippet(text, &terms, 50);
assert!(snippet.is_some());
assert!(snippet.unwrap().to_lowercase().contains("rust"));
}
#[test]
fn test_search_results_empty() {
let results = SearchResults::empty("test");
assert_eq!(results.total, 0);
assert!(results.results.is_empty());
}
#[test]
fn test_search_result_serialization() {
let result = SearchResult {
url: "/test".to_string(),
title: "Test Page".to_string(),
description: Some("A test page".to_string()),
score: 10.5,
snippet: None,
};
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("Test Page"));
assert!(json.contains("10.5"));
}
}