use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rmut_core::format::{self, IndexFields};
use rmut_core::maildir;
use rmut_core::message::{self, Envelope};
use rmut_core::thread;
const DEFAULT_INDEX_FORMAT: &str = "%4C %Z %{%b %d} %-15.15L (%?l?%4l&%4c?) %s";
fn examples() -> usize {
std::env::var("RMUT_CORPUS_EXAMPLES")
.ok()
.and_then(|n| n.parse().ok())
.unwrap_or(5)
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let dirs = if args.is_empty() {
default_dirs()
} else {
args.iter().map(PathBuf::from).collect()
};
if dirs.is_empty() {
eprintln!("no maildirs found; pass one as an argument");
std::process::exit(2);
}
let fmt = index_format();
println!("index format: {fmt}\n");
let panics: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&panics);
panic::set_hook(Box::new(move |info| {
sink.lock().unwrap().push(info.to_string());
}));
let mut total = Report::default();
for dir in &dirs {
let mut report = Report::default();
sweep(dir, &fmt, &panics, &mut report);
report.print(&dir.display().to_string());
total.absorb(report);
}
let _ = panic::take_hook();
if dirs.len() > 1 {
total.print("all folders");
}
let fatal = total.panicked.len() + total.unparsed.len() + total.lost.len();
println!(
"\n{}",
if fatal == 0 {
format!("{} messages, no panics and no parse failures", total.seen)
} else {
format!("{fatal} of {} messages need looking at", total.seen)
}
);
std::process::exit(if fatal == 0 { 0 } else { 1 });
}
fn default_dirs() -> Vec<PathBuf> {
let Some(home) = std::env::var_os("HOME") else {
return Vec::new();
};
let cache = Path::new(&home).join(".cache/rmut/imap");
let mut out = Vec::new();
let Ok(accounts) = cache.read_dir() else {
return out;
};
for account in accounts.flatten() {
let Ok(folders) = account.path().read_dir() else {
continue;
};
for folder in folders.flatten() {
let path = folder.path();
if path.join("cur").is_dir() {
out.push(path);
}
}
}
out.sort();
out
}
fn index_format() -> String {
let Some(home) = std::env::var_os("HOME") else {
return DEFAULT_INDEX_FORMAT.into();
};
let path = Path::new(&home).join(".config/rmut/config.toml");
let Ok(text) = std::fs::read_to_string(path) else {
return DEFAULT_INDEX_FORMAT.into();
};
let mut in_index = false;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') {
in_index = line == "[index]";
} else if in_index
&& let Some(value) = line.strip_prefix("format")
&& let Some(value) = value.trim_start().strip_prefix('=')
{
return value.trim().trim_matches('"').to_string();
}
}
DEFAULT_INDEX_FORMAT.into()
}
#[derive(Default)]
struct Report {
seen: usize,
panicked: Vec<String>,
unparsed: Vec<String>,
lost: Vec<String>,
undated: Vec<String>,
undecoded: Vec<String>,
replacement: Vec<String>,
no_msg_id: Vec<String>,
control_char: Vec<String>,
}
impl Report {
fn absorb(&mut self, other: Report) {
self.seen += other.seen;
for (mine, theirs) in [
(&mut self.panicked, other.panicked),
(&mut self.unparsed, other.unparsed),
(&mut self.lost, other.lost),
(&mut self.undated, other.undated),
(&mut self.undecoded, other.undecoded),
(&mut self.replacement, other.replacement),
(&mut self.no_msg_id, other.no_msg_id),
(&mut self.control_char, other.control_char),
] {
mine.extend(theirs);
}
}
fn print(&self, title: &str) {
println!("{title}: {} messages", self.seen);
let verdicts = [
("PANIC", &self.panicked),
("parse failed", &self.unparsed),
("lost in threading", &self.lost),
];
let signals = [
("no usable Date", &self.undated),
("undecoded =?...?=", &self.undecoded),
("U+FFFD after decoding", &self.replacement),
("no Message-ID", &self.no_msg_id),
("control char in index line", &self.control_char),
];
for (label, items) in verdicts.into_iter().chain(signals) {
if items.is_empty() {
continue;
}
let pct = 100.0 * items.len() as f64 / self.seen.max(1) as f64;
println!(" {label}: {} ({pct:.2}%)", items.len());
let shown = examples();
for item in items.iter().take(shown) {
println!(" {item}");
}
if items.len() > shown {
println!(" ... and {} more", items.len() - shown);
}
}
println!();
}
}
fn sweep(dir: &Path, fmt: &str, panics: &Arc<Mutex<Vec<String>>>, report: &mut Report) {
let files = match maildir::scan(dir) {
Ok(files) => files,
Err(err) => {
eprintln!("{}: {err:#}", dir.display());
return;
}
};
report.seen = files.len();
let mut envs: Vec<Envelope> = Vec::with_capacity(files.len());
for file in files {
let path = file.path.display().to_string();
match panic::catch_unwind(AssertUnwindSafe(|| message::envelope(file))) {
Ok(Ok(env)) => envs.push(env),
Ok(Err(err)) => report.unparsed.push(format!("{path}: {err:#}")),
Err(_) => report.panicked.push(format!("{path}: {}", took(panics))),
}
}
for env in &envs {
let path = env.file.path.display().to_string();
if env.date == 0 {
report.undated.push(path.clone());
}
if env.msg_id.is_none() {
report.no_msg_id.push(path.clone());
}
if env.subject.contains("=?") || env.from_full.contains("=?") {
report
.undecoded
.push(format!("{path}: {}", squash(&env.subject)));
}
if env.subject.contains('\u{fffd}') || env.from.contains('\u{fffd}') {
report
.replacement
.push(format!("{path}: {}", squash(&env.subject)));
}
}
check_threading(&envs, report, panics);
check_rendering(&envs, fmt, report, panics);
}
fn check_threading(envs: &[Envelope], report: &mut Report, panics: &Arc<Mutex<Vec<String>>>) {
let refs: Vec<&Envelope> = envs.iter().collect();
let items = match panic::catch_unwind(AssertUnwindSafe(|| thread::thread(&refs))) {
Ok(items) => items,
Err(_) => {
report
.panicked
.push(format!("threading the folder: {}", took(panics)));
return;
}
};
let mut times = vec![0usize; envs.len()];
for item in &items {
match times.get_mut(item.index) {
Some(slot) => *slot += 1,
None => report
.lost
.push(format!("threading returned index {}", item.index)),
}
}
for (env, n) in envs.iter().zip(times) {
match n {
1 => {}
0 => report
.lost
.push(format!("{}: dropped by threading", env.file.path.display())),
n => report
.lost
.push(format!("{}: threaded {n} times", env.file.path.display())),
}
}
}
fn check_rendering(
envs: &[Envelope],
fmt: &str,
report: &mut Report,
panics: &Arc<Mutex<Vec<String>>>,
) {
for (n, env) in envs.iter().enumerate() {
let date = message::format_index_date(env.date);
let size = env.file.size.to_string();
let fields = IndexFields {
number: n + 1,
status: env.file.flags.status_char(env.file.is_new),
flag: ' ',
mark: ' ',
date: &date,
from: &env.from,
size: &size,
lines: env.lines,
list: env.list.as_deref(),
hidden: None,
label: env.label.as_deref(),
subject: &env.subject,
};
let line = match panic::catch_unwind(AssertUnwindSafe(|| format::render(fmt, &fields))) {
Ok(line) => line,
Err(_) => {
report.panicked.push(format!(
"{}: rendering the index line: {}",
env.file.path.display(),
took(panics)
));
continue;
}
};
if line.chars().any(|c| c.is_control()) {
report
.control_char
.push(format!("{}: {}", env.file.path.display(), squash(&line)));
}
}
}
fn took(panics: &Arc<Mutex<Vec<String>>>) -> String {
panics
.lock()
.unwrap()
.pop()
.unwrap_or_else(|| "no panic message".into())
}
fn squash(text: &str) -> String {
let flat: String = text
.chars()
.map(|c| if c.is_control() { '.' } else { c })
.collect();
if flat.chars().count() > 70 {
format!("{}...", flat.chars().take(70).collect::<String>())
} else {
flat
}
}