1use rusmes_storage::MessageStore;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct Thread {
14 pub id: String,
16 pub email_ids: Vec<String>,
18}
19
20#[derive(Debug, Clone, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct ThreadGetRequest {
24 pub account_id: String,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub ids: Option<Vec<String>>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub properties: Option<Vec<String>>,
29}
30
31#[derive(Debug, Clone, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct ThreadGetResponse {
35 pub account_id: String,
36 pub state: String,
37 pub list: Vec<Thread>,
38 pub not_found: Vec<String>,
39}
40
41#[derive(Debug, Clone, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct ThreadChangesRequest {
45 pub account_id: String,
46 pub since_state: String,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub max_changes: Option<u64>,
49}
50
51#[derive(Debug, Clone, Serialize)]
53#[serde(rename_all = "camelCase")]
54pub struct ThreadChangesResponse {
55 pub account_id: String,
56 pub old_state: String,
57 pub new_state: String,
58 pub has_more_changes: bool,
59 pub created: Vec<String>,
60 pub updated: Vec<String>,
61 pub destroyed: Vec<String>,
62}
63
64pub async fn thread_get(
66 request: ThreadGetRequest,
67 _message_store: &dyn MessageStore,
68) -> anyhow::Result<ThreadGetResponse> {
69 let list = Vec::new();
70 let mut not_found = Vec::new();
71
72 let ids = request.ids.unwrap_or_default();
74
75 for id in ids {
76 not_found.push(id);
82 }
83
84 Ok(ThreadGetResponse {
85 account_id: request.account_id,
86 state: "1".to_string(),
87 list,
88 not_found,
89 })
90}
91
92pub async fn thread_changes(
94 request: ThreadChangesRequest,
95 _message_store: &dyn MessageStore,
96) -> anyhow::Result<ThreadChangesResponse> {
97 let since_state: u64 = request.since_state.parse().unwrap_or(0);
98 let new_state = (since_state + 1).to_string();
99
100 let created = Vec::new();
102 let updated = Vec::new();
103 let destroyed = Vec::new();
104
105 Ok(ThreadChangesResponse {
106 account_id: request.account_id,
107 old_state: request.since_state,
108 new_state,
109 has_more_changes: false,
110 created,
111 updated,
112 destroyed,
113 })
114}
115
116#[allow(dead_code)]
120fn calculate_thread_id(
121 message_id: Option<&str>,
122 in_reply_to: Option<&[String]>,
123 references: Option<&[String]>,
124) -> String {
125 use sha2::{Digest, Sha256};
132
133 let root_id = references
135 .and_then(|refs| refs.first())
136 .or_else(|| in_reply_to.and_then(|irt| irt.first()))
137 .map(|s| s.as_str())
138 .or(message_id)
139 .unwrap_or("unknown");
140
141 let mut hasher = Sha256::new();
143 hasher.update(root_id.as_bytes());
144 let result = hasher.finalize();
145 format!("T{:x}", result).chars().take(32).collect()
146}
147
148#[allow(dead_code)]
150fn generate_snippet(text: &str, search_terms: &[String], max_length: usize) -> String {
151 if search_terms.is_empty() {
152 if text.len() <= max_length {
154 return text.to_string();
155 }
156 return format!("{}...", &text[..max_length.saturating_sub(3)]);
157 }
158
159 let mut best_pos = None;
161 let text_lower = text.to_lowercase();
162
163 for term in search_terms {
164 let term_lower = term.to_lowercase();
165 if let Some(pos) = text_lower.find(&term_lower) {
166 if best_pos.map_or(true, |best| pos < best) {
167 best_pos = Some(pos);
168 }
169 }
170 }
171
172 match best_pos {
173 Some(pos) => {
174 let context_before = 50;
176 let context_after = max_length.saturating_sub(context_before).saturating_sub(6); let start = pos.saturating_sub(context_before);
179 let end = (start + context_before + context_after).min(text.len());
180
181 let mut snippet = String::new();
182 if start > 0 {
183 snippet.push_str("...");
184 }
185 snippet.push_str(&text[start..end]);
186 if end < text.len() {
187 snippet.push_str("...");
188 }
189
190 snippet
191 }
192 None => {
193 if text.len() <= max_length {
195 text.to_string()
196 } else {
197 format!("{}...", &text[..max_length.saturating_sub(3)])
198 }
199 }
200 }
201}
202
203#[allow(dead_code)]
205fn highlight_snippet(text: &str, search_terms: &[String]) -> String {
206 if search_terms.is_empty() {
207 return text.to_string();
208 }
209
210 let mut result = text.to_string();
211 let text_lower = text.to_lowercase();
212
213 let mut matches: Vec<(usize, usize, String)> = Vec::new();
215
216 for term in search_terms {
217 let term_lower = term.to_lowercase();
218 let mut pos = 0;
219 while let Some(found_pos) = text_lower[pos..].find(&term_lower) {
220 let actual_pos = pos + found_pos;
221 let end_pos = actual_pos + term.len();
222
223 let matched_text = text[actual_pos..end_pos].to_string();
225 matches.push((actual_pos, end_pos, matched_text));
226
227 pos = end_pos;
228 }
229 }
230
231 matches.sort_by_key(|b| std::cmp::Reverse(b.0));
233
234 let mut non_overlapping: Vec<(usize, usize, String)> = Vec::new();
236 for m in matches {
237 let overlaps = non_overlapping.iter().any(|existing| {
238 (m.0 >= existing.0 && m.0 < existing.1) || (m.1 > existing.0 && m.1 <= existing.1)
239 });
240 if !overlaps {
241 non_overlapping.push(m);
242 }
243 }
244
245 for (start, end, matched_text) in non_overlapping {
247 let highlighted = format!("<mark>{}</mark>", matched_text);
248 result.replace_range(start..end, &highlighted);
249 }
250
251 result
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use rusmes_storage::backends::filesystem::FilesystemBackend;
258 use rusmes_storage::StorageBackend;
259 use std::path::PathBuf;
260
261 fn create_test_store() -> std::sync::Arc<dyn MessageStore> {
262 let backend = FilesystemBackend::new(PathBuf::from("/tmp/rusmes-test-storage"));
263 backend.message_store()
264 }
265
266 #[tokio::test]
267 async fn test_thread_get() {
268 let store = create_test_store();
269 let request = ThreadGetRequest {
270 account_id: "acc1".to_string(),
271 ids: Some(vec!["thread1".to_string()]),
272 properties: None,
273 };
274
275 let response = thread_get(request, store.as_ref()).await.unwrap();
276 assert_eq!(response.account_id, "acc1");
277 assert_eq!(response.not_found.len(), 1);
278 }
279
280 #[tokio::test]
281 async fn test_thread_get_all() {
282 let store = create_test_store();
283 let request = ThreadGetRequest {
284 account_id: "acc1".to_string(),
285 ids: None,
286 properties: None,
287 };
288
289 let response = thread_get(request, store.as_ref()).await.unwrap();
290 assert_eq!(response.list.len(), 0);
291 }
292
293 #[tokio::test]
294 async fn test_thread_changes() {
295 let store = create_test_store();
296 let request = ThreadChangesRequest {
297 account_id: "acc1".to_string(),
298 since_state: "1".to_string(),
299 max_changes: Some(50),
300 };
301
302 let response = thread_changes(request, store.as_ref()).await.unwrap();
303 assert_eq!(response.old_state, "1");
304 assert_eq!(response.new_state, "2");
305 assert!(!response.has_more_changes);
306 }
307
308 #[tokio::test]
309 async fn test_calculate_thread_id_with_references() {
310 let references = vec![
311 "<root@example.com>".to_string(),
312 "<reply1@example.com>".to_string(),
313 ];
314 let thread_id = calculate_thread_id(Some("<reply2@example.com>"), None, Some(&references));
315
316 assert!(thread_id.starts_with('T'));
318 assert_eq!(thread_id.len(), 32);
319 }
320
321 #[tokio::test]
322 async fn test_calculate_thread_id_with_in_reply_to() {
323 let in_reply_to = vec!["<original@example.com>".to_string()];
324 let thread_id = calculate_thread_id(Some("<reply@example.com>"), Some(&in_reply_to), None);
325
326 assert!(thread_id.starts_with('T'));
327 }
328
329 #[tokio::test]
330 async fn test_calculate_thread_id_standalone() {
331 let thread_id = calculate_thread_id(Some("<standalone@example.com>"), None, None);
332
333 assert!(thread_id.starts_with('T'));
334 }
335
336 #[tokio::test]
337 async fn test_generate_snippet_no_search_terms() {
338 let text = "This is a test message with some content.";
339 let snippet = generate_snippet(text, &[], 100);
340 assert_eq!(snippet, text);
341 }
342
343 #[tokio::test]
344 async fn test_generate_snippet_with_match() {
345 let text = "This is a test message with important information that we need to find.";
346 let terms = vec!["important".to_string()];
347 let snippet = generate_snippet(text, &terms, 100);
348
349 assert!(snippet.contains("important"));
350 }
351
352 #[tokio::test]
353 async fn test_generate_snippet_truncate_long_text() {
354 let text = "A".repeat(200);
355 let snippet = generate_snippet(&text, &[], 50);
356
357 assert_eq!(snippet.len(), 50);
358 assert!(snippet.ends_with("..."));
359 }
360
361 #[tokio::test]
362 async fn test_thread_get_with_properties() {
363 let store = create_test_store();
364 let properties = vec!["id".to_string(), "emailIds".to_string()];
365
366 let request = ThreadGetRequest {
367 account_id: "acc1".to_string(),
368 ids: Some(vec!["thread1".to_string()]),
369 properties: Some(properties),
370 };
371
372 let response = thread_get(request, store.as_ref()).await.unwrap();
373 assert_eq!(response.list.len(), 0);
374 }
375
376 #[tokio::test]
377 async fn test_thread_changes_max_changes() {
378 let store = create_test_store();
379 let request = ThreadChangesRequest {
380 account_id: "acc1".to_string(),
381 since_state: "100".to_string(),
382 max_changes: Some(10),
383 };
384
385 let response = thread_changes(request, store.as_ref()).await.unwrap();
386 assert_eq!(response.old_state, "100");
387 assert_eq!(response.new_state, "101");
388 }
389
390 #[tokio::test]
391 async fn test_generate_snippet_match_at_start() {
392 let text = "Important information at the beginning of the message.";
393 let terms = vec!["Important".to_string()];
394 let snippet = generate_snippet(text, &terms, 100);
395
396 assert!(snippet.starts_with("Important"));
397 }
398
399 #[tokio::test]
400 async fn test_generate_snippet_match_at_end() {
401 let text = "The message ends with important information.";
402 let terms = vec!["important".to_string()];
403 let snippet = generate_snippet(text, &terms, 100);
404
405 assert!(snippet.contains("important"));
406 }
407
408 #[tokio::test]
409 async fn test_generate_snippet_multiple_terms() {
410 let text = "This message contains both urgent and important information.";
411 let terms = vec!["urgent".to_string(), "important".to_string()];
412 let snippet = generate_snippet(text, &terms, 100);
413
414 assert!(snippet.contains("urgent") || snippet.contains("important"));
416 }
417
418 #[tokio::test]
419 async fn test_thread_id_consistency() {
420 let message_id = "<msg@example.com>";
421 let thread_id1 = calculate_thread_id(Some(message_id), None, None);
422 let thread_id2 = calculate_thread_id(Some(message_id), None, None);
423
424 assert_eq!(thread_id1, thread_id2);
426 }
427
428 #[tokio::test]
429 async fn test_thread_changes_state_progression() {
430 let store = create_test_store();
431
432 let request1 = ThreadChangesRequest {
433 account_id: "acc1".to_string(),
434 since_state: "5".to_string(),
435 max_changes: None,
436 };
437 let response1 = thread_changes(request1, store.as_ref()).await.unwrap();
438
439 let request2 = ThreadChangesRequest {
440 account_id: "acc1".to_string(),
441 since_state: response1.new_state.clone(),
442 max_changes: None,
443 };
444 let response2 = thread_changes(request2, store.as_ref()).await.unwrap();
445
446 assert!(response1.new_state < response2.new_state);
447 }
448
449 #[tokio::test]
450 async fn test_generate_snippet_case_insensitive() {
451 let text = "This message contains IMPORTANT information.";
452 let terms = vec!["important".to_string()];
453 let snippet = generate_snippet(text, &terms, 100);
454
455 assert!(snippet.to_lowercase().contains("important"));
456 }
457
458 #[tokio::test]
459 async fn test_thread_get_multiple_ids() {
460 let store = create_test_store();
461 let request = ThreadGetRequest {
462 account_id: "acc1".to_string(),
463 ids: Some(vec![
464 "thread1".to_string(),
465 "thread2".to_string(),
466 "thread3".to_string(),
467 ]),
468 properties: None,
469 };
470
471 let response = thread_get(request, store.as_ref()).await.unwrap();
472 assert_eq!(response.not_found.len(), 3);
473 }
474
475 #[tokio::test]
476 async fn test_calculate_thread_id_empty_references() {
477 let thread_id = calculate_thread_id(Some("<msg@example.com>"), Some(&[]), Some(&[]));
478
479 assert!(thread_id.starts_with('T'));
480 }
481
482 #[tokio::test]
483 async fn test_generate_snippet_exact_max_length() {
484 let text = "Exactly fifty characters for testing purposes!";
485 let snippet = generate_snippet(text, &[], 47);
486
487 assert_eq!(snippet, text);
488 }
489
490 #[tokio::test]
491 async fn test_generate_snippet_context_window() {
492 let text = "A".repeat(50) + "IMPORTANT" + &"Z".repeat(50);
493 let terms = vec!["IMPORTANT".to_string()];
494 let snippet = generate_snippet(&text, &terms, 80);
495
496 assert!(snippet.contains("IMPORTANT"));
497 assert!(snippet.contains("..."));
498 }
499
500 #[tokio::test]
501 async fn test_thread_changes_empty_state() {
502 let store = create_test_store();
503 let request = ThreadChangesRequest {
504 account_id: "acc1".to_string(),
505 since_state: "0".to_string(),
506 max_changes: None,
507 };
508
509 let response = thread_changes(request, store.as_ref()).await.unwrap();
510 assert_eq!(response.new_state, "1");
511 assert!(response.created.is_empty());
512 assert!(response.updated.is_empty());
513 assert!(response.destroyed.is_empty());
514 }
515
516 #[tokio::test]
517 async fn test_thread_object_structure() {
518 let thread = Thread {
519 id: "T123".to_string(),
520 email_ids: vec!["email1".to_string(), "email2".to_string()],
521 };
522
523 let json = serde_json::to_string(&thread).unwrap();
524 assert!(json.contains("T123"));
525 assert!(json.contains("email1"));
526 }
527
528 #[tokio::test]
529 async fn test_highlight_snippet_basic() {
530 let text = "This is an important message";
531 let terms = vec!["important".to_string()];
532 let highlighted = highlight_snippet(text, &terms);
533
534 assert!(highlighted.contains("<mark>important</mark>"));
535 }
536
537 #[tokio::test]
538 async fn test_highlight_snippet_multiple_occurrences() {
539 let text = "test message with test data";
540 let terms = vec!["test".to_string()];
541 let highlighted = highlight_snippet(text, &terms);
542
543 let count = highlighted.matches("<mark>test</mark>").count();
545 assert_eq!(count, 2);
546 }
547
548 #[tokio::test]
549 async fn test_highlight_snippet_case_preservation() {
550 let text = "IMPORTANT message is Important";
551 let terms = vec!["important".to_string()];
552 let highlighted = highlight_snippet(text, &terms);
553
554 assert!(highlighted.contains("<mark>IMPORTANT</mark>"));
555 assert!(highlighted.contains("<mark>Important</mark>"));
556 }
557
558 #[tokio::test]
559 async fn test_highlight_snippet_no_terms() {
560 let text = "No highlighting needed";
561 let highlighted = highlight_snippet(text, &[]);
562
563 assert_eq!(highlighted, text);
564 assert!(!highlighted.contains("<mark>"));
565 }
566
567 #[tokio::test]
568 async fn test_highlight_snippet_overlapping_terms() {
569 let text = "important information";
570 let terms = vec!["important".to_string(), "info".to_string()];
571 let highlighted = highlight_snippet(text, &terms);
572
573 assert!(highlighted.contains("<mark>"));
575 }
576
577 #[tokio::test]
578 async fn test_calculate_thread_id_nested_conversation() {
579 let references = vec![
580 "<root@example.com>".to_string(),
581 "<reply1@example.com>".to_string(),
582 "<reply2@example.com>".to_string(),
583 ];
584 let thread_id = calculate_thread_id(Some("<reply3@example.com>"), None, Some(&references));
585
586 let thread_id2 = calculate_thread_id(Some("<reply4@example.com>"), None, Some(&references));
588 assert_eq!(thread_id, thread_id2);
589 }
590
591 #[tokio::test]
592 async fn test_calculate_thread_id_multi_branch() {
593 let references1 = vec!["<root@example.com>".to_string()];
594 let references2 = vec![
595 "<root@example.com>".to_string(),
596 "<branch1@example.com>".to_string(),
597 ];
598
599 let thread_id1 =
600 calculate_thread_id(Some("<reply1@example.com>"), None, Some(&references1));
601 let thread_id2 =
602 calculate_thread_id(Some("<reply2@example.com>"), None, Some(&references2));
603
604 assert_eq!(thread_id1, thread_id2);
606 }
607
608 #[tokio::test]
609 async fn test_generate_snippet_very_short_text() {
610 let text = "Hi";
611 let snippet = generate_snippet(text, &[], 100);
612 assert_eq!(snippet, "Hi");
613 }
614
615 #[tokio::test]
616 async fn test_generate_snippet_no_match_no_terms() {
617 let text = "This is a longer message that should be truncated";
618 let snippet = generate_snippet(text, &[], 20);
619
620 assert!(snippet.len() <= 20);
621 assert!(snippet.ends_with("..."));
622 }
623}