1use crate::backend::{QtThread, Shared, json, load_config, offline, say, tell, tr};
20use cxx_qt::Threading;
21use cxx_qt_lib::QString;
22use jiff::Zoned;
23use serde::Serialize;
24use sioul_core::card::ImapOrigin;
25use sioul_core::config::{Account, Config};
26use sioul_core::folders::{Folder, Role};
27use sioul_core::mailsearch::{self, Found, Names, Search, ServerRef, Stored};
28use sioul_core::porch::Senders;
29use sioul_core::{maildir, rules, view};
30use sioul_sync::fetch::Kept;
31use sioul_sync::{mailbox, secret};
32use std::collections::{BTreeMap, BTreeSet};
33use std::path::{Path, PathBuf};
34use std::pin::Pin;
35use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
36use std::sync::{Arc, Mutex};
37use std::time::Duration;
38
39const SHOWN: usize = 500;
41const PER_SERVER: usize = 200;
43const PAUSE: Duration = Duration::from_millis(900);
45
46#[derive(Clone)]
48struct Hit {
49 date: i64,
50 account: String,
51 folder: String,
52 role: Role,
53 origin: Option<ImapOrigin>,
54 message_id: Option<String>,
55 row: Row,
56}
57
58#[derive(Clone, Serialize)]
60struct Row {
61 #[serde(flatten)]
62 item: view::MailItem,
63 place: String,
65 account: String,
66 folder: String,
67 role: Role,
68 server: bool,
70}
71
72#[derive(Clone)]
74enum ServerPart {
75 Looking,
76 Found { hits: Vec<Hit>, more: usize },
77 Failed(String),
78}
79
80#[derive(Default)]
81struct State {
82 active: bool,
84 search: Search,
85 generation: u64,
86 local: Vec<Hit>,
88 local_total: usize,
89 scanning: bool,
91 server: BTreeMap<String, ServerPart>,
92 brought: Option<(String, String)>,
94}
95
96static STATE: Mutex<Option<State>> = Mutex::new(None);
97static GENERATION: AtomicU64 = AtomicU64::new(0);
99static RESCANNING: AtomicBool = AtomicBool::new(false);
101
102fn with_state<T>(f: impl FnOnce(&mut State) -> T) -> T {
103 let mut guard = STATE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
104 f(guard.get_or_insert_with(State::default))
105}
106
107fn current(generation: u64) -> bool {
108 GENERATION.load(Ordering::SeqCst) == generation
109}
110
111fn folder_title(folder: &Folder, all: &[Folder]) -> String {
114 let has_archive = all.iter().any(|f| f.role == Role::Archive);
115 match folder.role {
116 Role::All if !has_archive => tr().text(Role::Archive.message_id(), None),
117 Role::Other | Role::All => ["[Gmail]/", "[Google Mail]/"].iter().fold(folder.display.clone(), |name, prefix| name.strip_prefix(prefix).map_or(name.clone(), str::to_string)),
118 role => tr().text(role.message_id(), None),
119 }
120}
121
122fn account_title(account: &Account) -> String {
123 account.address.clone().unwrap_or_else(|| account.id.clone())
124}
125
126fn searched(search: &Search, account: &Account, folders: &[Folder]) -> Vec<Folder> {
129 let has_archive = folders.iter().any(|f| f.role == Role::Archive);
130 folders.iter().filter(|f| (f.role != Role::All || !has_archive) && search.reaches(&account.id, f)).cloned().collect()
131}
132
133fn kept_dir(account: &Account, folder: &Folder) -> Option<PathBuf> {
135 let kept = folder.role == Role::Inbox || !account.skip_folders.contains(&folder.name);
136 let mut parts = Path::new(&folder.local).components();
137 let own = matches!((parts.next(), parts.next()), (None, None) | (Some(std::path::Component::Normal(_)), None));
138 (kept && own).then(|| account.maildir_path().join(&folder.local))
139}
140
141fn trusted(config: &Config) -> BTreeMap<String, Vec<String>> {
143 config.mail_sources().into_iter().filter_map(|s| Some((s.account?, s.trusted_ids))).collect()
144}
145
146fn rank(role: Role) -> u8 {
148 match role {
149 Role::Inbox => 0,
150 Role::Other => 1,
151 Role::Archive => 2,
152 Role::Sent | Role::Drafts => 3,
153 Role::Junk | Role::Trash => 4,
154 Role::All => 5,
155 }
156}
157
158fn merged(mut hits: Vec<Hit>) -> Vec<Hit> {
160 hits.sort_by_key(|h| (std::cmp::Reverse(h.date), rank(h.role)));
161 let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
162 let mut by_rank: BTreeMap<(String, String), u8> = BTreeMap::new();
163 for hit in &hits {
164 if let Some(id) = &hit.message_id {
165 let best = by_rank.entry((hit.account.clone(), id.clone())).or_insert(u8::MAX);
166 *best = (*best).min(rank(hit.role));
167 }
168 }
169 hits.retain(|hit| match &hit.message_id {
170 Some(id) => {
171 let key = (hit.account.clone(), id.clone());
172 by_rank.get(&key) == Some(&rank(hit.role)) && seen.insert(key)
173 }
174 None => true,
175 });
176 hits
177}
178
179fn senders(search: &Search, config: &Config) -> Option<Senders> {
181 search.needs_senders().then(|| Senders::load(config))
182}
183
184fn scan(search: &Search, generation: u64, hidden: &BTreeSet<PathBuf>) -> Option<(Vec<Hit>, usize)> {
186 let config = load_config();
187 let judge = senders(search, &config);
188 let mut candidates: Vec<(PathBuf, Arc<(String, Folder)>)> = Vec::new();
189 for account in config.accounts.iter().filter(|a| a.syncs()) {
190 let folders = mailbox::folders(&account.id);
191 for folder in searched(search, account, &folders) {
192 let Some(dir) = kept_dir(account, &folder) else { continue };
193 let place = Arc::new((account.id.clone(), folder));
194 for sub in ["cur", "new"] {
195 for entry in std::fs::read_dir(dir.join(sub)).into_iter().flatten().filter_map(Result::ok) {
196 let path = entry.path();
197 let hidden_file = path.file_name().and_then(|n| n.to_str()).is_none_or(|n| n.starts_with('.'));
198 if !hidden_file && !hidden.contains(&path) {
199 candidates.push((path, Arc::clone(&place)));
200 }
201 }
202 }
203 }
204 }
205 let workers = std::thread::available_parallelism().map_or(2, |n| n.get()).clamp(1, 8);
206 let chunk = candidates.len().div_ceil(workers).max(1);
207 let stopped = AtomicBool::new(false);
208 let found: Vec<(i64, PathBuf, Arc<(String, Folder)>, Option<String>)> = std::thread::scope(|scope| {
209 let handles: Vec<_> = candidates
210 .chunks(chunk)
211 .map(|part| {
212 let (stopped, judge) = (&stopped, judge.as_ref());
213 scope.spawn(move || {
214 let mut out = Vec::new();
215 for (i, (path, place)) in part.iter().enumerate() {
216 if i % 64 == 0 && (!current(generation) || stopped.load(Ordering::Relaxed)) {
217 stopped.store(true, Ordering::Relaxed);
218 return out;
219 }
220 let stored = Stored::new(path.clone(), &place.0, &place.1);
221 if stored.matches(search, judge) {
222 out.push((stored.date().unwrap_or(0), path.clone(), Arc::clone(place), stored.message_id()));
223 }
224 }
225 out
226 })
227 })
228 .collect();
229 handles.into_iter().flat_map(|h| h.join().unwrap_or_default()).collect()
230 });
231 if stopped.load(Ordering::Relaxed) || !current(generation) {
232 return None;
233 }
234 let trusted = trusted(&config);
235 let looked = sioul_core::words::Words::of(&config);
236 let titles: BTreeMap<String, (String, Vec<Folder>)> = config.accounts.iter().filter(|a| a.syncs()).map(|a| (a.id.clone(), (account_title(a), mailbox::folders(&a.id)))).collect();
237 let light: Vec<Hit> = found
239 .into_iter()
240 .map(|(date, path, place, message_id)| Hit {
241 date,
242 account: place.0.clone(),
243 folder: place.1.name.clone(),
244 role: place.1.role,
245 origin: maildir::origin_of(&path),
246 message_id,
247 row: Row { item: empty_item(&path), place: String::new(), account: place.0.clone(), folder: place.1.name.clone(), role: place.1.role, server: false },
248 })
249 .collect();
250 let all = merged(light);
251 let total = all.len();
252 let hits = all
253 .into_iter()
254 .take(SHOWN)
255 .filter_map(|mut hit| {
256 let path = PathBuf::from(&hit.row.item.key);
257 let card = maildir::read_one(&path)?;
258 let (account, folders) = titles.get(&hit.account)?;
259 let folder = folders.iter().find(|f| f.name == hit.folder)?;
260 let ids = trusted.get(&hit.account).map(Vec::as_slice).unwrap_or_default();
261 hit.row.item = view::mail_item(&card, folder.role, false, &maildir::flags_of(&path), ids, &looked, tr());
262 hit.row.place = say("search-place", &[("folder", folder_title(folder, folders)), ("account", account.clone())]);
263 Some(hit)
264 })
265 .collect();
266 Some((hits, total))
267}
268
269fn empty_item(path: &Path) -> view::MailItem {
271 view::MailItem {
272 key: path.display().to_string(),
273 who: String::new(),
274 address: String::new(),
275 subject: String::new(),
276 date: String::new(),
277 unread: false,
278 flagged: false,
279 answered: false,
280 attachments: false,
281 trust_level: "own",
282 trust: String::new(),
283 checks: String::new(),
284 thread: String::new(),
285 size: 1,
286 member: false,
287 }
288}
289
290fn server_hit(found: &Found, account: &Account, folders: &[Folder], trusted: &[String], looked: &sioul_core::words::Words) -> Option<Hit> {
292 let mut item = view::mail_item(&found.card, found.folder.role, false, &found.flags, trusted, looked, tr());
293 item.key = found.place().key();
294 Some(Hit {
295 date: found.date().unwrap_or(0),
296 account: account.id.clone(),
297 folder: found.folder.name.clone(),
298 role: found.folder.role,
299 origin: Some(found.origin),
300 message_id: found.message_id(),
301 row: Row {
302 item,
303 place: format!("{} · {}", say("search-place", &[("folder", folder_title(&found.folder, folders)), ("account", account_title(account))]), tr().text("search-on-server", None)),
304 account: account.id.clone(),
305 folder: found.folder.name.clone(),
306 role: found.folder.role,
307 server: true,
308 },
309 })
310}
311
312fn needs_server(account: &Account, folder: &Folder, earliest: Option<i64>) -> bool {
316 if kept_dir(account, folder).is_none() {
317 return true;
318 }
319 match sioul_sync::fetch::kept(account, &folder.name) {
320 Kept::Everything => false,
321 Kept::Since(since) => earliest.is_none_or(|first| first < since + 86_400),
322 Kept::Unknown => true,
323 }
324}
325
326fn ask_servers(qt: &QtThread, shared: &Arc<Shared>, search: &Search, generation: u64) {
329 if offline() {
330 return;
331 }
332 let config = load_config();
333 let earliest = mailsearch::earliest(search).and_then(|d| d.to_zoned(jiff::tz::TimeZone::system()).ok()).map(|z| z.timestamp().as_second());
334 let trusted = trusted(&config);
335 let judge = senders(search, &config).map(Arc::new);
336 let words = sioul_core::words::Words::of(&config);
337 for account in config.accounts.into_iter().filter(Account::syncs) {
338 let all = mailbox::folders(&account.id);
339 let folders: Vec<Folder> = searched(search, &account, &all).into_iter().filter(|f| needs_server(&account, f, earliest)).collect();
340 if folders.is_empty() {
341 continue;
342 }
343 if !with_state(|s| {
344 let fresh = s.generation == generation;
345 if fresh {
346 s.server.insert(account.id.clone(), ServerPart::Looking);
347 }
348 fresh
349 }) {
350 return;
351 }
352 let (qt, shared, search) = (qt.clone(), Arc::clone(shared), search.clone());
353 let ids = trusted.get(&account.id).cloned().unwrap_or_default();
354 let judge = judge.clone();
355 let looked = words.clone();
356 std::thread::spawn(move || {
357 let here: BTreeMap<String, BTreeSet<(u32, u32)>> = folders.iter().map(|f| (f.name.clone(), sioul_sync::search::kept_uids(&account, f))).collect();
358 let answer = secret::password(&account).and_then(|p| sioul_sync::search::search(&account, &p, &search, &folders, &here, PER_SERVER, judge.as_deref()));
359 let part = match answer {
360 Ok(outcome) => ServerPart::Found { hits: outcome.found.iter().filter_map(|f| server_hit(f, &account, &all, &ids, &looked)).collect(), more: outcome.more },
361 Err(e) => ServerPart::Failed(e.sentence(tr(), &account_title(&account))),
362 };
363 with_state(|s| {
364 if s.generation == generation {
365 s.server.insert(account.id.clone(), part);
366 }
367 });
368 publish(&qt, &shared);
369 });
370 }
371 publish(qt, shared);
372}
373
374#[derive(Serialize)]
376struct SearchView {
377 active: bool,
378 sentence: String,
379 status: String,
381 busy: bool,
383 items: Vec<Row>,
384 brought: Option<Brought>,
386}
387
388#[derive(Serialize)]
389struct Brought {
390 from: String,
391 to: String,
392}
393
394fn names_sentence(search: &Search) -> String {
396 let config = load_config();
397 let account = |id: &str| config.account(id).map_or_else(|| id.to_string(), account_title);
398 let folder = |value: &str| match rules::ROLES.iter().find(|(id, _)| *id == value) {
399 Some((_, role)) => tr().text(role.message_id(), None),
400 None => value.to_string(),
401 };
402 mailsearch::sentence(search, tr(), &Names { account: &account, folder: &folder }, Zoned::now().date())
403}
404
405fn view_now(shared: &Shared) -> SearchView {
407 let hidden = crate::mail::hidden_files(shared);
408 let hidden_keys = crate::mail::hidden_keys(shared);
409 with_state(|s| {
410 if !s.active {
411 return SearchView { active: false, sentence: String::new(), status: String::new(), busy: false, items: Vec::new(), brought: None };
412 }
413 let mut hits: Vec<Hit> = s.local.iter().filter(|h| !hidden.contains(Path::new(&h.row.item.key))).cloned().collect();
414 let here_count = s.local_total.saturating_sub(s.local.len() - hits.len());
416 let here: BTreeSet<(String, String, ImapOrigin)> = hits.iter().filter_map(|h| Some((h.account.clone(), h.folder.clone(), h.origin?))).collect();
418 let (mut looking, mut found_there, mut more_there, mut failures) = (false, 0usize, 0usize, Vec::new());
419 for part in s.server.values() {
420 match part {
421 ServerPart::Looking => looking = true,
422 ServerPart::Found { hits: there, more } => {
423 let fresh: Vec<Hit> = there.iter().filter(|h| !hidden_keys.contains(&h.row.item.key) && h.origin.is_none_or(|o| !here.contains(&(h.account.clone(), h.folder.clone(), o)))).cloned().collect();
424 found_there += fresh.len();
425 more_there += more;
426 hits.extend(fresh);
427 }
428 ServerPart::Failed(why) => failures.push(why.clone()),
429 }
430 }
431 let before = hits.len();
432 let hits = merged(hits);
433 found_there = found_there.saturating_sub(before - hits.len());
435 let empty = s.search.is_empty();
436 let mut status: Vec<String> = Vec::new();
437 if !empty && !s.scanning {
438 let asked = !s.server.is_empty();
439 status.push(say(if asked { "search-found" } else { "search-found-all" }, &[("n", here_count.to_string())]));
440 if here_count > SHOWN {
441 status.push(say("search-found-more", &[("shown", SHOWN.to_string()), ("n", here_count.to_string())]));
442 }
443 if looking {
444 status.push(tr().text("search-server-looking", None));
445 } else if asked && failures.len() < s.server.len() {
446 status.push(if found_there > 0 { say("search-server-found", &[("n", found_there.to_string())]) } else { tr().text("search-server-none", None) });
447 }
448 if more_there > 0 {
449 status.push(say("search-server-more", &[("n", more_there.to_string())]));
450 }
451 status.extend(failures);
452 }
453 SearchView {
454 active: true,
455 sentence: names_sentence(&s.search),
456 status: status.join(" "),
457 busy: s.scanning || looking,
458 items: hits.into_iter().map(|h| h.row).collect(),
459 brought: s.brought.clone().map(|(from, to)| Brought { from, to }),
460 }
461 })
462}
463
464static PUBLISHED: Mutex<String> = Mutex::new(String::new());
467
468fn publish(qt: &QtThread, shared: &Arc<Shared>) {
470 let text = json(&view_now(shared));
471 {
472 let mut last = PUBLISHED.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
473 if *last == text {
474 return;
475 }
476 last.clone_from(&text);
477 }
478 let _ = qt.queue(move |mut sioul| sioul.as_mut().set_mail_search(QString::from(&text)));
479}
480
481pub(crate) fn search(qt: &QtThread, shared: &Arc<Shared>, text: &str, now: bool) {
484 let search: Search = serde_json::from_str(text).unwrap_or_default();
485 let generation = GENERATION.fetch_add(1, Ordering::SeqCst) + 1;
486 let empty = search.is_empty();
487 with_state(|s| {
488 *s = State { active: true, search: search.clone(), generation, scanning: !empty, ..State::default() };
489 });
490 publish(qt, shared);
491 if empty {
492 return;
493 }
494 let (qt, shared) = (qt.clone(), Arc::clone(shared));
495 std::thread::spawn(move || {
496 let hidden = crate::mail::hidden_files(&shared);
497 if let Some((hits, total)) = scan(&search, generation, &hidden) {
498 with_state(|s| {
499 if s.generation == generation {
500 s.local = hits;
501 s.local_total = total;
502 s.scanning = false;
503 }
504 });
505 publish(&qt, &shared);
506 }
507 if !now {
508 std::thread::sleep(PAUSE);
509 }
510 if current(generation) {
511 ask_servers(&qt, &shared, &search, generation);
512 }
513 });
514}
515
516pub(crate) fn clear(qt: &QtThread, shared: &Arc<Shared>) {
518 GENERATION.fetch_add(1, Ordering::SeqCst);
519 with_state(|s| *s = State::default());
520 publish(qt, shared);
521}
522
523pub(crate) fn changed(qt: &QtThread, shared: &Arc<Shared>) {
527 let (active, gone, search, generation) = with_state(|s| (s.active, s.local.iter().any(|h| !Path::new(&h.row.item.key).exists()), s.search.clone(), s.generation));
528 if !active {
529 return;
530 }
531 publish(qt, shared);
532 if !gone || RESCANNING.swap(true, Ordering::SeqCst) {
533 return;
534 }
535 let (qt, shared) = (qt.clone(), Arc::clone(shared));
536 std::thread::spawn(move || {
537 std::thread::sleep(Duration::from_millis(400));
539 let hidden = crate::mail::hidden_files(&shared);
540 if let Some((hits, total)) = scan(&search, generation, &hidden) {
541 with_state(|s| {
542 if s.generation == generation {
543 s.local = hits;
544 s.local_total = total;
545 }
546 });
547 }
548 RESCANNING.store(false, Ordering::SeqCst);
549 publish(&qt, &shared);
550 });
551}
552
553pub(crate) fn forget(keys: &[String]) {
555 with_state(|s| {
556 for part in s.server.values_mut() {
557 if let ServerPart::Found { hits, .. } = part {
558 hits.retain(|h| !keys.contains(&h.row.item.key));
559 }
560 }
561 });
562}
563
564pub(crate) fn marked(keys: &[String], letters: impl Fn(&str) -> String) {
566 with_state(|s| {
567 for part in s.server.values_mut() {
568 if let ServerPart::Found { hits, .. } = part {
569 for hit in hits.iter_mut().filter(|h| keys.contains(&h.row.item.key)) {
570 let mut flags = String::new();
571 flags.push_str(if hit.row.item.unread { "" } else { "S" });
572 flags.push_str(if hit.row.item.flagged { "F" } else { "" });
573 let after = letters(&flags);
574 hit.row.item.unread = matches!(hit.role, Role::Inbox | Role::Other | Role::Archive) && !after.contains('S');
575 hit.row.item.flagged = after.contains('F');
576 }
577 }
578 }
579 });
580}
581
582pub(crate) fn bring(qt: &QtThread, shared: &Arc<Shared>, key: &str) {
584 let Some(place) = ServerRef::parse(key) else { return };
585 let config = load_config();
586 let Some(account) = config.account(&place.account).filter(|a| a.syncs()).cloned() else {
587 tell(qt, shared, say("account-unknown", &[("id", place.account.clone())]));
588 return;
589 };
590 crate::backend::set_status(qt, tr().text("search-bringing", None));
591 let trusted = trusted(&config).remove(&account.id).unwrap_or_default();
592 let looked = sioul_core::words::Words::of(&config);
593 let (qt, shared, key) = (qt.clone(), Arc::clone(shared), key.to_string());
594 std::thread::spawn(move || {
595 let brought = secret::password(&account).and_then(|p| sioul_sync::search::bring(&account, &p, &place));
596 match brought {
597 Ok(path) => {
598 let folders = mailbox::folders(&account.id);
599 let row = maildir::read_one(&path).and_then(|card| {
600 let folder = folders.iter().find(|f| f.name == place.folder)?;
601 let mut item = view::mail_item(&card, folder.role, false, &maildir::flags_of(&path), &trusted, &looked, tr());
602 item.key = path.display().to_string();
603 Some(Hit {
604 date: card.date.unwrap_or(0),
605 account: account.id.clone(),
606 folder: folder.name.clone(),
607 role: folder.role,
608 origin: maildir::origin_of(&path),
609 message_id: card.message_id.as_deref().map(|id| id.trim_matches(['<', '>']).to_string()),
610 row: Row { item, place: say("search-place", &[("folder", folder_title(folder, &folders)), ("account", account_title(&account))]), account: account.id.clone(), folder: folder.name.clone(), role: folder.role, server: false },
611 })
612 });
613 with_state(|s| {
614 s.local.extend(row);
616 s.local.sort_by_key(|h| std::cmp::Reverse(h.date));
617 s.local_total += 1;
618 s.brought = Some((key.clone(), path.display().to_string()));
619 });
620 crate::backend::set_status(&qt, String::new());
621 publish(&qt, &shared);
622 with_state(|s| s.brought = None);
623 }
624 Err(e) => tell(&qt, &shared, e.sentence(tr(), &account_title(&account))),
625 }
626 });
627}
628
629#[derive(Serialize)]
630struct Fields {
631 #[serde(flatten)]
632 words: mailsearch::Form,
633 accounts: Vec<rules::Choice>,
634 folders: Vec<rules::Choice>,
636 help: String,
637}
638
639fn fields() -> String {
641 let config = load_config();
642 let words = mailsearch::form(tr());
643 let accounts: Vec<rules::Choice> = config.accounts.iter().filter(|a| a.syncs()).map(|a| rules::Choice { id: a.id.clone(), label: account_title(a) }).collect();
644 let mut folders = words.roles.clone();
645 let mut named: BTreeMap<String, String> = BTreeMap::new();
646 for account in config.accounts.iter().filter(|a| a.syncs()) {
647 let all = mailbox::folders(&account.id);
648 for folder in all.iter().filter(|f| f.role == Role::Other) {
649 named.entry(folder.name.clone()).or_insert_with(|| folder_title(folder, &all));
650 }
651 }
652 let mut others: Vec<rules::Choice> = named.into_iter().map(|(id, label)| rules::Choice { id, label }).collect();
653 others.sort_by_key(|c| c.label.to_lowercase());
654 folders.extend(others);
655 json(&Fields { words, accounts, folders, help: tr().text("search-help", None) })
656}
657
658impl crate::backend::qobject::Sioul {
659 pub(crate) fn search_mail(self: Pin<&mut Self>, conditions: &QString, now: bool) {
660 search(&self.qt_thread(), &self.shared(), &conditions.to_string(), now);
661 }
662
663 pub(crate) fn clear_mail_search(self: Pin<&mut Self>) {
664 clear(&self.qt_thread(), &self.shared());
665 }
666
667 pub(crate) fn bring_message(self: Pin<&mut Self>, key: &QString) {
668 bring(&self.qt_thread(), &self.shared(), &key.to_string());
669 }
670
671 pub(crate) fn mail_search_fields(&self) -> QString {
672 QString::from(&fields())
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 fn hit(account: &str, folder: &str, role: Role, date: i64, id: Option<&str>) -> Hit {
681 let path = PathBuf::from(format!("/m/{account}/{folder}/{date}"));
682 Hit {
683 date,
684 account: account.into(),
685 folder: folder.into(),
686 role,
687 origin: None,
688 message_id: id.map(str::to_string),
689 row: Row { item: empty_item(&path), place: String::new(), account: account.into(), folder: folder.into(), role, server: false },
690 }
691 }
692
693 #[test]
694 fn twins_once_from_the_folder_you_sort_from() {
695 let hits = vec![
697 hit("gmail", "[Gmail]/All Mail", Role::All, 30, Some("a@x")),
698 hit("gmail", "INBOX", Role::Inbox, 30, Some("a@x")),
699 hit("home", "INBOX", Role::Inbox, 30, Some("a@x")),
700 hit("gmail", "INBOX", Role::Inbox, 50, None),
701 hit("gmail", "Sent", Role::Sent, 10, Some("b@x")),
702 ];
703 let kept: Vec<(String, String, i64)> = merged(hits).into_iter().map(|h| (h.account, h.folder, h.date)).collect();
704 assert_eq!(
705 kept,
706 vec![("gmail".into(), "INBOX".into(), 50), ("gmail".into(), "INBOX".into(), 30), ("home".into(), "INBOX".into(), 30), ("gmail".into(), "Sent".into(), 10)]
707 );
708 }
709}