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 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 let results = idx.search("canary", 10).unwrap();
136 assert_eq!(results.len(), 0);
137
138 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 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(), 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, ),
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(), true, ),
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 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 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}