use std::collections::HashMap;
use gloo_net::http::Request;
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
use crate::query::{SearchQuery, SearchResult, SearchResults, generate_snippet, score_document};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimpleDocument {
pub url: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lang: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
pub terms: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimpleSearchIndex {
pub version: u32,
pub documents: Vec<SimpleDocument>,
pub index: HashMap<String, Vec<usize>>,
}
impl SimpleSearchIndex {
pub fn empty() -> Self {
Self {
version: 1,
documents: Vec::new(),
index: HashMap::new(),
}
}
pub fn from_json(json: &str) -> Result<Self, String> {
serde_json::from_str(json).map_err(|e| e.to_string())
}
pub fn search(&self, query: &SearchQuery) -> SearchResults {
#[cfg(target_arch = "wasm32")]
let start = js_sys::Date::now();
if query.is_empty() {
return SearchResults::empty(&query.raw);
}
let mut doc_scores: HashMap<usize, f32> = HashMap::new();
for term in &query.terms {
if let Some(postings) = self.index.get(term) {
for &doc_idx in postings {
let doc = &self.documents[doc_idx];
let score = score_document(&query.terms, &doc.title, &doc.terms);
let entry = doc_scores.entry(doc_idx).or_insert(0.0);
*entry = entry.max(score);
}
}
}
let mut scored: Vec<_> = doc_scores.into_iter().collect();
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let results: Vec<SearchResult> = scored
.into_iter()
.take(query.limit)
.map(|(doc_idx, score)| {
let doc = &self.documents[doc_idx];
let snippet = doc
.description
.as_ref()
.and_then(|d| generate_snippet(d, &query.terms, 150));
SearchResult {
url: doc.url.clone(),
title: doc.title.clone(),
description: doc.description.clone(),
score,
snippet,
}
})
.collect();
#[cfg(target_arch = "wasm32")]
let duration_ms = (js_sys::Date::now() - start) as u32;
#[cfg(not(target_arch = "wasm32"))]
let duration_ms = 0u32;
SearchResults {
query: query.raw.clone(),
total: results.len(),
results,
duration_ms,
}
}
pub fn document_count(&self) -> usize {
self.documents.len()
}
pub fn term_count(&self) -> usize {
self.index.len()
}
}
#[wasm_bindgen]
pub struct SimpleSearchEngine {
index: SimpleSearchIndex,
}
#[wasm_bindgen]
impl SimpleSearchEngine {
#[wasm_bindgen(js_name = load)]
pub async fn load(index_url: &str) -> Result<SimpleSearchEngine, JsValue> {
let response = Request::get(index_url)
.send()
.await
.map_err(|e| JsValue::from_str(&format!("Network error: {e}")))?;
if !response.ok() {
return Err(JsValue::from_str(&format!(
"Failed to load index: HTTP {}",
response.status()
)));
}
let json = response
.text()
.await
.map_err(|e| JsValue::from_str(&format!("Failed to read response: {e}")))?;
let index = SimpleSearchIndex::from_json(&json)
.map_err(|e| JsValue::from_str(&format!("Failed to parse index: {e}")))?;
Ok(Self { index })
}
#[wasm_bindgen(js_name = fromJson)]
pub fn from_json(json: &str) -> Result<SimpleSearchEngine, JsValue> {
let index = SimpleSearchIndex::from_json(json)
.map_err(|e| JsValue::from_str(&format!("Failed to parse index: {e}")))?;
Ok(Self { index })
}
pub fn search(&self, query: &str, limit: Option<usize>) -> Result<JsValue, JsValue> {
let limit = limit.unwrap_or(10);
let parsed_query = SearchQuery::parse(query, limit);
let results = self.index.search(&parsed_query);
results.to_js()
}
#[wasm_bindgen(js_name = documentCount)]
pub fn document_count(&self) -> usize {
self.index.document_count()
}
#[wasm_bindgen(js_name = termCount)]
pub fn term_count(&self) -> usize {
self.index.term_count()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_index() -> SimpleSearchIndex {
let documents = vec![
SimpleDocument {
url: "/rust".to_string(),
title: "Learning Rust".to_string(),
description: Some("A guide to Rust programming".to_string()),
lang: Some("en".to_string()),
tags: vec!["rust".to_string()],
date: None,
terms: vec![
"learning".to_string(),
"rust".to_string(),
"programming".to_string(),
],
},
SimpleDocument {
url: "/go".to_string(),
title: "Learning Go".to_string(),
description: Some("A guide to Go programming".to_string()),
lang: Some("en".to_string()),
tags: vec!["go".to_string()],
date: None,
terms: vec![
"learning".to_string(),
"go".to_string(),
"programming".to_string(),
],
},
];
let mut index = HashMap::new();
index.insert("learning".to_string(), vec![0, 1]);
index.insert("rust".to_string(), vec![0]);
index.insert("go".to_string(), vec![1]);
index.insert("programming".to_string(), vec![0, 1]);
SimpleSearchIndex {
version: 1,
documents,
index,
}
}
#[test]
fn test_simple_search() {
let index = create_test_index();
let query = SearchQuery::parse("rust", 10);
let results = index.search(&query);
assert_eq!(results.total, 1);
assert_eq!(results.results[0].url, "/rust");
}
#[test]
fn test_simple_search_multiple_results() {
let index = create_test_index();
let query = SearchQuery::parse("programming", 10);
let results = index.search(&query);
assert_eq!(results.total, 2);
}
#[test]
fn test_simple_search_no_results() {
let index = create_test_index();
let query = SearchQuery::parse("python", 10);
let results = index.search(&query);
assert_eq!(results.total, 0);
}
#[test]
fn test_simple_search_empty_query() {
let index = create_test_index();
let query = SearchQuery::parse("", 10);
let results = index.search(&query);
assert_eq!(results.total, 0);
}
#[test]
fn test_index_from_json() {
let json = r#"{
"version": 1,
"documents": [{
"url": "/test",
"title": "Test",
"terms": ["test"]
}],
"index": {"test": [0]}
}"#;
let index = SimpleSearchIndex::from_json(json).unwrap();
assert_eq!(index.documents.len(), 1);
assert_eq!(index.index.len(), 1);
}
#[test]
fn test_document_and_term_count() {
let index = create_test_index();
assert_eq!(index.document_count(), 2);
assert_eq!(index.term_count(), 4);
}
}