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