1pub(crate) mod by_itself;
24pub(crate) mod jobs;
25pub(crate) mod report;
26
27use crate::Session;
28use clap::Subcommand;
29use report::Report;
30use sioul_core::spam::Action;
31use sioul_core::spam::labels::Label;
32use sioul_learn::{Cancel, Dirs, Progress, Stage, external, train};
33use std::io::{IsTerminal, Write};
34use std::path::PathBuf;
35
36#[derive(Subcommand)]
37pub(crate) enum SpamCommand {
38 Fetch {
43 #[arg(long)]
44 account: Option<String>,
45 #[arg(long)]
48 verify: bool,
49 #[arg(long)]
51 json: bool,
52 #[arg(long, hide = true)]
54 job: Option<String>,
55 },
56 Train {
59 #[arg(long)]
61 no_fetch: bool,
62 #[arg(long)]
65 no_replace: bool,
66 #[arg(long, value_parser = clap::value_parser!(u64).range(0..=500))]
69 errors: Option<u64>,
70 #[arg(long)]
74 scores: bool,
75 #[command(flatten)]
76 settings: SettingsArgs,
77 #[arg(long)]
79 json: bool,
80 #[arg(long, hide = true)]
82 job: Option<String>,
83 #[arg(long, hide = true)]
88 by_itself: bool,
89 },
90 Eval {
93 #[arg(long, default_value_t = 0, value_parser = clap::value_parser!(u64).range(0..=500))]
96 errors: u64,
97 #[arg(long)]
98 json: bool,
99 },
100 Status {
102 #[arg(long)]
103 json: bool,
104 },
105 DryRun {
109 #[arg(long)]
111 account: Option<String>,
112 #[arg(long, default_value_t = 50)]
114 limit: usize,
115 #[command(flatten)]
116 matrix: MatrixArgs,
117 #[arg(long)]
118 json: bool,
119 },
120 Features {
123 #[arg(long)]
125 domain: Option<String>,
126 #[arg(long)]
127 json: bool,
128 },
129 Review {
131 #[arg(long, default_value_t = 100)]
132 limit: usize,
133 #[arg(long)]
134 json: bool,
135 },
136 Label {
140 message: String,
142 #[arg(value_parser = ["spam", "ham"])]
144 label: String,
145 #[arg(long = "move", overrides_with = "no_move")]
148 moving: bool,
149 #[arg(long)]
151 no_move: bool,
152 #[arg(long)]
153 json: bool,
154 },
155 Job {
158 id: Option<String>,
159 #[arg(long, requires = "id")]
161 stop: bool,
162 #[arg(long)]
163 json: bool,
164 },
165 Import {
172 #[arg(required_unless_present = "remove", conflicts_with = "remove")]
174 file: Option<PathBuf>,
175 #[arg(long)]
177 source: Option<String>,
178 #[arg(long, value_name = "SOURCE")]
180 remove: Option<String>,
181 },
182 Why { file: PathBuf },
185}
186
187#[derive(clap::Args, Debug, Clone, Default)]
190pub(crate) struct SettingsArgs {
191 #[arg(long, value_parser = ["supervised", "centroid"])]
195 model: Option<String>,
196 #[arg(long)]
198 threads: Option<u32>,
199 #[arg(long)]
201 dim: Option<u32>,
202 #[arg(long)]
204 epochs: Option<u32>,
205 #[arg(long)]
207 bucket: Option<u32>,
208 #[arg(long)]
210 minn: Option<u32>,
211 #[arg(long)]
213 maxn: Option<u32>,
214 #[arg(long)]
216 min_count: Option<u32>,
217 #[arg(long)]
219 lr: Option<f64>,
220 #[arg(long, hide = true)]
223 word_ngrams: Option<u32>,
224 #[arg(long)]
226 c: Option<f64>,
227 #[arg(long)]
229 ham_weight: Option<f64>,
230}
231
232impl SettingsArgs {
233 fn settings(&self) -> report::Settings {
234 report::Settings {
235 threads: self.threads,
236 dim: self.dim,
237 epochs: self.epochs,
238 bucket: self.bucket,
239 minn: self.minn,
240 maxn: self.maxn,
241 c: self.c,
242 ham_weight: self.ham_weight,
243 model: self.model.clone(),
244 word_ngrams: self.word_ngrams,
245 lr: self.lr,
246 min_count: self.min_count,
247 }
248 }
249}
250
251#[derive(clap::Args, Debug, Clone, Default)]
253pub(crate) struct MatrixArgs {
254 #[arg(long, value_parser = ["move", "flag", "nothing"])]
256 spam: Option<String>,
257 #[arg(long, value_parser = ["move", "flag", "nothing"])]
259 unsure: Option<String>,
260 #[arg(long, value_parser = ["move", "flag", "nothing"])]
262 ham: Option<String>,
263 #[arg(long)]
265 threshold_spam: Option<f32>,
266 #[arg(long)]
268 threshold_unsure: Option<f32>,
269}
270
271pub(crate) fn run(s: &Session, command: SpamCommand) -> Result<(), String> {
272 let dirs = Dirs::standard();
273 match command {
274 SpamCommand::Fetch { account, verify: false, json, job } => long(s, json, job.as_deref(), |progress, cancel| report::fetch(s, &dirs, account.as_deref(), progress, cancel)),
275 SpamCommand::Fetch { account, verify: true, json, job } => long(s, json, job.as_deref(), |progress, cancel| report::verify(s, &dirs, account.as_deref(), progress, cancel)),
276 SpamCommand::Train { no_fetch, no_replace, errors, scores, settings, json, job, by_itself } => {
277 if by_itself && job.is_none() {
279 let job = by_itself::start(s, !no_fetch, settings.threads)?;
280 let line = s.say("spam-job-started", &[("id", job.id.clone()), ("kind", job.kind.clone())]);
281 return say(Report { lines: vec![line], data: serde_json::json!({ "job": job.id }) }, json);
282 }
283 let ask = report::TrainAsk { fetch: !no_fetch, replace: !no_replace, errors: errors.map(|n| n as usize), scores, settings: settings.settings() };
284 match job.as_deref() {
285 Some(id) if by_itself => by_itself::run(s, &dirs, id, &ask, json),
286 _ => long(s, json, job.as_deref(), |progress, cancel| report::train(s, &dirs, &ask, progress, cancel)),
287 }
288 }
289 SpamCommand::Eval { errors, json } => long(s, json, None, |progress, cancel| report::eval(s, &dirs, errors as usize, progress, cancel)),
290 SpamCommand::Status { json } => say(report::status(s, &dirs)?, json),
291 SpamCommand::DryRun { account, limit, matrix, json } => {
292 let action = |text: &Option<String>| text.as_deref().and_then(Action::read);
293 let ask = report::DryAsk {
294 account,
295 limit,
296 spam: action(&matrix.spam),
297 unsure: action(&matrix.unsure),
298 ham: action(&matrix.ham),
299 threshold_spam: matrix.threshold_spam,
300 threshold_unsure: matrix.threshold_unsure,
301 };
302 say(report::dry_run(s, &ask)?, json)
303 }
304 SpamCommand::Features { domain, json } => say(report::features(s, &dirs, domain.as_deref())?, json),
305 SpamCommand::Review { limit, json } => say(report::review(s, limit)?, json),
306 SpamCommand::Label { message, label, moving, no_move: _, json } => {
307 let label = if label == "spam" { Label::Spam } else { Label::Ham };
308 say(report::label(s, &dirs, &message, label, moving)?, json)
309 }
310 SpamCommand::Job { id, stop, json } => say(report::job(s, id.as_deref(), stop)?, json),
311 SpamCommand::Import { file, source, remove } => match (file, remove) {
312 (_, Some(source)) => remove_source(s, &dirs, &source),
313 (Some(file), None) => import(s, &dirs, &file, source.as_deref()),
314 (None, None) => Ok(()),
315 },
316 SpamCommand::Why { file } => why(s, &dirs, &file),
317 }
318}
319
320fn say(report: Report, json: bool) -> Result<(), String> {
322 if json {
323 println!("{}", serde_json::to_string_pretty(&report.data).map_err(|e| e.to_string())?);
324 } else {
325 for line in &report.lines {
326 println!("{line}");
327 }
328 }
329 Ok(())
330}
331
332fn long(s: &Session, json: bool, job: Option<&str>, work: impl FnOnce(&mut dyn FnMut(&Progress), &Cancel) -> Result<Report, String>) -> Result<(), String> {
336 match job {
337 Some(id) => {
338 let mut running = jobs::Running::open(id)?;
339 let cancel = running.cancel();
340 let outcome = {
341 let mut see = |p: &Progress| running.see(s, p);
342 work(&mut see, &cancel)
343 };
344 let failed = outcome.as_ref().err().cloned();
345 running.finish(outcome.map(|report| (report.lines, report.data)))?;
346 failed.map_or(Ok(()), Err)
347 }
348 None => {
349 let mut shown = Shown::new();
350 let outcome = work(&mut |p| shown.show(s, p), &Cancel::new());
351 shown.end();
352 say(outcome?, json)
353 }
354 }
355}
356
357struct Shown {
361 stage: Option<Stage>,
362 detail: String,
363 terminal: bool,
364}
365
366impl Shown {
367 fn new() -> Shown {
368 Shown { stage: None, detail: String::new(), terminal: std::io::stderr().is_terminal() }
369 }
370
371 fn show(&mut self, s: &Session, p: &Progress) {
372 let mut err = std::io::stderr();
373 let by_place = matches!(p.stage, Stage::Corpus | Stage::Verify);
375 if self.stage != Some(p.stage) || self.detail != p.detail && by_place {
376 if self.terminal && self.stage.is_some() {
377 let _ = writeln!(err);
378 }
379 if self.stage != Some(p.stage) && p.stage != Stage::Corpus {
380 let _ = writeln!(err, "{}", s.tr.text(&format!("spam-stage-{}", p.stage.as_str()), None));
381 }
382 if p.stage == Stage::Corpus && p.detail.is_empty() && p.total > 0 {
384 let _ = writeln!(err, "{}", s.say("spam-fetch-estimate", &[("n", p.total.to_string())]));
385 }
386 self.stage = Some(p.stage);
387 self.detail = p.detail.clone();
388 }
389 if self.terminal && p.total > 0 && !(p.stage == Stage::Corpus && p.detail.is_empty()) {
390 let what = if p.detail.is_empty() { String::new() } else { format!("{}: ", crate::one_line(&p.detail)) };
391 let _ = write!(err, "\r{what}{} / {} ", p.done, p.total);
392 } else if !self.terminal && by_place && !p.detail.is_empty() && p.total > 0 && p.done == p.total {
393 let _ = writeln!(err, "{}: {} / {}", crate::one_line(&p.detail), p.done, p.total);
394 }
395 }
396
397 fn end(&self) {
398 if self.terminal && self.stage.is_some() {
399 eprintln!();
400 }
401 }
402}
403
404fn import(s: &Session, dirs: &Dirs, file: &std::path::Path, source: Option<&str>) -> Result<(), String> {
406 let imported = external::import(dirs, file, source).map_err(|e| e.to_string())?;
407 println!("{}", s.say("spam-import-lines", &[("n", imported.lines.to_string())]));
408 for (source, counts) in &imported.sources {
409 println!("{}", s.say("spam-import-done", &outside_pairs(s, source, counts)));
410 }
411 for (why, n) in &imported.refused {
412 println!("{}", s.say("spam-import-refused", &[("n", n.to_string()), ("why", s.tr.text(&format!("spam-import-why-{why}"), None))]));
413 }
414 Ok(())
415}
416
417fn remove_source(s: &Session, dirs: &Dirs, source: &str) -> Result<(), String> {
418 let name = external::source_name(source);
419 if external::remove(dirs, &name).map_err(|e| e.to_string())? {
420 println!("{}", s.say("spam-import-removed", &[("source", name)]));
421 Ok(())
422 } else {
423 Err(s.say("spam-import-none", &[("source", name)]))
424 }
425}
426
427fn outside_pairs(s: &Session, source: &str, counts: &external::Counts) -> [(&'static str, String); 5] {
429 [("source", crate::one_line(source)), ("ham", counts.ham.to_string()), ("spam", counts.spam.to_string()), ("first", report::day(s, counts.first)), ("last", report::day(s, counts.last))]
430}
431
432fn why(s: &Session, dirs: &Dirs, file: &std::path::Path) -> Result<(), String> {
433 let raw = std::fs::read(file).map_err(|e| format!("{}: {e}", file.display()))?;
434 let sources = s.config.mail_sources();
436 let trusted = s.config.account_of(file).and_then(|a| sources.iter().find(|src| src.account.as_deref() == Some(a.id.as_str()))).map(|src| src.trusted_ids.clone()).unwrap_or_default();
437 let received = std::fs::metadata(file).ok().and_then(|m| m.modified().ok()).and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs() as i64);
438 let why = train::why(dirs, &raw, &trusted, received).map_err(|detail| s.say("spam-why-none", &[("detail", detail)]))?;
439 println!("{}", s.say("spam-why-score", &[("p", report::decimal(s, why.p, 3)), ("f", report::decimal(s, why.f, 3))]));
440 println!("{}", s.tr.text("spam-why-words", None));
441 for (word, share) in &why.words {
442 println!(" {:<24} {}", crate::one_line(word), signed(s, *share));
443 }
444 println!("{}", s.tr.text("spam-why-headers", None));
445 for (name, share) in &why.headers {
446 println!(" {name:<24} {}", signed(s, *share));
447 }
448 Ok(())
449}
450
451fn signed(s: &Session, value: f64) -> String {
452 let text = report::decimal(s, value, 3);
453 if value >= 0.0 { format!("+{text}") } else { text }
454}