1use crate::backend::{QtThread, Shared, say, tr};
30use serde::Serialize;
31use sioul_core::config::Account;
32use sioul_core::porch::{self, Lane, Reason};
33use sioul_core::spam::table::{self, Table};
34use sioul_core::spam::{Action, Class};
35use std::path::{Path, PathBuf};
36use std::sync::{Arc, Mutex};
37
38pub(crate) fn after_fetch(account: &Account, new: &[PathBuf], first: bool) -> usize {
49 if first || new.is_empty() {
50 return 0;
51 }
52 let config = crate::backend::load_config();
53 let filter = sioul_core::spam::Filter::of(&config);
54 if filter.actions.idle() || !filter.table.exists() {
56 return 0;
57 }
58 let ties = sioul_core::links::LocalLinks::load(&sioul_core::links::LocalLinks::default_path());
59 let store = config.notes_root_path().and_then(|root| sioul_core::projects::ProjectStore::load(&root).ok()).map(|s| s.with_ties(&ties));
60 let known = porch::KnownSenders::load(&config.known_senders_path());
61 let senders = porch::Senders::load(&config);
62 let judged = porch::judge(new, &config.mail_sources(), store.as_ref(), &known, &senders, jiff::Timestamp::now().as_second());
63 for (file, class) in chosen(&judged, &filter, Action::Flag) {
65 if let Err(e) = sioul_sync::mailbox::flagged(account, &file, class) {
66 eprintln!("sioul: spam: {}: {e:?}", account.id);
67 }
68 }
69 let to_move = chosen(&judged, &filter, Action::Move);
70 if to_move.is_empty() {
71 return 0;
72 }
73 let Ok(password) = sioul_sync::secret::password(account) else { return 0 };
74 to_move
75 .into_iter()
76 .filter(|(file, class)| match sioul_sync::mailbox::act(account, &password, file, &sioul_sync::mailbox::Action::Filtered(*class)) {
77 Ok(()) => true,
78 Err(e) => {
79 eprintln!("sioul: spam: {}: {e:?}", account.id);
80 false
81 }
82 })
83 .count()
84}
85
86fn chosen(judged: &[porch::Triaged], filter: &sioul_core::spam::Filter, action: Action) -> Vec<(PathBuf, Class)> {
90 judged
91 .iter()
92 .filter(|t| t.lane == Lane::Review && !t.reasons.contains(&Reason::MovedToJunk))
93 .filter_map(|t| {
94 let (class, _) = t.reasons.iter().find_map(Reason::learned)?;
95 (filter.actions.of(class) == action).then_some((t.card.path.clone()?, class))
96 })
97 .collect()
98}
99
100#[derive(Debug, Clone, Default, Serialize)]
102pub(crate) struct Status {
103 trains: bool,
105 table: String,
107 measured: String,
109 refused: String,
111 running: bool,
113 stopping: bool,
115 progress: String,
117 fraction: f64,
119 ended: String,
121 by_itself: bool,
123 by_itself_here: bool,
125 by_itself_line: String,
127 last: Vec<String>,
129 corpus: Vec<String>,
131 outside: Vec<String>,
133}
134
135pub(crate) fn status() -> String {
137 serde_json::to_string(&status_now()).unwrap_or_default()
138}
139
140fn status_now() -> Status {
141 let mut status = Status { trains: cfg!(not(target_os = "android")), fraction: -1.0, ..Status::default() };
142 let (table, measured, refused) = table_lines(&sioul_core::spam::table_path(), status.trains);
143 (status.table, status.measured, status.refused) = (table, measured, refused);
144 #[cfg(not(target_os = "android"))]
145 training::fill(&mut status);
146 status
147}
148
149pub(crate) fn train(qt: QtThread, shared: Arc<Shared>) -> String {
152 #[cfg(not(target_os = "android"))]
153 {
154 training::start(qt, shared)
155 }
156 #[cfg(target_os = "android")]
157 {
158 drop((qt, shared));
159 tr().text("spam-app-phone-trains-not", None)
160 }
161}
162
163pub(crate) fn stop(qt: QtThread) {
165 #[cfg(not(target_os = "android"))]
166 training::stop(qt);
167 #[cfg(target_os = "android")]
168 drop(qt);
169}
170
171pub(crate) fn by_itself(qt: QtThread) {
176 #[cfg(not(target_os = "android"))]
177 training::by_itself(qt);
178 #[cfg(target_os = "android")]
179 drop(qt);
180}
181
182fn table_lines(path: &Path, trains: bool) -> (String, String, String) {
185 type Said = Option<(Vec<Option<(u64, std::time::SystemTime)>>, (String, String, String))>;
186 static SAID: Mutex<Said> = Mutex::new(None);
187 let seal = |path: &Path| std::fs::metadata(path).ok().and_then(|m| Some((m.len(), m.modified().ok()?)));
188 let sealed = vec![seal(path), seal(&table::previous(path))];
189 let mut said = SAID.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
190 if let Some((known, lines)) = said.as_ref()
191 && *known == sealed
192 {
193 return lines.clone();
194 }
195 let lines = read_table_lines(path, trains);
196 *said = Some((sealed, lines.clone()));
197 lines
198}
199
200fn read_table_lines(path: &Path, trains: bool) -> (String, String, String) {
201 let (used, refused) = match Table::read(path) {
204 Ok(table) => (Some(table), None),
205 Err(why) if path.exists() => (Table::read(&table::previous(path)).ok(), Some(why)),
206 Err(_) => (None, None),
207 };
208 let refused = refused.map_or(String::new(), |why| {
209 let other_version = ["tokenizer", "header features", "format"].iter().any(|w| why.contains(w));
211 let why = tr().text(if other_version { "spam-app-refused-version" } else { "spam-app-refused-damaged" }, None);
212 say(if used.is_some() { "spam-app-refused" } else { "spam-app-refused-none" }, &[("why", why)])
213 });
214 let Some(table) = used else {
215 let none = if !refused.is_empty() { String::new() } else { tr().text(if trains { "spam-app-none" } else { "spam-app-none-phone" }, None) };
216 return (none, String::new(), refused);
217 };
218 let meta = &table.meta;
219 let here = sioul_sync::lease::host_name();
220 let (id, device) = match meta.device.as_str() {
221 "" => ("spam-app-table-somewhere", String::new()),
222 device if device == here => ("spam-app-table-here", String::new()),
223 device => ("spam-app-table", device.to_string()),
224 };
225 let line = say(id, &[("device", device), ("when", when(meta.trained_at)), ("ham", grouped(meta.ham)), ("spam", grouped(meta.spam))]);
226 let metric = |name: &str| meta.metrics.get(name).copied().filter(|v| v.is_finite());
227 let measured = match (metric("threshold_spam"), metric("ham_called_spam"), metric("spam_caught")) {
228 (Some(threshold), Some(ham), Some(spam)) => say("spam-app-table-numbers", &[("threshold", percent(threshold, 0)), ("ham", percent(ham, 1)), ("spam", percent(spam, 1))]),
229 _ => String::new(),
230 };
231 (line, measured, refused)
232}
233
234fn when(seconds: i64) -> String {
236 jiff::Timestamp::from_second(seconds).ok().filter(|_| seconds > 0).map(|t| tr().when(&t.to_zoned(jiff::tz::TimeZone::system()))).unwrap_or_default()
237}
238
239fn grouped(n: u64) -> String {
241 let digits = n.to_string();
242 let separator = tr().text("thousands-separator", None);
243 let mut out = String::new();
244 for (i, c) in digits.chars().enumerate() {
245 if i > 0 && (digits.len() - i) % 3 == 0 {
246 out.push_str(&separator);
247 }
248 out.push(c);
249 }
250 out
251}
252
253fn percent(fraction: f64, places: usize) -> String {
255 let n = format!("{:.places$}", fraction * 100.0).replace('.', &tr().text("decimal-separator", None));
256 say("spam-percent", &[("n", n)])
257}
258
259#[cfg(not(target_os = "android"))]
261mod training {
262 use super::{Status, grouped, percent, say, tr, when};
263 use crate::backend::{QtThread, Shared, load_config};
264 use cxx_qt_lib::QString;
265 use sioul_learn::{Cancel, Dirs, LearnError, Progress, Stage, auto, corpus, train};
266 use std::collections::BTreeMap;
267 use std::sync::atomic::{AtomicBool, Ordering};
268 use std::sync::{Arc, Mutex, PoisonError};
269 use std::time::{Duration, Instant};
270
271 const PACE: Duration = Duration::from_millis(300);
273
274 struct Run {
276 cancel: Option<Cancel>,
278 stopping: bool,
279 stage: Option<Stage>,
280 progress: String,
281 fraction: f64,
282 ended: String,
283 said: Option<Instant>,
284 }
285
286 static RUN: Mutex<Run> = Mutex::new(Run { cancel: None, stopping: false, stage: None, progress: String::new(), fraction: -1.0, ended: String::new(), said: None });
287
288 struct Fetch {
292 folders: BTreeMap<String, (u64, u64)>,
293 left: BTreeMap<String, u64>,
294 overall: Option<u64>,
296 }
297
298 impl Fetch {
299 fn new(dirs: &Dirs) -> Fetch {
300 let left = corpus::status(dirs).accounts.iter().map(|a| (a.id.clone(), a.folders.iter().map(|f| u64::from(f.on_server).saturating_sub(f.recorded)).sum())).collect();
301 Fetch { folders: BTreeMap::new(), left, overall: None }
302 }
303
304 fn note(&mut self, p: &Progress) -> (u64, u64) {
306 if p.detail.is_empty() {
307 self.overall = (p.total > 0).then_some(p.total).or(self.overall);
308 } else {
309 self.folders.insert(p.detail.clone(), (p.done, p.total));
310 }
311 let done: u64 = self.folders.values().map(|(done, _)| done).sum();
312 let seen: u64 = self.folders.values().map(|(_, total)| total).sum();
313 let begun = |account: &str| self.folders.keys().any(|place| place.split(" · ").next() == Some(account));
314 let rest: u64 = self.left.iter().filter(|(account, _)| !begun(account)).map(|(_, n)| n).sum();
315 (done, self.overall.unwrap_or(seen + rest).max(done))
316 }
317 }
318
319 pub(super) fn fill(status: &mut Status) {
321 {
322 let run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
323 status.running = run.cancel.is_some();
324 status.stopping = run.stopping;
325 if status.running {
326 status.progress = run.progress.clone();
327 status.fraction = run.fraction;
328 }
329 status.ended = run.ended.clone();
330 }
331 let dirs = Dirs::standard();
332 status.by_itself = load_config().spam.trains_by_itself();
333 (status.by_itself_here, status.by_itself_line) = by_itself_line(&dirs, &sioul_sync::lease::host_name());
334 if let Some(summary) = train::last(&dirs) {
335 status.last = last_lines(&summary);
336 }
337 status.corpus = corpus_lines(&dirs);
338 status.outside = sioul_learn::external::sources(&dirs)
339 .iter()
340 .map(|source| say("spam-app-outside", &[("source", source.name.clone()), ("ham", grouped(source.counts.ham)), ("spam", grouped(source.counts.spam)), ("first", day(source.counts.first)), ("last", day(source.counts.last))]))
341 .collect();
342 }
343
344 fn day(seconds: i64) -> String {
346 jiff::Timestamp::from_second(seconds).ok().map(|t| tr().day(t.to_zoned(jiff::tz::TimeZone::system()).date())).unwrap_or_default()
347 }
348
349 pub(super) fn start(qt: QtThread, shared: Arc<Shared>) -> String {
350 if auto::a_job_runs(&Dirs::standard()) {
352 return tr().text("spam-app-busy-job", None);
353 }
354 let cancel = Cancel::new();
355 {
356 let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
357 if run.cancel.is_some() {
358 return tr().text("spam-app-busy", None);
359 }
360 *run = Run { cancel: Some(cancel.clone()), stopping: false, stage: None, progress: tr().text("spam-app-starting", None), fraction: -1.0, ended: String::new(), said: None };
361 }
362 let started = std::thread::Builder::new().name("spam-training".into()).spawn(move || {
363 lower_priority();
364 let config = load_config();
365 let dirs = Dirs::standard();
366 let _held = window_lock(&dirs);
368 let mut fetch = Fetch::new(&dirs);
369 let result = train::run(&config, &dirs, &mut |p: &Progress| moved(&qt, &mut fetch, p), &cancel);
370 let ended = match result {
371 Ok(_) => String::new(),
372 Err(e) => failure(&e),
373 };
374 {
375 let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
376 (run.cancel, run.stopping, run.stage, run.ended) = (None, false, None, ended);
377 run.progress.clear();
378 }
379 let status = super::status();
380 let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
381 crate::backend::show(&qt, &shared);
383 });
384 match started {
385 Ok(_) => String::new(),
386 Err(e) => {
387 RUN.lock().unwrap_or_else(PoisonError::into_inner).cancel = None;
388 say("spam-app-error", &[("detail", e.to_string())])
389 }
390 }
391 }
392
393 pub(super) fn stop(qt: QtThread) {
394 {
395 let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
396 let Some(cancel) = run.cancel.as_ref() else { return };
397 cancel.cancel();
398 run.stopping = true;
399 }
400 let status = super::status();
401 let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
402 }
403
404 pub(super) fn by_itself(qt: QtThread) {
408 static LOOKING: AtomicBool = AtomicBool::new(false);
410 if LOOKING.swap(true, Ordering::SeqCst) {
411 return;
412 }
413 let config = load_config();
414 let (dirs, now) = (Dirs::standard(), jiff::Timestamp::now().as_second());
415 let window_trains = RUN.lock().unwrap_or_else(PoisonError::into_inner).cancel.is_some();
416 let focus = sioul_core::timelog::running().is_some();
417 let facts = auto::facts(&dirs, config.spam.trains_by_itself(), &sioul_sync::lease::host_name(), window_trains, focus, now, sioul_sync::power::read);
418 let started = match auto::decide(&facts, now) {
419 auto::Decision::Train { fetch } => start_apart(fetch),
420 auto::Decision::Wait(_) => false,
421 };
422 let lately = facts.last_run.as_ref().is_some_and(|run| run.ended.is_none_or(|end| now - end < 120));
424 if started || lately {
425 let status = super::status();
426 let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
427 }
428 LOOKING.store(false, Ordering::SeqCst);
429 }
430
431 fn start_apart(fetch: bool) -> bool {
434 let Some(sioul) = crate::remind::command() else { return false };
435 let mut command = std::process::Command::new(sioul);
436 command.arg("--config").arg(crate::backend::config_path()).arg("--language").arg(tr().language());
437 command.args(["spam", "train", "--by-itself", "--threads", &auto::threads().to_string()]);
438 if !fetch {
439 command.arg("--no-fetch");
440 }
441 command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
442 command.status().is_ok_and(|status| status.success())
443 }
444
445 fn by_itself_line(dirs: &Dirs, here: &str) -> (bool, String) {
449 match auto::trainer(dirs, here) {
450 auto::Trainer::Elsewhere(device) => (false, say("spam-app-by-itself-elsewhere", &[("device", device)])),
451 auto::Trainer::Nobody => (true, tr().text("spam-app-by-itself-first", None)),
452 auto::Trainer::Here => {
453 let Some(run) = auto::Run::load(dirs) else { return (true, String::new()) };
454 let end = run.ended.unwrap_or(run.started);
455 let just = jiff::Timestamp::now().as_second() - run.started < 60;
457 let (id, at) = match run.outcome {
458 None if just || auto::a_job_runs(dirs) => ("spam-app-by-itself-running", run.started),
459 Some(auto::Outcome::Replaced) => ("spam-app-by-itself-replaced", end),
460 Some(auto::Outcome::Kept) => ("spam-app-by-itself-kept", end),
461 Some(auto::Outcome::Stopped) => ("spam-app-by-itself-stopped", end),
462 Some(auto::Outcome::Failed) | None => ("spam-app-by-itself-failed", end),
463 };
464 (true, say(id, &[("when", when(at))]))
465 }
466 }
467 }
468
469 fn window_lock(dirs: &Dirs) -> Option<std::fs::File> {
471 let jobs = dirs.state.join("jobs");
472 let mut folder = std::fs::DirBuilder::new();
473 folder.recursive(true);
474 #[cfg(unix)]
475 std::os::unix::fs::DirBuilderExt::mode(&mut folder, 0o700);
476 folder.create(&jobs).ok()?;
477 let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(jobs.join("window.lock")).ok()?;
478 file.lock().ok()?;
479 Some(file)
480 }
481
482 fn moved(qt: &QtThread, fetch: &mut Fetch, p: &Progress) {
484 let (line, fraction) = match p.stage {
485 Stage::Corpus => {
486 let (done, total) = fetch.note(p);
487 let place = p.detail.clone();
488 let line = if total == 0 {
489 say("spam-app-fetching-start", &[("place", place)])
490 } else {
491 say("spam-app-fetching", &[("done", grouped(done)), ("total", grouped(total)), ("place", place)])
492 };
493 (line, if total > 0 { done as f64 / total as f64 } else { -1.0 })
494 }
495 stage => {
496 let name = tr().text(&format!("spam-stage-{}", stage.as_str()), None);
497 if p.total > 0 { (say("spam-app-step", &[("stage", name), ("done", grouped(p.done)), ("total", grouped(p.total))]), p.done as f64 / p.total as f64) } else { (name, -1.0) }
498 }
499 };
500 {
501 let mut run = RUN.lock().unwrap_or_else(PoisonError::into_inner);
502 let new_step = run.stage != Some(p.stage);
503 (run.stage, run.progress, run.fraction) = (Some(p.stage), line, fraction);
504 if !new_step && run.said.is_some_and(|at| at.elapsed() < PACE) {
505 return;
506 }
507 run.said = Some(Instant::now());
508 }
509 let status = super::status();
510 let _ = qt.queue(move |mut sioul| sioul.as_mut().spam_changed(QString::from(&status)));
511 }
512
513 fn failure(e: &LearnError) -> String {
515 match e {
516 LearnError::Cancelled => tr().text("spam-app-stopped", None),
517 LearnError::TooFew { ham, spam } => say("spam-app-too-few", &[("ham", grouped(*ham)), ("spam", grouped(*spam)), ("least", train::Options::default().least_of_each.to_string())]),
518 LearnError::Disk(_) => tr().text("spam-app-disk", None),
519 LearnError::Fold(_) => tr().text("spam-app-fold", None),
520 other => say("spam-app-error", &[("detail", other.to_string())]),
521 }
522 }
523
524 fn last_lines(summary: &train::Summary) -> Vec<String> {
530 let at = when(summary.trained_at);
531 let compared = summary.compared.unwrap_or_default();
532 let counted = |id: &str| say(id, &[("when", at.clone()), ("n", grouped(compared.ham + compared.spam)), ("new", compared.new_ham_lost.to_string()), ("old", compared.current_ham_lost.to_string())]);
533 let mut lines = vec![match summary.reason.as_str() {
534 "no-table" => say("spam-app-last-new", &[("when", at.clone())]),
535 "unreadable" => say("spam-app-last-unreadable", &[("when", at.clone())]),
536 "nothing-new" => say("spam-app-last-nothing-new", &[("when", at.clone())]),
537 "worse" => counted("spam-app-last-kept"),
538 _ => counted("spam-app-last-replaced"),
539 }];
540 if let Some(refit) = &summary.refit {
541 lines.push(say("spam-app-refit", &[("n", grouped(refit.ham + refit.spam))]));
542 }
543 let split = &summary.split;
544 let since = jiff::Timestamp::from_second(split.test_from).ok().filter(|_| split.test_from > 0).map(|t| tr().day(t.to_zoned(jiff::tz::TimeZone::system()).date())).unwrap_or_default();
545 lines.push(say(
546 "spam-app-learned",
547 &[("ham", grouped(split.train_ham)), ("spam", grouped(split.train_spam)), ("since", since), ("testham", grouped(split.test_ham)), ("testspam", grouped(split.test_spam))],
548 ));
549 for (at, id) in [(&summary.test.at_spam, "spam-app-at-spam"), (&summary.test.at_unsure, "spam-app-at-unsure")] {
550 lines.push(say(
551 id,
552 &[
553 ("threshold", percent(at.threshold, 0)),
554 ("ham", percent(at.ham_called_spam.fraction(), 1)),
555 ("hamlow", percent(at.ham_called_spam.low, 1)),
556 ("hamhigh", percent(at.ham_called_spam.high, 1)),
557 ("spam", percent(at.spam_caught.fraction(), 1)),
558 ("spamlow", percent(at.spam_caught.low, 1)),
559 ("spamhigh", percent(at.spam_caught.high, 1)),
560 ],
561 ));
562 }
563 lines.push(tr().text("spam-intervals", None));
564 for (source, outside) in &summary.outside {
566 lines.push(say(
567 "spam-app-outside-learned",
568 &[("source", source.clone()), ("trainham", grouped(outside.train_ham)), ("trainspam", grouped(outside.train_spam)), ("heldham", grouped(outside.held_ham)), ("heldspam", grouped(outside.held_spam))],
569 ));
570 if let Some(baseline) = &outside.baseline {
571 let at = &baseline.at_spam;
572 lines.push(say(
573 "spam-app-outside-baseline",
574 &[("threshold", percent(at.threshold, 0)), ("ham", percent(at.ham_called_spam.fraction(), 1)), ("spam", percent(at.spam_caught.fraction(), 1)), ("auc", baseline.auc.map_or_else(String::new, |a| format!("{a:.3}").replace('.', &tr().text("decimal-separator", None))))],
575 ));
576 }
577 }
578 let (spam, unsure) = load_config().spam.thresholds();
580 let moved = |then: f64, now: f32| (then - f64::from(now)).abs() > 0.005;
581 if moved(summary.threshold_spam, spam) || moved(summary.threshold_unsure, unsure) {
582 lines.push(say("spam-app-thresholds-then", &[("spam", percent(summary.threshold_spam, 0)), ("unsure", percent(summary.threshold_unsure, 0))]));
583 }
584 lines
585 }
586
587 fn corpus_lines(dirs: &Dirs) -> Vec<String> {
589 let status = corpus::status(dirs);
590 let mut lines = Vec::new();
591 let records: u64 = status.accounts.iter().flat_map(|a| a.folders.iter()).map(|f| f.records).sum();
592 let bytes: u64 = status.accounts.iter().map(|a| a.bytes).sum();
593 if records == 0 {
594 lines.push(tr().text("spam-app-corpus-none", None));
595 } else {
596 let last = status.last_run.map(when).unwrap_or_default();
597 lines.push(say("spam-app-corpus", &[("records", grouped(records)), ("accounts", status.accounts.len().to_string()), ("size", grouped(bytes >> 20)), ("when", last)]));
598 }
599 if let Some(held) = status.held {
600 lines.push(say("spam-held", &[("free", grouped(held.free >> 20))]));
601 }
602 if let Some(free) = corpus::free_space(&dirs.data).or_else(|| corpus::free_space(&sioul_core::config::data_dir())) {
603 lines.push(say("spam-app-room", &[("free", format!("{:.1}", free as f64 / f64::from(1u32 << 30)).replace('.', &tr().text("decimal-separator", None)))]));
604 }
605 for (account, detail) in &status.errors {
606 lines.push(say("spam-account-failed", &[("account", account.clone()), ("detail", detail.lines().next().unwrap_or_default().to_string())]));
607 }
608 lines
609 }
610
611 fn lower_priority() {
615 #[cfg(target_os = "linux")]
616 unsafe {
618 libc::setpriority(libc::PRIO_PROCESS, 0, 10);
619 }
620 }
621
622 #[cfg(test)]
623 mod tests {
624 use super::*;
625
626 fn place(detail: &str, done: u64, total: u64) -> Progress {
627 Progress { stage: Stage::Corpus, done, total, detail: detail.into() }
628 }
629
630 #[test]
634 fn messages_fetched_of_about_all() {
635 let mut fetch = Fetch { folders: BTreeMap::new(), left: BTreeMap::from([("home".to_string(), 100), ("work".to_string(), 50)]), overall: None };
636 assert_eq!(fetch.note(&place("home · INBOX", 0, 30)), (0, 30 + 50));
637 assert_eq!(fetch.note(&place("home · INBOX", 10, 30)), (10, 80));
638 assert_eq!(fetch.note(&place("home · Archive", 5, 20)), (15, 100));
639 assert_eq!(fetch.note(&place("work · INBOX", 1, 7)), (16, 57));
640 let mut fetch = Fetch { folders: BTreeMap::new(), left: BTreeMap::new(), overall: None };
642 assert_eq!(fetch.note(&place("", 0, 400)), (0, 400));
643 assert_eq!(fetch.note(&place("home · INBOX", 120, 300)), (120, 400));
644 assert_eq!(fetch.note(&place("home · Junk", 300, 300)), (420, 420), "never fewer than what came");
645 }
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use sioul_core::card::Card;
653 use sioul_core::porch::Triaged;
654 use sioul_core::spam::{Actions, Filter};
655
656 #[test]
660 fn what_the_filter_moves_or_flags_after_a_fetch() {
661 let triaged = |n: u32, lane: Lane, reasons: Vec<Reason>| {
662 let mut card = Card::from_bytes(b"From: Prize <win@lottery.test>\r\nSubject: You won\r\n\r\nClaim it.\r\n").unwrap();
663 card.path = Some(PathBuf::from(format!("/mail/home/new/1759400000.U1-{n}.sioul")));
664 Triaged { card, lane, trust: sioul_core::trust::Trust::Unverified, code: None, reasons, priority: Default::default(), checks: None, assessment: None }
665 };
666 let filter = |spam: Action, unsure: Action| Filter { actions: Actions { spam, unsure, ham: Action::Nothing }, threshold_spam: 0.95, threshold_unsure: 0.5, table: PathBuf::new() };
667 let judged = vec![
668 triaged(1, Lane::Review, vec![Reason::LearnedSpam { p: 0.99 }]),
669 triaged(2, Lane::Review, vec![Reason::Unsure { p: 0.6 }]),
670 triaged(3, Lane::Review, vec![Reason::LearnedSpam { p: 0.99 }, Reason::MovedToJunk]),
671 triaged(4, Lane::Screener, vec![Reason::FirstMessage]),
672 ];
673 let file = |n: u32| PathBuf::from(format!("/mail/home/new/1759400000.U1-{n}.sioul"));
674 assert_eq!(chosen(&judged, &filter(Action::Move, Action::Flag), Action::Move), vec![(file(1), Class::Spam)]);
675 assert_eq!(chosen(&judged, &filter(Action::Move, Action::Flag), Action::Flag), vec![(file(2), Class::Unsure)], "the doubt flagged");
676 assert_eq!(chosen(&judged, &filter(Action::Move, Action::Move), Action::Move).len(), 2, "the doubt too, never twice");
677 assert!(chosen(&judged, &filter(Action::Flag, Action::Flag), Action::Move).is_empty(), "flagged only: nothing moves");
678 assert_eq!(chosen(&judged, &filter(Action::Flag, Action::Flag), Action::Flag), vec![(file(1), Class::Spam), (file(2), Class::Unsure)], "each flag once: what was moved is not flagged");
679 }
680}