saorsa_core/messaging/
search.rs

1// Message search and filtering capabilities
2
3use 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
13/// Message search engine with advanced filtering
14pub struct MessageSearch {
15    store: MessageStore,
16    /// Search index cache
17    search_index: Arc<RwLock<SearchIndex>>,
18    /// Recent searches for quick access
19    recent_searches: Arc<RwLock<Vec<SearchQuery>>>,
20}
21
22impl MessageSearch {
23    /// Create new search engine
24    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    /// Search messages with query
35    pub async fn search(&self, query: SearchQuery) -> Result<Vec<RichMessage>> {
36        // Add to recent searches
37        self.add_to_recent(query.clone()).await;
38
39        // Build search filters
40        let filters = self.build_filters(&query);
41
42        // Search in index first
43        let message_ids = self.search_index(&query, &filters).await?;
44
45        // Fetch full messages
46        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        // Sort by relevance and date
56        messages.sort_by(|a, b| b.created_at.cmp(&a.created_at));
57
58        // Apply limit
59        messages.truncate(query.limit);
60
61        Ok(messages)
62    }
63
64    /// Quick search with just text
65    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    /// Search within a specific channel
81    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    /// Search messages from specific users
102    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    /// Search for messages with attachments
123    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    /// Advanced search with regex
143    pub async fn regex_search(&self, pattern: &str, limit: usize) -> Result<Vec<RichMessage>> {
144        let regex = Regex::new(pattern)?;
145
146        // Search with pattern
147        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, // Get more for regex filtering
156        };
157
158        let mut messages = self.search(query).await?;
159
160        // Filter with regex
161        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    /// Get search suggestions based on partial input
172    pub async fn get_suggestions(&self, partial: &str) -> Result<Vec<SearchSuggestion>> {
173        let mut suggestions = Vec::new();
174
175        // Suggest recent searches
176        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        // Suggest users (from index)
190        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        // Suggest channels
203        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        // Common search operators
212        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    /// Get recent searches
229    pub async fn get_recent_searches(&self) -> Vec<SearchQuery> {
230        let recent = self.recent_searches.read().await;
231        recent.clone()
232    }
233
234    /// Clear search history
235    pub async fn clear_history(&self) -> Result<()> {
236        let mut recent = self.recent_searches.write().await;
237        recent.clear();
238        Ok(())
239    }
240
241    /// Update search index with new message
242    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    /// Build search filters from query
249    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    /// Search in index
266    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        // Text search in index
276        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 no text query, get all messages for filtering
283        if results.is_empty() && query.text.is_none() {
284            // In production, this would query from storage with filters
285            // For now, return empty
286        }
287
288        Ok(results)
289    }
290
291    /// Check if message matches query
292    fn matches_query(
293        &self,
294        message: &RichMessage,
295        _query: &SearchQuery,
296        filters: &SearchFilters,
297    ) -> bool {
298        // Check text match
299        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        // Check sender
317        if !filters.from_users.is_empty()
318            && !filters.from_users.contains(&message.sender)
319        {
320            return false;
321        }
322
323        // Check channel
324        if !filters.channels.is_empty() && !filters.channels.contains(&message.channel_id) {
325            return false;
326        }
327
328        // Check attachments
329        if let Some(has_attach) = filters.has_attachments
330            && has_attach == message.attachments.is_empty()
331        {
332            return false;
333        }
334
335        // Check reactions
336        if let Some(has_react) = filters.has_reactions
337            && has_react == message.reactions.is_empty()
338        {
339            return false;
340        }
341
342        // Check thread
343        if let Some(is_thread) = filters.is_thread
344            && is_thread != message.thread_id.is_some()
345        {
346            return false;
347        }
348
349        // Check date range
350        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    /// Add to recent searches
360    async fn add_to_recent(&self, query: SearchQuery) {
361        let mut recent = self.recent_searches.write().await;
362
363        // Remove duplicates
364        recent.retain(|q| q.text != query.text);
365
366        // Add to front
367        recent.insert(0, query);
368
369        // Keep only last 10
370        recent.truncate(10);
371    }
372}
373
374/// Search index for fast lookups
375struct SearchIndex {
376    /// Text to message IDs mapping
377    text_index: HashMap<String, HashSet<MessageId>>,
378    /// Known users for suggestions
379    known_users: HashSet<UserHandle>,
380    /// Known channels for suggestions
381    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        // Index text content
395        let text = match &message.content {
396            MessageContent::Text(t) => t.clone(),
397            MessageContent::RichText(r) => r.raw.clone(),
398            _ => String::new(),
399        };
400
401        // Tokenize and index
402        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        // Track users and channels
408        self.known_users.insert(message.sender.clone());
409        self.known_channels.insert(message.channel_id);
410    }
411}
412
413/// Search filters
414struct 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/// Search suggestion
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct SearchSuggestion {
427    pub text: String,
428    pub category: SuggestionCategory,
429    pub icon: String,
430}
431
432/// Suggestion category
433#[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); // No messages indexed yet
474    }
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}