Skip to main content

mxr_search/
lib.rs

1pub mod ast;
2mod index;
3pub mod parser;
4pub mod query_builder;
5mod saved;
6mod schema;
7
8pub use ast::*;
9pub use index::{SearchIndex, SearchResult};
10pub use parser::{parse_query, ParseError};
11pub use query_builder::QueryBuilder;
12pub use saved::SavedSearchService;
13pub use schema::MxrSchema;
14
15#[cfg(test)]
16mod tests {
17    use super::*;
18    use mxr_core::id::*;
19    use mxr_core::types::*;
20
21    fn make_envelope(subject: &str, snippet: &str, from_name: &str) -> Envelope {
22        make_envelope_full(
23            subject,
24            snippet,
25            from_name,
26            "test@example.com",
27            MessageFlags::READ,
28            false,
29        )
30    }
31
32    fn make_envelope_full(
33        subject: &str,
34        snippet: &str,
35        from_name: &str,
36        from_email: &str,
37        flags: MessageFlags,
38        has_attachments: bool,
39    ) -> Envelope {
40        Envelope {
41            id: MessageId::new(),
42            account_id: AccountId::new(),
43            provider_id: format!("fake-{}", subject.len()),
44            thread_id: ThreadId::new(),
45            message_id_header: None,
46            in_reply_to: None,
47            references: vec![],
48            from: Address {
49                name: Some(from_name.to_string()),
50                email: from_email.to_string(),
51            },
52            to: vec![Address {
53                name: None,
54                email: "recipient@example.com".to_string(),
55            }],
56            cc: vec![Address {
57                name: None,
58                email: "team@example.com".to_string(),
59            }],
60            bcc: vec![Address {
61                name: None,
62                email: "hidden@example.com".to_string(),
63            }],
64            subject: subject.to_string(),
65            date: chrono::Utc::now(),
66            flags,
67            snippet: snippet.to_string(),
68            has_attachments,
69            size_bytes: 1000,
70            unsubscribe: UnsubscribeMethod::None,
71            label_provider_ids: vec!["notifications".to_string()],
72        }
73    }
74
75    #[test]
76    fn search_by_subject_keyword() {
77        let mut idx = SearchIndex::in_memory().unwrap();
78        let subjects = [
79            "Deployment plan for v2.3",
80            "Q1 Report review",
81            "This Week in Rust #580",
82            "Invoice #2847",
83            "Team standup notes",
84            "Summer trip planning",
85            "PR review: fix auth",
86            "HN Weekly Digest",
87            "RustConf 2026 invite",
88            "CI pipeline failures",
89        ];
90        let mut target_id = String::new();
91        for (i, subj) in subjects.iter().enumerate() {
92            let env = make_envelope(subj, &format!("Snippet for msg {}", i), "Alice");
93            if i == 0 {
94                target_id = env.id.as_str();
95            }
96            idx.index_envelope(&env).unwrap();
97        }
98        idx.commit().unwrap();
99
100        let results = idx.search("deployment", 10).unwrap();
101        assert_eq!(results.len(), 1);
102        assert_eq!(results[0].message_id, target_id);
103    }
104
105    #[test]
106    fn field_boost_ranking() {
107        let mut idx = SearchIndex::in_memory().unwrap();
108
109        let env_subject = make_envelope("Critical deployment issue", "Nothing special here", "Bob");
110        let env_snippet = make_envelope("Regular update", "The deployment went well", "Carol");
111
112        let subject_id = env_subject.id.as_str();
113
114        idx.index_envelope(&env_subject).unwrap();
115        idx.index_envelope(&env_snippet).unwrap();
116        idx.commit().unwrap();
117
118        let results = idx.search("deployment", 10).unwrap();
119        assert_eq!(results.len(), 2);
120        // Subject match should rank higher due to 3.0 boost vs 1.0 snippet
121        assert_eq!(results[0].message_id, subject_id);
122    }
123
124    #[test]
125    fn body_indexing() {
126        let mut idx = SearchIndex::in_memory().unwrap();
127
128        let env = make_envelope("Meeting notes", "Quick summary", "Alice");
129        let env_id = env.id.as_str();
130
131        idx.index_envelope(&env).unwrap();
132        idx.commit().unwrap();
133
134        // Search for body-only keyword should find nothing yet
135        let results = idx.search("canary", 10).unwrap();
136        assert_eq!(results.len(), 0);
137
138        // Now index with body
139        let body = MessageBody {
140            message_id: env.id.clone(),
141            text_plain: Some("Deploy canary to 5% of traffic first".to_string()),
142            text_html: None,
143            attachments: vec![],
144            fetched_at: chrono::Utc::now(),
145            metadata: MessageMetadata::default(),
146        };
147        idx.index_body(&env, &body).unwrap();
148        idx.commit().unwrap();
149
150        let results = idx.search("canary", 10).unwrap();
151        assert_eq!(results.len(), 1);
152        assert_eq!(results[0].message_id, env_id);
153    }
154
155    #[test]
156    fn remove_document() {
157        let mut idx = SearchIndex::in_memory().unwrap();
158
159        let env = make_envelope("Remove me", "This should be gone", "Alice");
160        idx.index_envelope(&env).unwrap();
161        idx.commit().unwrap();
162
163        let results = idx.search("remove", 10).unwrap();
164        assert_eq!(results.len(), 1);
165
166        idx.remove_document(&env.id);
167        idx.commit().unwrap();
168
169        let results = idx.search("remove", 10).unwrap();
170        assert_eq!(results.len(), 0);
171    }
172
173    #[test]
174    fn empty_search() {
175        let idx = SearchIndex::in_memory().unwrap();
176        let results = idx.search("nonexistent", 10).unwrap();
177        assert!(results.is_empty());
178    }
179
180    // -- E2E: parse → build → search integration tests --
181
182    fn build_e2e_index() -> (SearchIndex, Vec<Envelope>) {
183        let mut idx = SearchIndex::in_memory().unwrap();
184        let envelopes = vec![
185            make_envelope_full(
186                "Deployment plan for v2",
187                "Rolling out to prod",
188                "Alice",
189                "alice@example.com",
190                MessageFlags::empty(), // unread
191                false,
192            ),
193            make_envelope_full(
194                "Invoice #2847",
195                "Payment due next week",
196                "Bob",
197                "bob@example.com",
198                MessageFlags::READ | MessageFlags::STARRED,
199                true, // has attachment
200            ),
201            make_envelope_full(
202                "Team standup notes",
203                "Sprint review action items",
204                "Carol",
205                "carol@example.com",
206                MessageFlags::READ,
207                false,
208            ),
209            make_envelope_full(
210                "CI pipeline failures",
211                "Build broken on main",
212                "Alice",
213                "alice@example.com",
214                MessageFlags::empty(), // unread
215                true,                  // has attachment
216            ),
217        ];
218        for env in &envelopes {
219            idx.index_envelope(env).unwrap();
220        }
221        idx.commit().unwrap();
222        (idx, envelopes)
223    }
224
225    fn e2e_search(idx: &SearchIndex, query_str: &str) -> Vec<String> {
226        let ast = parser::parse_query(query_str).unwrap();
227        let schema = MxrSchema::build();
228        let qb = QueryBuilder::new(&schema);
229        let query = qb.build(&ast);
230        idx.search_ast(query, 10)
231            .unwrap()
232            .into_iter()
233            .map(|r| r.message_id)
234            .collect()
235    }
236
237    #[test]
238    fn e2e_parse_build_search_text() {
239        let (idx, envelopes) = build_e2e_index();
240        let results = e2e_search(&idx, "deployment");
241        assert_eq!(results.len(), 1);
242        assert_eq!(results[0], envelopes[0].id.as_str());
243    }
244
245    #[test]
246    fn e2e_parse_build_search_field() {
247        let (idx, envelopes) = build_e2e_index();
248        let results = e2e_search(&idx, "from:alice@example.com");
249        assert_eq!(results.len(), 2);
250        let alice_ids: Vec<String> = vec![
251            envelopes[0].id.as_str().to_string(),
252            envelopes[3].id.as_str().to_string(),
253        ];
254        for id in &results {
255            assert!(alice_ids.contains(id));
256        }
257    }
258
259    #[test]
260    fn e2e_parse_build_search_compound() {
261        let (idx, envelopes) = build_e2e_index();
262        // from:alice AND is:unread — both alice messages are unread
263        let results = e2e_search(&idx, "from:alice@example.com is:unread");
264        assert_eq!(results.len(), 2);
265        let alice_ids: Vec<String> = vec![
266            envelopes[0].id.as_str().to_string(),
267            envelopes[3].id.as_str().to_string(),
268        ];
269        for id in &results {
270            assert!(alice_ids.contains(id));
271        }
272    }
273
274    #[test]
275    fn e2e_parse_build_search_negation() {
276        let (idx, _envelopes) = build_e2e_index();
277        // -is:read = unread messages (alice's two)
278        let results = e2e_search(&idx, "-is:read");
279        assert_eq!(results.len(), 2);
280    }
281
282    #[test]
283    fn e2e_filter_has_attachment() {
284        let (idx, envelopes) = build_e2e_index();
285        let results = e2e_search(&idx, "has:attachment");
286        assert_eq!(results.len(), 2);
287        let attachment_ids: Vec<String> = vec![
288            envelopes[1].id.as_str().to_string(),
289            envelopes[3].id.as_str().to_string(),
290        ];
291        for id in &results {
292            assert!(attachment_ids.contains(id));
293        }
294    }
295
296    #[test]
297    fn e2e_search_by_label() {
298        let (idx, envelopes) = build_e2e_index();
299        let results = e2e_search(&idx, "label:notifications");
300        assert_eq!(results.len(), envelopes.len());
301    }
302
303    #[test]
304    fn e2e_search_by_label_is_case_insensitive() {
305        let (idx, envelopes) = build_e2e_index();
306        let results = e2e_search(&idx, "label:NOTIFICATIONS");
307        assert_eq!(results.len(), envelopes.len());
308    }
309
310    #[test]
311    fn e2e_filter_starred() {
312        let (idx, envelopes) = build_e2e_index();
313        let results = e2e_search(&idx, "is:starred");
314        assert_eq!(results.len(), 1);
315        assert_eq!(results[0], envelopes[1].id.as_str());
316    }
317
318    #[test]
319    fn e2e_search_cc_and_bcc_fields() {
320        let (idx, envelopes) = build_e2e_index();
321
322        let cc_results = e2e_search(&idx, "cc:team@example.com");
323        assert_eq!(cc_results.len(), envelopes.len());
324
325        let bcc_results = e2e_search(&idx, "bcc:hidden@example.com");
326        assert_eq!(bcc_results.len(), envelopes.len());
327    }
328
329    #[test]
330    fn e2e_search_sent_filter() {
331        let mut idx = SearchIndex::in_memory().unwrap();
332        let sent = make_envelope_full(
333            "Sent follow-up",
334            "Done",
335            "Alice",
336            "alice@example.com",
337            MessageFlags::READ | MessageFlags::SENT,
338            false,
339        );
340        let inbox = make_envelope_full(
341            "Inbox message",
342            "Pending",
343            "Bob",
344            "bob@example.com",
345            MessageFlags::READ,
346            false,
347        );
348        idx.index_envelope(&sent).unwrap();
349        idx.index_envelope(&inbox).unwrap();
350        idx.commit().unwrap();
351
352        let results = e2e_search(&idx, "is:sent");
353        assert_eq!(results, vec![sent.id.as_str().to_string()]);
354    }
355
356    #[test]
357    fn e2e_search_size_and_body_and_filename() {
358        let mut idx = SearchIndex::in_memory().unwrap();
359        let env = make_envelope_full(
360            "Release checklist",
361            "Contains attachment",
362            "Alice",
363            "alice@example.com",
364            MessageFlags::READ,
365            true,
366        );
367        let body = MessageBody {
368            message_id: env.id.clone(),
369            text_plain: Some("Deploy canary to 10% before global rollout".to_string()),
370            text_html: None,
371            attachments: vec![AttachmentMeta {
372                id: AttachmentId::new(),
373                message_id: env.id.clone(),
374                filename: "release-notes-v2.pdf".to_string(),
375                mime_type: "application/pdf".to_string(),
376                size_bytes: 10,
377                local_path: None,
378                provider_id: "att-1".to_string(),
379            }],
380            fetched_at: chrono::Utc::now(),
381            metadata: MessageMetadata::default(),
382        };
383
384        idx.index_body(&env, &body).unwrap();
385        idx.commit().unwrap();
386
387        assert_eq!(
388            e2e_search(&idx, "body:canary"),
389            vec![env.id.as_str().to_string()]
390        );
391        assert_eq!(
392            e2e_search(&idx, "filename:release-notes"),
393            vec![env.id.as_str().to_string()]
394        );
395        assert_eq!(
396            e2e_search(&idx, "size:>=1000"),
397            vec![env.id.as_str().to_string()]
398        );
399    }
400}