1use super::MessageStore;
4use super::types::*;
5use super::user_handle::UserHandle;
6use anyhow::Result;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11use tokio::sync::RwLock;
12
13pub struct MessageSearch {
15 store: MessageStore,
16 search_index: Arc<RwLock<SearchIndex>>,
18 recent_searches: Arc<RwLock<Vec<SearchQuery>>>,
20}
21
22impl MessageSearch {
23 pub async fn new(store: MessageStore) -> Result<Self> {
25 let search_index = SearchIndex::new();
26
27 Ok(Self {
28 store,
29 search_index: Arc::new(RwLock::new(search_index)),
30 recent_searches: Arc::new(RwLock::new(Vec::new())),
31 })
32 }
33
34 pub async fn search(&self, query: SearchQuery) -> Result<Vec<RichMessage>> {
36 self.add_to_recent(query.clone()).await;
38
39 let filters = self.build_filters(&query);
41
42 let message_ids = self.search_index(&query, &filters).await?;
44
45 let mut messages = Vec::new();
47 for id in message_ids {
48 if let Ok(msg) = self.store.get_message(id).await
49 && self.matches_query(&msg, &query, &filters)
50 {
51 messages.push(msg);
52 }
53 }
54
55 messages.sort_by(|a, b| b.created_at.cmp(&a.created_at));
57
58 messages.truncate(query.limit);
60
61 Ok(messages)
62 }
63
64 pub async fn quick_search(&self, text: String, limit: usize) -> Result<Vec<RichMessage>> {
66 let query = SearchQuery {
67 text: Some(text),
68 from: None,
69 in_channels: None,
70 has_attachments: None,
71 has_reactions: None,
72 is_thread: None,
73 date_range: None,
74 limit,
75 };
76
77 self.search(query).await
78 }
79
80 pub async fn search_channel(
82 &self,
83 channel_id: ChannelId,
84 text: Option<String>,
85 limit: usize,
86 ) -> Result<Vec<RichMessage>> {
87 let query = SearchQuery {
88 text,
89 from: None,
90 in_channels: Some(vec![channel_id]),
91 has_attachments: None,
92 has_reactions: None,
93 is_thread: None,
94 date_range: None,
95 limit,
96 };
97
98 self.search(query).await
99 }
100
101 pub async fn search_from_users(
103 &self,
104 users: Vec<UserHandle>,
105 text: Option<String>,
106 limit: usize,
107 ) -> Result<Vec<RichMessage>> {
108 let query = SearchQuery {
109 text,
110 from: Some(users),
111 in_channels: None,
112 has_attachments: None,
113 has_reactions: None,
114 is_thread: None,
115 date_range: None,
116 limit,
117 };
118
119 self.search(query).await
120 }
121
122 pub async fn search_attachments(
124 &self,
125 mime_type: Option<String>,
126 limit: usize,
127 ) -> Result<Vec<RichMessage>> {
128 let query = SearchQuery {
129 text: mime_type,
130 from: None,
131 in_channels: None,
132 has_attachments: Some(true),
133 has_reactions: None,
134 is_thread: None,
135 date_range: None,
136 limit,
137 };
138
139 self.search(query).await
140 }
141
142 pub async fn regex_search(&self, pattern: &str, limit: usize) -> Result<Vec<RichMessage>> {
144 let regex = Regex::new(pattern)?;
145
146 let query = SearchQuery {
148 text: Some(pattern.to_string()),
149 from: None,
150 in_channels: None,
151 has_attachments: None,
152 has_reactions: None,
153 is_thread: None,
154 date_range: None,
155 limit: limit * 2, };
157
158 let mut messages = self.search(query).await?;
159
160 messages.retain(|msg| match &msg.content {
162 MessageContent::Text(text) => regex.is_match(text),
163 MessageContent::RichText(rich) => regex.is_match(&rich.raw),
164 _ => false,
165 });
166
167 messages.truncate(limit);
168 Ok(messages)
169 }
170
171 pub async fn get_suggestions(&self, partial: &str) -> Result<Vec<SearchSuggestion>> {
173 let mut suggestions = Vec::new();
174
175 let recent = self.recent_searches.read().await;
177 for query in recent.iter() {
178 if let Some(text) = &query.text
179 && text.starts_with(partial)
180 {
181 suggestions.push(SearchSuggestion {
182 text: text.clone(),
183 category: SuggestionCategory::Recent,
184 icon: "🕐".to_string(),
185 });
186 }
187 }
188
189 let index = self.search_index.read().await;
191 for user in &index.known_users {
192 let user_str = user.to_string();
193 if user_str.contains(partial) {
194 suggestions.push(SearchSuggestion {
195 text: format!("from:{}", user_str),
196 category: SuggestionCategory::User,
197 icon: "👤".to_string(),
198 });
199 }
200 }
201
202 for channel in &index.known_channels {
204 suggestions.push(SearchSuggestion {
205 text: format!("in:channel-{}", channel.0),
206 category: SuggestionCategory::Channel,
207 icon: "#".to_string(),
208 });
209 }
210
211 if "has:".starts_with(partial) {
213 suggestions.push(SearchSuggestion {
214 text: "has:attachment".to_string(),
215 category: SuggestionCategory::Filter,
216 icon: "📎".to_string(),
217 });
218 suggestions.push(SearchSuggestion {
219 text: "has:reaction".to_string(),
220 category: SuggestionCategory::Filter,
221 icon: "😊".to_string(),
222 });
223 }
224
225 Ok(suggestions)
226 }
227
228 pub async fn get_recent_searches(&self) -> Vec<SearchQuery> {
230 let recent = self.recent_searches.read().await;
231 recent.clone()
232 }
233
234 pub async fn clear_history(&self) -> Result<()> {
236 let mut recent = self.recent_searches.write().await;
237 recent.clear();
238 Ok(())
239 }
240
241 pub async fn index_message(&self, message: &RichMessage) -> Result<()> {
243 let mut index = self.search_index.write().await;
244 index.add_message(message);
245 Ok(())
246 }
247
248 fn build_filters(&self, query: &SearchQuery) -> SearchFilters {
250 SearchFilters {
251 text_tokens: query
252 .text
253 .as_ref()
254 .map(|t| t.split_whitespace().map(|s| s.to_lowercase()).collect())
255 .unwrap_or_default(),
256 from_users: query.from.clone().unwrap_or_default(),
257 channels: query.in_channels.clone().unwrap_or_default(),
258 has_attachments: query.has_attachments,
259 has_reactions: query.has_reactions,
260 is_thread: query.is_thread,
261 date_range: query.date_range.clone(),
262 }
263 }
264
265 async fn search_index(
267 &self,
268 query: &SearchQuery,
269 _filters: &SearchFilters,
270 ) -> Result<Vec<MessageId>> {
271 let index = self.search_index.read().await;
272
273 let mut results = Vec::new();
274
275 if let Some(text) = &query.text
277 && let Some(msg_ids) = index.text_index.get(&text.to_lowercase())
278 {
279 results.extend(msg_ids.iter().copied());
280 }
281
282 if results.is_empty() && query.text.is_none() {
284 }
287
288 Ok(results)
289 }
290
291 fn matches_query(
293 &self,
294 message: &RichMessage,
295 _query: &SearchQuery,
296 filters: &SearchFilters,
297 ) -> bool {
298 if !filters.text_tokens.is_empty() {
300 let content_text = match &message.content {
301 MessageContent::Text(t) => t.clone(),
302 MessageContent::RichText(r) => r.raw.clone(),
303 _ => String::new(),
304 };
305
306 let content_lower = content_text.to_lowercase();
307 if !filters
308 .text_tokens
309 .iter()
310 .all(|token| content_lower.contains(token))
311 {
312 return false;
313 }
314 }
315
316 if !filters.from_users.is_empty()
318 && !filters.from_users.contains(&message.sender)
319 {
320 return false;
321 }
322
323 if !filters.channels.is_empty() && !filters.channels.contains(&message.channel_id) {
325 return false;
326 }
327
328 if let Some(has_attach) = filters.has_attachments
330 && has_attach == message.attachments.is_empty()
331 {
332 return false;
333 }
334
335 if let Some(has_react) = filters.has_reactions
337 && has_react == message.reactions.is_empty()
338 {
339 return false;
340 }
341
342 if let Some(is_thread) = filters.is_thread
344 && is_thread != message.thread_id.is_some()
345 {
346 return false;
347 }
348
349 if let Some(range) = &filters.date_range
351 && (message.created_at < range.start || message.created_at > range.end)
352 {
353 return false;
354 }
355
356 true
357 }
358
359 async fn add_to_recent(&self, query: SearchQuery) {
361 let mut recent = self.recent_searches.write().await;
362
363 recent.retain(|q| q.text != query.text);
365
366 recent.insert(0, query);
368
369 recent.truncate(10);
371 }
372}
373
374struct SearchIndex {
376 text_index: HashMap<String, HashSet<MessageId>>,
378 known_users: HashSet<UserHandle>,
380 known_channels: HashSet<ChannelId>,
382}
383
384impl SearchIndex {
385 fn new() -> Self {
386 Self {
387 text_index: HashMap::new(),
388 known_users: HashSet::new(),
389 known_channels: HashSet::new(),
390 }
391 }
392
393 fn add_message(&mut self, message: &RichMessage) {
394 let text = match &message.content {
396 MessageContent::Text(t) => t.clone(),
397 MessageContent::RichText(r) => r.raw.clone(),
398 _ => String::new(),
399 };
400
401 for word in text.split_whitespace() {
403 let token = word.to_lowercase();
404 self.text_index.entry(token).or_default().insert(message.id);
405 }
406
407 self.known_users.insert(message.sender.clone());
409 self.known_channels.insert(message.channel_id);
410 }
411}
412
413struct SearchFilters {
415 text_tokens: Vec<String>,
416 from_users: Vec<UserHandle>,
417 channels: Vec<ChannelId>,
418 has_attachments: Option<bool>,
419 has_reactions: Option<bool>,
420 is_thread: Option<bool>,
421 date_range: Option<DateRange>,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct SearchSuggestion {
427 pub text: String,
428 pub category: SuggestionCategory,
429 pub icon: String,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
434pub enum SuggestionCategory {
435 Recent,
436 User,
437 Channel,
438 Filter,
439 Command,
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[tokio::test]
447 async fn test_search_creation() {
448 #[allow(unused)]
449 let store = super::super::database::DatabaseMessageStore::new(
450 super::super::DhtClient::new_mock(),
451 None,
452 )
453 .await
454 .unwrap();
455 let search = MessageSearch::new(store).await.unwrap();
456
457 let recent = search.get_recent_searches().await;
458 assert_eq!(recent.len(), 0);
459 }
460
461 #[tokio::test]
462 async fn test_quick_search() {
463 #[allow(unused)]
464 let store = super::super::database::DatabaseMessageStore::new(
465 super::super::DhtClient::new_mock(),
466 None,
467 )
468 .await
469 .unwrap();
470 let search = MessageSearch::new(store).await.unwrap();
471
472 let results = search.quick_search("test".to_string(), 10).await.unwrap();
473 assert_eq!(results.len(), 0); }
475
476 #[tokio::test]
477 async fn test_search_suggestions() {
478 #[allow(unused)]
479 let store = super::super::database::DatabaseMessageStore::new(
480 super::super::DhtClient::new_mock(),
481 None,
482 )
483 .await
484 .unwrap();
485 let search = MessageSearch::new(store).await.unwrap();
486
487 let suggestions = search.get_suggestions("has").await.unwrap();
488 assert!(suggestions.iter().any(|s| s.text == "has:attachment"));
489 assert!(suggestions.iter().any(|s| s.text == "has:reaction"));
490 }
491}