use std::collections::HashMap;
use crate::message::Envelope;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ThreadedItem {
pub index: usize,
pub depth: usize,
pub root: usize,
pub pseudo: bool,
}
struct Container {
message: Option<usize>,
parent: Option<usize>,
children: Vec<usize>,
pseudo: bool,
}
fn get_or_create(
by_id: &mut HashMap<String, usize>,
arena: &mut Vec<Container>,
id: &str,
) -> usize {
if let Some(&c) = by_id.get(id) {
return c;
}
arena.push(Container {
message: None,
parent: None,
children: Vec::new(),
pseudo: false,
});
let idx = arena.len() - 1;
by_id.insert(id.to_string(), idx);
idx
}
fn is_ancestor(arena: &[Container], ancestor: usize, mut node: usize) -> bool {
loop {
if node == ancestor {
return true;
}
match arena[node].parent {
Some(p) => node = p,
None => return false,
}
}
}
fn link(arena: &mut [Container], rooted: &[bool], parent: usize, child: usize) {
if parent == child
|| arena[child].parent.is_some()
|| rooted[child]
|| is_ancestor(arena, child, parent)
{
return;
}
arena[child].parent = Some(parent);
arena[parent].children.push(child);
}
fn subtree_date(arena: &[Container], envs: &[&Envelope], node: usize, newest: bool) -> i64 {
let own = arena[node]
.message
.map(|m| envs[m].date)
.unwrap_or(if newest { i64::MIN } else { i64::MAX });
let fold = if newest { i64::max } else { i64::min };
arena[node]
.children
.iter()
.map(|&c| subtree_date(arena, envs, c, newest))
.fold(own, fold)
}
fn real_children(arena: &[Container], node: usize, out: &mut Vec<usize>) {
for &child in &arena[node].children {
if arena[child].message.is_some() {
out.push(child);
} else {
real_children(arena, child, out);
}
}
}
fn emit(
arena: &[Container],
envs: &[&Envelope],
node: usize,
depth: usize,
root: usize,
newest: bool,
out: &mut Vec<ThreadedItem>,
) {
let index = arena[node].message.expect("emit called on empty container");
out.push(ThreadedItem {
index,
depth,
root,
pseudo: arena[node].pseudo,
});
let mut kids = Vec::new();
real_children(arena, node, &mut kids);
kids.sort_by_key(|&k| subtree_date(arena, envs, k, newest));
for kid in kids {
emit(arena, envs, kid, depth + 1, root, newest, out);
}
}
pub fn thread(envs: &[&Envelope]) -> Vec<ThreadedItem> {
thread_by(envs, ThreadOrder::default())
}
#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
pub struct ThreadOrder {
pub newest: bool,
pub reverse: bool,
}
impl ThreadOrder {
pub fn parse(spec: &str) -> ThreadOrder {
let spec = spec.trim().to_lowercase();
let (reverse, rest) = match spec.strip_prefix("reverse-") {
Some(rest) => (true, rest.to_string()),
None => (false, spec),
};
ThreadOrder {
newest: rest.starts_with("last-"),
reverse,
}
}
}
#[derive(Clone, Copy)]
pub struct SubjectFallback<'a> {
pub reply_re: &'a regex_lite::Regex,
pub sort_re: bool,
}
fn real_subject<'a>(re: ®ex_lite::Regex, subject: &'a str) -> (&'a str, bool) {
match re.find(subject) {
Some(m) if m.start() == 0 => (subject[m.end()..].trim(), true),
_ => (subject.trim(), false),
}
}
fn message_ancestor(arena: &[Container], node: usize) -> Option<usize> {
let mut at = arena[node].parent;
while let Some(p) = at {
if arena[p].message.is_some() {
return Some(p);
}
at = arena[p].parent;
}
None
}
fn group_by_subject(
arena: &mut [Container],
envs: &[&Envelope],
top: &mut Vec<usize>,
sub: &SubjectFallback,
) {
let mut candidates: HashMap<&str, Vec<usize>> = HashMap::new();
for node in 0..arena.len() {
let Some(m) = arena[node].message else {
continue;
};
let subj = real_subject(sub.reply_re, &envs[m].subject).0;
let changed = match message_ancestor(arena, node) {
Some(p) => {
let pm = arena[p]
.message
.expect("message_ancestor carries a message");
real_subject(sub.reply_re, &envs[pm].subject).0 != subj
}
None => true,
};
if changed {
candidates.entry(subj).or_default().push(node);
}
}
let mut roots: Vec<usize> = top.clone();
roots.sort_by_key(|&r| {
let m = arena[r].message.expect("a thread root carries a message");
(envs[m].date, m)
});
for cur in roots {
let m = arena[cur].message.expect("a thread root carries a message");
if envs[m].broken {
continue;
}
let (subj, is_reply) = real_subject(sub.reply_re, &envs[m].subject);
if sub.sort_re && !is_reply {
continue;
}
let here = (envs[m].date, m);
let mut best: Option<((i64, usize), usize)> = None;
for &t in candidates.get(subj).map(Vec::as_slice).unwrap_or_default() {
if t == cur || arena[t].pseudo {
continue;
}
let tm = arena[t].message.expect("a candidate carries a message");
let there = (envs[tm].date, tm);
if there >= here || is_ancestor(arena, cur, t) {
continue;
}
if best.is_none_or(|(seen, _)| seen < there) {
best = Some((there, t));
}
}
if let Some((_, parent)) = best {
arena[cur].parent = Some(parent);
arena[parent].children.push(cur);
arena[cur].pseudo = true;
}
}
top.retain(|&t| arena[t].parent.is_none());
}
pub fn thread_by(envs: &[&Envelope], order: ThreadOrder) -> Vec<ThreadedItem> {
thread_with(envs, order, None)
}
pub fn thread_with(
envs: &[&Envelope],
order: ThreadOrder,
subject: Option<&SubjectFallback>,
) -> Vec<ThreadedItem> {
let newest = order.newest;
let mut arena: Vec<Container> = Vec::new();
let mut by_id: HashMap<String, usize> = HashMap::new();
let mut container_of = Vec::with_capacity(envs.len());
for (i, env) in envs.iter().enumerate() {
let id = env
.msg_id
.clone()
.unwrap_or_else(|| format!("<rmut-missing-{i}>"));
let mut container = get_or_create(&mut by_id, &mut arena, &id);
if arena[container].message.is_some() {
arena.push(Container {
message: None,
parent: None,
children: Vec::new(),
pseudo: false,
});
container = arena.len() - 1;
}
arena[container].message = Some(i);
container_of.push(container);
}
let mut rooted: Vec<bool> = arena
.iter()
.map(|c| c.message.is_some_and(|m| envs[m].references.is_empty()))
.collect();
for (i, env) in envs.iter().enumerate() {
let container = container_of[i];
let mut prev: Option<usize> = None;
for rid in &env.references {
let r = get_or_create(&mut by_id, &mut arena, rid);
rooted.resize(arena.len(), false);
if r == container {
continue;
}
if let Some(p) = prev {
link(&mut arena, &rooted, p, r);
}
prev = Some(r);
}
if let Some(p) = prev {
link(&mut arena, &rooted, p, container);
}
}
let mut top = Vec::new();
for i in 0..arena.len() {
if arena[i].parent.is_none() {
if arena[i].message.is_some() {
top.push(i);
} else {
real_children(&arena, i, &mut top);
}
}
}
if let Some(sub) = subject {
group_by_subject(&mut arena, envs, &mut top, sub);
}
let envs_ref = envs;
top.sort_by_key(|&t| subtree_date(&arena, envs_ref, t, newest));
if order.reverse {
top.reverse();
}
let mut out = Vec::new();
for t in top {
let root = arena[t].message.expect("top containers carry messages");
emit(&arena, envs_ref, t, 0, root, newest, &mut out);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::maildir::{Flags, MailFile};
fn env(id: &str, refs: &[&str], date: i64) -> Envelope {
Envelope {
file: MailFile {
path: format!("/mail/{id}").into(),
is_new: false,
flags: Flags::default(),
size: 0,
},
from: "x".into(),
from_full: "x".into(),
subject: id.into(),
date,
msg_id: (!id.is_empty()).then(|| format!("<{id}>")),
references: refs.iter().map(|r| format!("<{r}>")).collect(),
tagged: false,
to: vec![],
cc: vec![],
lines: Some(0),
list: None,
label: None,
broken: false,
}
}
fn subj(id: &str, subject: &str, refs: &[&str], date: i64) -> Envelope {
Envelope {
subject: subject.into(),
..env(id, refs, date)
}
}
fn run_subject(envs: &[Envelope], sort_re: bool) -> Vec<(usize, usize, bool)> {
let re = crate::compose::default_reply_regexp();
let fallback = SubjectFallback {
reply_re: &re,
sort_re,
};
let refs: Vec<&Envelope> = envs.iter().collect();
thread_with(&refs, ThreadOrder::default(), Some(&fallback))
.iter()
.map(|i| (i.index, i.depth, i.pseudo))
.collect()
}
fn run(envs: &[Envelope]) -> Vec<(usize, usize)> {
let refs: Vec<&Envelope> = envs.iter().collect();
thread(&refs).iter().map(|i| (i.index, i.depth)).collect()
}
#[test]
fn chain_nests_by_references() {
let envs = [
env("a", &[], 1),
env("b", &["a"], 2),
env("c", &["a", "b"], 3),
];
assert_eq!(run(&envs), vec![(0, 0), (1, 1), (2, 2)]);
let refs: Vec<&Envelope> = envs.iter().collect();
assert!(thread(&refs).iter().all(|i| i.root == 0));
}
#[test]
fn unrelated_messages_are_separate_threads_by_date() {
let envs = [env("b", &[], 5), env("a", &[], 2)];
assert_eq!(run(&envs), vec![(1, 0), (0, 0)]);
}
#[test]
fn missing_parent_promotes_children() {
let envs = [env("b", &["ghost"], 2), env("c", &["ghost"], 3)];
assert_eq!(run(&envs), vec![(0, 0), (1, 0)]);
}
#[test]
fn missing_middle_of_chain_is_bridged() {
let envs = [env("a", &[], 1), env("c", &["a", "b-missing"], 3)];
assert_eq!(run(&envs), vec![(0, 0), (1, 1)]);
}
#[test]
fn newest_orders_threads_by_their_latest_message() {
let envs = [env("a", &[], 10), env("a2", &["a"], 100), env("b", &[], 50)];
let refs: Vec<&Envelope> = envs.iter().collect();
let order = |spec: &str| -> Vec<usize> {
thread_by(&refs, ThreadOrder::parse(spec))
.iter()
.map(|t| t.index)
.collect()
};
assert_eq!(order("date"), vec![0, 1, 2], "thread A first by its oldest");
assert_eq!(
order("last-date-sent"),
vec![2, 0, 1],
"thread B first, A has the newest last"
);
assert_eq!(order("reverse-date"), vec![2, 0, 1]);
assert_eq!(order("reverse-last-date-received"), vec![0, 1, 2]);
}
#[test]
fn sort_aux_spellings_parse_the_way_mutt_writes_them() {
assert_eq!(ThreadOrder::parse("date"), ThreadOrder::default());
assert_eq!(
ThreadOrder::parse("last-date-received"),
ThreadOrder {
newest: true,
reverse: false
}
);
assert_eq!(
ThreadOrder::parse("reverse-last-date-sent"),
ThreadOrder {
newest: true,
reverse: true
}
);
assert_eq!(
ThreadOrder::parse("REVERSE-DATE"),
ThreadOrder {
newest: false,
reverse: true
}
);
}
#[test]
fn siblings_sorted_by_date() {
let envs = [
env("a", &[], 1),
env("late", &["a"], 9),
env("early", &["a"], 2),
];
assert_eq!(run(&envs), vec![(0, 0), (2, 1), (1, 1)]);
}
#[test]
fn duplicates_and_self_references_do_not_panic() {
let envs = [
env("a", &[], 1),
env("a", &[], 2), env("s", &["s"], 3), env("", &[], 4), ];
let out = run(&envs);
assert_eq!(out.len(), 4);
let mut seen: Vec<usize> = out.iter().map(|(i, _)| *i).collect();
seen.sort_unstable();
assert_eq!(seen, vec![0, 1, 2, 3]);
}
#[test]
fn reference_loops_are_broken() {
let envs = [env("a", &["b"], 1), env("b", &["a"], 2)];
let out = run(&envs);
assert_eq!(out.len(), 2);
}
#[test]
fn a_message_without_references_is_never_reparented_by_its_replies() {
let envs = [env("a", &[], 1), env("b", &[], 2), env("c", &["a", "b"], 3)];
assert_eq!(run(&envs), vec![(0, 0), (1, 0), (2, 1)]);
let envs = [
env("a", &[], 1),
env("b", &["a"], 2),
env("c", &["a", "b"], 3),
];
assert_eq!(run(&envs), vec![(0, 0), (1, 1), (2, 2)]);
let envs = [env("p", &[], 1), env("c", &["gone", "p"], 2)];
assert_eq!(run(&envs), vec![(0, 0), (1, 1)]);
}
#[test]
fn subject_groups_mail_that_carries_no_references() {
let envs = [
subj("a", "Re: proj | a change (!1661)", &[], 10),
subj("b", "Re: proj | a change (!1661)", &[], 20),
subj("c", "Re: proj | a change (!1661)", &[], 30),
];
assert_eq!(
run_subject(&envs, true),
vec![(0, 0, false), (1, 1, true), (2, 1, true)]
);
assert_eq!(run(&envs), vec![(0, 0), (1, 0), (2, 0)]);
}
#[test]
fn sort_re_decides_whether_a_plain_subject_joins() {
let envs = [
subj("a", "hi", &[], 10),
subj("b", "Re: hi", &[], 20),
subj("c", "hi", &[], 30),
subj("d", "Re: other", &[], 40),
];
assert_eq!(
run_subject(&envs, true),
vec![(0, 0, false), (1, 1, true), (2, 0, false), (3, 0, false)]
);
assert_eq!(
run_subject(&envs, false),
vec![(0, 0, false), (1, 1, true), (2, 1, true), (3, 0, false)]
);
}
#[test]
fn a_renamed_reply_is_the_parent_for_its_own_subject() {
let envs = [
subj("a", "hi", &[], 10),
subj("b", "Re: hi", &["a"], 20),
subj("m", "Re: newtopic", &["a", "b"], 30),
subj("n", "Re: newtopic", &[], 40),
];
assert_eq!(
run_subject(&envs, true),
vec![(0, 0, false), (1, 1, false), (2, 2, false), (3, 3, true)]
);
}
#[test]
fn a_subject_child_brings_its_own_replies_with_it() {
let envs = [
subj("a", "hi", &[], 10),
subj("b", "Re: hi", &[], 20),
subj("c", "Re: hi", &["b"], 30),
];
assert_eq!(
run_subject(&envs, true),
vec![(0, 0, false), (1, 1, true), (2, 2, false)]
);
}
#[test]
fn the_subject_pass_never_loops_or_reparents_a_real_child() {
let envs = [
subj("a", "Re: same", &[], 30),
subj("b", "Re: same", &["a"], 10),
];
let out = run_subject(&envs, true);
assert_eq!(out.len(), 2);
assert_eq!(out, vec![(0, 0, false), (1, 1, false)]);
}
}