use super::content::{HelpContent, HelpTopic};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct SearchResult {
pub topic: HelpTopic,
pub score: f32,
pub matched_keywords: Vec<String>,
pub excerpt: String,
}
#[derive(Debug)]
pub struct HelpSearchEngine {
content: HelpContent,
index: HashMap<String, Vec<String>>,
}
impl HelpSearchEngine {
pub fn new(content: HelpContent) -> Self {
let mut engine = Self {
content,
index: HashMap::new(),
};
engine.build_index();
engine
}
fn build_index(&mut self) {
for topic in self.content.all_topics() {
for word in Self::tokenize(&topic.title) {
self.index
.entry(word.to_lowercase())
.or_default()
.push(topic.id.clone());
}
for word in Self::tokenize(&topic.content) {
self.index
.entry(word.to_lowercase())
.or_default()
.push(topic.id.clone());
}
for keyword in &topic.keywords {
self.index
.entry(keyword.to_lowercase())
.or_default()
.push(topic.id.clone());
}
}
}
fn tokenize(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|s| !s.is_empty() && s.len() > 2) .map(|s| s.to_string())
.collect()
}
pub fn search(&self, query: &str) -> Vec<SearchResult> {
if query.trim().is_empty() {
return Vec::new();
}
let query_terms = Self::tokenize(&query.to_lowercase());
let mut topic_scores: HashMap<String, (f32, Vec<String>)> = HashMap::new();
for term in &query_terms {
if let Some(topic_ids) = self.index.get(term) {
for topic_id in topic_ids {
let entry = topic_scores
.entry(topic_id.clone())
.or_insert((0.0, Vec::new()));
entry.0 += 1.0; entry.1.push(term.clone());
}
}
for (indexed_term, topic_ids) in &self.index {
if indexed_term.starts_with(term) && indexed_term != term {
for topic_id in topic_ids {
let entry = topic_scores
.entry(topic_id.clone())
.or_insert((0.0, Vec::new()));
entry.0 += 0.5; entry.1.push(indexed_term.clone());
}
}
}
}
let mut results: Vec<SearchResult> = topic_scores
.into_iter()
.filter_map(|(topic_id, (score, matched_keywords))| {
self.content.get_topic(&topic_id).map(|topic| {
let normalized_score = score / query_terms.len() as f32;
let excerpt = self.extract_excerpt(&topic.content, &query_terms);
SearchResult {
topic: topic.clone(),
score: normalized_score.min(1.0),
matched_keywords,
excerpt,
}
})
})
.collect();
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}
fn extract_excerpt(&self, content: &str, query_terms: &[String]) -> String {
const EXCERPT_LENGTH: usize = 150;
let content_lower = content.to_lowercase();
let mut first_match_pos = None;
for term in query_terms {
if let Some(pos) = content_lower.find(term) {
if first_match_pos.is_none() || pos < first_match_pos.unwrap() {
first_match_pos = Some(pos);
}
}
}
let start = if let Some(pos) = first_match_pos {
pos.saturating_sub(30)
} else {
0
};
let end = (start + EXCERPT_LENGTH).min(content.len());
let excerpt_start = content[..start]
.rfind(char::is_whitespace)
.map(|p| p + 1)
.unwrap_or(start);
let excerpt_end = content[end..]
.find(char::is_whitespace)
.map(|p| end + p)
.unwrap_or(end);
let mut excerpt = content[excerpt_start..excerpt_end].trim().to_string();
if excerpt_start > 0 {
excerpt = format!("...{}", excerpt);
}
if excerpt_end < content.len() {
excerpt = format!("{}...", excerpt);
}
excerpt
}
pub fn suggest(&self, partial: &str) -> Vec<String> {
if partial.len() < 2 {
return Vec::new();
}
let partial_lower = partial.to_lowercase();
let mut suggestions: Vec<String> = self
.index
.keys()
.filter(|k| k.starts_with(&partial_lower))
.take(10)
.map(|s| s.to_string())
.collect();
suggestions.sort();
suggestions.dedup();
suggestions
}
pub fn content(&self) -> &HelpContent {
&self.content
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokenize() {
let tokens = HelpSearchEngine::tokenize("Hello, world! This is a test.");
assert!(tokens.contains(&"Hello".to_string()));
assert!(tokens.contains(&"world".to_string()));
assert!(tokens.contains(&"test".to_string()));
}
#[test]
fn test_search() {
let content = HelpContent::new();
let engine = HelpSearchEngine::new(content);
let results = engine.search("workflow");
assert!(!results.is_empty());
}
#[test]
fn test_search_case_insensitive() {
let content = HelpContent::new();
let engine = HelpSearchEngine::new(content);
let results1 = engine.search("WORKFLOW");
let results2 = engine.search("workflow");
assert_eq!(results1.len(), results2.len());
}
#[test]
fn test_suggest() {
let content = HelpContent::new();
let engine = HelpSearchEngine::new(content);
let suggestions = engine.suggest("work");
assert!(!suggestions.is_empty());
}
#[test]
fn test_empty_query() {
let content = HelpContent::new();
let engine = HelpSearchEngine::new(content);
let results = engine.search("");
assert!(results.is_empty());
}
}