Skip to main content

rmut_core/
thread.rs

1//! Message threading after JWZ's algorithm
2//! (<https://www.jwz.org/doc/threading.html>): containers per message-id,
3//! reference chains linked parent→child with loop protection, empty
4//! containers pruned by promoting their children. Threads and siblings
5//! are ordered by date. JWZ step 5, grouping what is left by subject,
6//! is mutt's `pseudo_threads` here: see [`SubjectFallback`].
7
8use std::collections::HashMap;
9
10use crate::message::Envelope;
11
12/// One index entry in thread order.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub struct ThreadedItem {
15    /// Index into the input slice.
16    pub index: usize,
17    /// Nesting depth (0 = thread root).
18    pub depth: usize,
19    /// Input index of this thread's first (root) message.
20    pub root: usize,
21    /// The subject fallback put this message here, not its own
22    /// References: mutt's fake_thread, which its tree stars.
23    pub pseudo: bool,
24}
25
26struct Container {
27    message: Option<usize>,
28    parent: Option<usize>,
29    children: Vec<usize>,
30    /// Attached by subject rather than by a reference chain.
31    pseudo: bool,
32}
33
34fn get_or_create(
35    by_id: &mut HashMap<String, usize>,
36    arena: &mut Vec<Container>,
37    id: &str,
38) -> usize {
39    if let Some(&c) = by_id.get(id) {
40        return c;
41    }
42    arena.push(Container {
43        message: None,
44        parent: None,
45        children: Vec::new(),
46        pseudo: false,
47    });
48    let idx = arena.len() - 1;
49    by_id.insert(id.to_string(), idx);
50    idx
51}
52
53fn is_ancestor(arena: &[Container], ancestor: usize, mut node: usize) -> bool {
54    loop {
55        if node == ancestor {
56            return true;
57        }
58        match arena[node].parent {
59            Some(p) => node = p,
60            None => return false,
61        }
62    }
63}
64
65/// Link parent→child unless it would create a loop, the child is
66/// already placed, or the child is a message that carries no
67/// references of its own. That last one is what makes break-thread
68/// stick: a reply's References chain still names the broken message's
69/// old ancestors, and without it the chain would quietly put the
70/// message back under them.
71fn link(arena: &mut [Container], rooted: &[bool], parent: usize, child: usize) {
72    if parent == child
73        || arena[child].parent.is_some()
74        || rooted[child]
75        || is_ancestor(arena, child, parent)
76    {
77        return;
78    }
79    arena[child].parent = Some(parent);
80    arena[parent].children.push(child);
81}
82
83/// Earliest (or, with `newest`, latest) date in a container's
84/// subtree, for ordering threads and siblings.
85fn subtree_date(arena: &[Container], envs: &[&Envelope], node: usize, newest: bool) -> i64 {
86    let own = arena[node]
87        .message
88        .map(|m| envs[m].date)
89        .unwrap_or(if newest { i64::MIN } else { i64::MAX });
90    let fold = if newest { i64::max } else { i64::min };
91    arena[node]
92        .children
93        .iter()
94        .map(|&c| subtree_date(arena, envs, c, newest))
95        .fold(own, fold)
96}
97
98/// Children of `node` that carry messages, looking through empty
99/// containers (their children are promoted transparently).
100fn real_children(arena: &[Container], node: usize, out: &mut Vec<usize>) {
101    for &child in &arena[node].children {
102        if arena[child].message.is_some() {
103            out.push(child);
104        } else {
105            real_children(arena, child, out);
106        }
107    }
108}
109
110fn emit(
111    arena: &[Container],
112    envs: &[&Envelope],
113    node: usize,
114    depth: usize,
115    root: usize,
116    newest: bool,
117    out: &mut Vec<ThreadedItem>,
118) {
119    let index = arena[node].message.expect("emit called on empty container");
120    out.push(ThreadedItem {
121        index,
122        depth,
123        root,
124        pseudo: arena[node].pseudo,
125    });
126    let mut kids = Vec::new();
127    real_children(arena, node, &mut kids);
128    kids.sort_by_key(|&k| subtree_date(arena, envs, k, newest));
129    for kid in kids {
130        emit(arena, envs, kid, depth + 1, root, newest, out);
131    }
132}
133
134pub fn thread(envs: &[&Envelope]) -> Vec<ThreadedItem> {
135    thread_by(envs, ThreadOrder::default())
136}
137
138/// Like `thread`, ordering threads by their newest message when
139/// `newest` (mutt's sort_aux = last-date-sent).
140/// How the threads themselves are ordered, from mutt's $sort_aux:
141/// by the root's date or by the newest message under it, oldest
142/// first or newest first.
143#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
144pub struct ThreadOrder {
145    /// mutt's `last-`: order a thread by its newest message rather
146    /// than by its root.
147    pub newest: bool,
148    /// mutt's `reverse-`: the newest thread first.
149    pub reverse: bool,
150}
151
152impl ThreadOrder {
153    /// mutt's $sort_aux, as far as threads care: everything else is
154    /// a date order under another name.
155    pub fn parse(spec: &str) -> ThreadOrder {
156        let spec = spec.trim().to_lowercase();
157        let (reverse, rest) = match spec.strip_prefix("reverse-") {
158            Some(rest) => (true, rest.to_string()),
159            None => (false, spec),
160        };
161        ThreadOrder {
162            newest: rest.starts_with("last-"),
163            reverse,
164        }
165    }
166}
167
168/// mutt's subject fallback, which runs unless `$strict_threads`: a
169/// thread root whose subject repeats one already in the mailbox
170/// hangs under the message carrying it. It is what keeps mail that
171/// arrives with no References at all (a notification robot, a list
172/// that strips the headers) reading as one thread.
173#[derive(Clone, Copy)]
174pub struct SubjectFallback<'a> {
175    /// Compiled $reply_regexp: what marks a subject as a reply, and
176    /// what is taken off it to compare with (mutt's real_subj).
177    pub reply_re: &'a regex_lite::Regex,
178    /// mutt's $sort_re: only a root whose subject carries the reply
179    /// prefix is attached. Unset, any equal subject is, which groups
180    /// unrelated mail sharing a subject like "hi".
181    pub sort_re: bool,
182}
183
184/// mutt's real_subj: the subject with one $reply_regexp match taken
185/// off the front, and whether it was there at all. Both ends are
186/// trimmed, where mutt compares the raw remainder, so a stray
187/// trailing space does not split a thread.
188fn real_subject<'a>(re: &regex_lite::Regex, subject: &'a str) -> (&'a str, bool) {
189    match re.find(subject) {
190        Some(m) if m.start() == 0 => (subject[m.end()..].trim(), true),
191        _ => (subject.trim(), false),
192    }
193}
194
195/// The nearest ancestor carrying a message, looking through the
196/// empty containers a missing parent leaves behind.
197fn message_ancestor(arena: &[Container], node: usize) -> Option<usize> {
198    let mut at = arena[node].parent;
199    while let Some(p) = at {
200        if arena[p].message.is_some() {
201            return Some(p);
202        }
203        at = arena[p].parent;
204    }
205    None
206}
207
208/// mutt's pseudo_threads: every thread root whose subject repeats a
209/// subject already in the mailbox is hung under the message that
210/// carries it, roots taken oldest first. The parent may sit anywhere
211/// in a thread, not only at its root, but it must be a message whose
212/// own subject differs from its parent's (mutt's subject_changed) and
213/// not one that was itself attached this way, so a stray answers the
214/// message that named the subject rather than the last one to repeat
215/// it: the shape mutt draws is one root with a flat fan under it.
216fn group_by_subject(
217    arena: &mut [Container],
218    envs: &[&Envelope],
219    top: &mut Vec<usize>,
220    sub: &SubjectFallback,
221) {
222    // Who may be a parent, by the subject they named. A reply
223    // repeating its parent's subject is not in here, so a long thread
224    // does not offer every message in it as a place to hang strays.
225    let mut candidates: HashMap<&str, Vec<usize>> = HashMap::new();
226    for node in 0..arena.len() {
227        let Some(m) = arena[node].message else {
228            continue;
229        };
230        let subj = real_subject(sub.reply_re, &envs[m].subject).0;
231        let changed = match message_ancestor(arena, node) {
232            Some(p) => {
233                let pm = arena[p]
234                    .message
235                    .expect("message_ancestor carries a message");
236                real_subject(sub.reply_re, &envs[pm].subject).0 != subj
237            }
238            None => true,
239        };
240        if changed {
241            candidates.entry(subj).or_default().push(node);
242        }
243    }
244
245    // Oldest first, so the message that opened the subject is the one
246    // still standing as a root when the later ones look for a parent.
247    let mut roots: Vec<usize> = top.clone();
248    roots.sort_by_key(|&r| {
249        let m = arena[r].message.expect("a thread root carries a message");
250        (envs[m].date, m)
251    });
252
253    for cur in roots {
254        let m = arena[cur].message.expect("a thread root carries a message");
255        // What break-thread marked stays where the user put it. mutt
256        // has nowhere to record that and hangs a broken message
257        // straight back under its old subject; rmut's `#` sticks.
258        if envs[m].broken {
259            continue;
260        }
261        let (subj, is_reply) = real_subject(sub.reply_re, &envs[m].subject);
262        if sub.sort_re && !is_reply {
263            continue;
264        }
265        let here = (envs[m].date, m);
266        let mut best: Option<((i64, usize), usize)> = None;
267        for &t in candidates.get(subj).map(Vec::as_slice).unwrap_or_default() {
268            if t == cur || arena[t].pseudo {
269                continue;
270            }
271            let tm = arena[t].message.expect("a candidate carries a message");
272            let there = (envs[tm].date, tm);
273            // Only a message already sent, and never one from inside
274            // this root's own thread: that would be a loop.
275            if there >= here || is_ancestor(arena, cur, t) {
276                continue;
277            }
278            if best.is_none_or(|(seen, _)| seen < there) {
279                best = Some((there, t));
280            }
281        }
282        if let Some((_, parent)) = best {
283            arena[cur].parent = Some(parent);
284            arena[parent].children.push(cur);
285            arena[cur].pseudo = true;
286        }
287    }
288    top.retain(|&t| arena[t].parent.is_none());
289}
290
291pub fn thread_by(envs: &[&Envelope], order: ThreadOrder) -> Vec<ThreadedItem> {
292    thread_with(envs, order, None)
293}
294
295/// Threading proper: reference chains, and then, with a
296/// [`SubjectFallback`], what mutt does for the messages whose
297/// senders left no chain behind.
298pub fn thread_with(
299    envs: &[&Envelope],
300    order: ThreadOrder,
301    subject: Option<&SubjectFallback>,
302) -> Vec<ThreadedItem> {
303    let newest = order.newest;
304    let mut arena: Vec<Container> = Vec::new();
305    let mut by_id: HashMap<String, usize> = HashMap::new();
306
307    // Every message gets its container first, so a message that
308    // explicitly carries no references is known as a root before any
309    // other message's chain can claim it.
310    let mut container_of = Vec::with_capacity(envs.len());
311    for (i, env) in envs.iter().enumerate() {
312        let id = env
313            .msg_id
314            .clone()
315            .unwrap_or_else(|| format!("<rmut-missing-{i}>"));
316        let mut container = get_or_create(&mut by_id, &mut arena, &id);
317        if arena[container].message.is_some() {
318            // Duplicate Message-ID: give this message its own container.
319            arena.push(Container {
320                message: None,
321                parent: None,
322                children: Vec::new(),
323                pseudo: false,
324            });
325            container = arena.len() - 1;
326        }
327        arena[container].message = Some(i);
328        container_of.push(container);
329    }
330    let mut rooted: Vec<bool> = arena
331        .iter()
332        .map(|c| c.message.is_some_and(|m| envs[m].references.is_empty()))
333        .collect();
334
335    for (i, env) in envs.iter().enumerate() {
336        let container = container_of[i];
337        let mut prev: Option<usize> = None;
338        for rid in &env.references {
339            let r = get_or_create(&mut by_id, &mut arena, rid);
340            rooted.resize(arena.len(), false);
341            if r == container {
342                continue;
343            }
344            if let Some(p) = prev {
345                link(&mut arena, &rooted, p, r);
346            }
347            prev = Some(r);
348        }
349        if let Some(p) = prev {
350            link(&mut arena, &rooted, p, container);
351        }
352    }
353
354    // Top-level containers with messages: roots, with empty roots
355    // replaced by their (recursively) real children.
356    let mut top = Vec::new();
357    for i in 0..arena.len() {
358        if arena[i].parent.is_none() {
359            if arena[i].message.is_some() {
360                top.push(i);
361            } else {
362                real_children(&arena, i, &mut top);
363            }
364        }
365    }
366    if let Some(sub) = subject {
367        group_by_subject(&mut arena, envs, &mut top, sub);
368    }
369    let envs_ref = envs;
370    top.sort_by_key(|&t| subtree_date(&arena, envs_ref, t, newest));
371    if order.reverse {
372        // mutt's reverse-: the threads turn round, the messages
373        // inside one keep their order.
374        top.reverse();
375    }
376
377    let mut out = Vec::new();
378    for t in top {
379        let root = arena[t].message.expect("top containers carry messages");
380        emit(&arena, envs_ref, t, 0, root, newest, &mut out);
381    }
382    out
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::maildir::{Flags, MailFile};
389
390    fn env(id: &str, refs: &[&str], date: i64) -> Envelope {
391        Envelope {
392            file: MailFile {
393                path: format!("/mail/{id}").into(),
394                is_new: false,
395                flags: Flags::default(),
396                size: 0,
397            },
398            from: "x".into(),
399            from_full: "x".into(),
400            subject: id.into(),
401            date,
402            msg_id: (!id.is_empty()).then(|| format!("<{id}>")),
403            references: refs.iter().map(|r| format!("<{r}>")).collect(),
404            tagged: false,
405            to: vec![],
406            cc: vec![],
407            lines: Some(0),
408            list: None,
409            label: None,
410            broken: false,
411        }
412    }
413
414    /// Like `env`, with a subject of its own: what the subject
415    /// fallback works from.
416    fn subj(id: &str, subject: &str, refs: &[&str], date: i64) -> Envelope {
417        Envelope {
418            subject: subject.into(),
419            ..env(id, refs, date)
420        }
421    }
422
423    fn run_subject(envs: &[Envelope], sort_re: bool) -> Vec<(usize, usize, bool)> {
424        let re = crate::compose::default_reply_regexp();
425        let fallback = SubjectFallback {
426            reply_re: &re,
427            sort_re,
428        };
429        let refs: Vec<&Envelope> = envs.iter().collect();
430        thread_with(&refs, ThreadOrder::default(), Some(&fallback))
431            .iter()
432            .map(|i| (i.index, i.depth, i.pseudo))
433            .collect()
434    }
435
436    fn run(envs: &[Envelope]) -> Vec<(usize, usize)> {
437        let refs: Vec<&Envelope> = envs.iter().collect();
438        thread(&refs).iter().map(|i| (i.index, i.depth)).collect()
439    }
440
441    #[test]
442    fn chain_nests_by_references() {
443        let envs = [
444            env("a", &[], 1),
445            env("b", &["a"], 2),
446            env("c", &["a", "b"], 3),
447        ];
448        assert_eq!(run(&envs), vec![(0, 0), (1, 1), (2, 2)]);
449        let refs: Vec<&Envelope> = envs.iter().collect();
450        assert!(thread(&refs).iter().all(|i| i.root == 0));
451    }
452
453    #[test]
454    fn unrelated_messages_are_separate_threads_by_date() {
455        let envs = [env("b", &[], 5), env("a", &[], 2)];
456        assert_eq!(run(&envs), vec![(1, 0), (0, 0)]);
457    }
458
459    #[test]
460    fn missing_parent_promotes_children() {
461        // Both reference a message we never saw; they become top-level.
462        let envs = [env("b", &["ghost"], 2), env("c", &["ghost"], 3)];
463        assert_eq!(run(&envs), vec![(0, 0), (1, 0)]);
464    }
465
466    #[test]
467    fn missing_middle_of_chain_is_bridged() {
468        // c references a and the missing b; c still lands under a.
469        let envs = [env("a", &[], 1), env("c", &["a", "b-missing"], 3)];
470        assert_eq!(run(&envs), vec![(0, 0), (1, 1)]);
471    }
472
473    #[test]
474    fn newest_orders_threads_by_their_latest_message() {
475        // Thread A: root at 10, reply at 100. Thread B: single at 50.
476        let envs = [env("a", &[], 10), env("a2", &["a"], 100), env("b", &[], 50)];
477        let refs: Vec<&Envelope> = envs.iter().collect();
478        let order = |spec: &str| -> Vec<usize> {
479            thread_by(&refs, ThreadOrder::parse(spec))
480                .iter()
481                .map(|t| t.index)
482                .collect()
483        };
484        assert_eq!(order("date"), vec![0, 1, 2], "thread A first by its oldest");
485        assert_eq!(
486            order("last-date-sent"),
487            vec![2, 0, 1],
488            "thread B first, A has the newest last"
489        );
490        // mutt's reverse-: the threads turn round, the messages
491        // inside one keep their order.
492        assert_eq!(order("reverse-date"), vec![2, 0, 1]);
493        assert_eq!(order("reverse-last-date-received"), vec![0, 1, 2]);
494    }
495
496    #[test]
497    fn sort_aux_spellings_parse_the_way_mutt_writes_them() {
498        assert_eq!(ThreadOrder::parse("date"), ThreadOrder::default());
499        assert_eq!(
500            ThreadOrder::parse("last-date-received"),
501            ThreadOrder {
502                newest: true,
503                reverse: false
504            }
505        );
506        assert_eq!(
507            ThreadOrder::parse("reverse-last-date-sent"),
508            ThreadOrder {
509                newest: true,
510                reverse: true
511            }
512        );
513        assert_eq!(
514            ThreadOrder::parse("REVERSE-DATE"),
515            ThreadOrder {
516                newest: false,
517                reverse: true
518            }
519        );
520    }
521
522    #[test]
523    fn siblings_sorted_by_date() {
524        let envs = [
525            env("a", &[], 1),
526            env("late", &["a"], 9),
527            env("early", &["a"], 2),
528        ];
529        assert_eq!(run(&envs), vec![(0, 0), (2, 1), (1, 1)]);
530    }
531
532    #[test]
533    fn duplicates_and_self_references_do_not_panic() {
534        let envs = [
535            env("a", &[], 1),
536            env("a", &[], 2),    // duplicate id
537            env("s", &["s"], 3), // references itself
538            env("", &[], 4),     // no id at all
539        ];
540        let out = run(&envs);
541        assert_eq!(out.len(), 4);
542        let mut seen: Vec<usize> = out.iter().map(|(i, _)| *i).collect();
543        seen.sort_unstable();
544        assert_eq!(seen, vec![0, 1, 2, 3]);
545    }
546
547    #[test]
548    fn reference_loops_are_broken() {
549        // a references b, b references a: whoever links second must not loop.
550        let envs = [env("a", &["b"], 1), env("b", &["a"], 2)];
551        let out = run(&envs);
552        assert_eq!(out.len(), 2);
553    }
554
555    #[test]
556    fn a_message_without_references_is_never_reparented_by_its_replies() {
557        // b answered a, and c answered b, so c's chain names a and b.
558        // break-thread then cleared b's own headers: b must stand as
559        // a root with c under it, and c's chain must not put it back
560        // under a (mutt's mutt_break_thread edits the one message).
561        let envs = [env("a", &[], 1), env("b", &[], 2), env("c", &["a", "b"], 3)];
562        assert_eq!(run(&envs), vec![(0, 0), (1, 0), (2, 1)]);
563        // A message with references of its own still hangs where its
564        // chain says, including through a missing ancestor.
565        let envs = [
566            env("a", &[], 1),
567            env("b", &["a"], 2),
568            env("c", &["a", "b"], 3),
569        ];
570        assert_eq!(run(&envs), vec![(0, 0), (1, 1), (2, 2)]);
571        let envs = [env("p", &[], 1), env("c", &["gone", "p"], 2)];
572        assert_eq!(run(&envs), vec![(0, 0), (1, 1)]);
573    }
574
575    #[test]
576    fn subject_groups_mail_that_carries_no_references() {
577        // The gitlab-notification shape: every message a fresh
578        // Message-ID, no References anywhere, one subject. mutt hangs
579        // the later ones off the first as a flat fan; without the
580        // fallback they are seven threads.
581        let envs = [
582            subj("a", "Re: proj | a change (!1661)", &[], 10),
583            subj("b", "Re: proj | a change (!1661)", &[], 20),
584            subj("c", "Re: proj | a change (!1661)", &[], 30),
585        ];
586        assert_eq!(
587            run_subject(&envs, true),
588            vec![(0, 0, false), (1, 1, true), (2, 1, true)]
589        );
590        // Without it, three roots, which is what rmut did before.
591        assert_eq!(run(&envs), vec![(0, 0), (1, 0), (2, 0)]);
592    }
593
594    #[test]
595    fn sort_re_decides_whether_a_plain_subject_joins() {
596        // "hi", then a reply to it, then another unrelated "hi".
597        let envs = [
598            subj("a", "hi", &[], 10),
599            subj("b", "Re: hi", &[], 20),
600            subj("c", "hi", &[], 30),
601            subj("d", "Re: other", &[], 40),
602        ];
603        // $sort_re set (mutt's default): only the "Re:" one joins.
604        assert_eq!(
605            run_subject(&envs, true),
606            vec![(0, 0, false), (1, 1, true), (2, 0, false), (3, 0, false)]
607        );
608        // Unset: any equal subject joins, which is what makes a
609        // mailbox full of "hi" one thread.
610        assert_eq!(
611            run_subject(&envs, false),
612            vec![(0, 0, false), (1, 1, true), (2, 1, true), (3, 0, false)]
613        );
614    }
615
616    #[test]
617    fn a_renamed_reply_is_the_parent_for_its_own_subject() {
618        // b keeps a's subject, m renames the thread. A stray "Re:
619        // newtopic" belongs under m, not under the root: mutt's
620        // subject_changed, which keeps every message in a long thread
621        // from offering itself as a parent.
622        let envs = [
623            subj("a", "hi", &[], 10),
624            subj("b", "Re: hi", &["a"], 20),
625            subj("m", "Re: newtopic", &["a", "b"], 30),
626            subj("n", "Re: newtopic", &[], 40),
627        ];
628        assert_eq!(
629            run_subject(&envs, true),
630            vec![(0, 0, false), (1, 1, false), (2, 2, false), (3, 3, true)]
631        );
632    }
633
634    #[test]
635    fn a_subject_child_brings_its_own_replies_with_it() {
636        // c answered b by References; b, which carries none of its
637        // own, joins a by subject and c goes along, a level deeper.
638        let envs = [
639            subj("a", "hi", &[], 10),
640            subj("b", "Re: hi", &[], 20),
641            subj("c", "Re: hi", &["b"], 30),
642        ];
643        assert_eq!(
644            run_subject(&envs, true),
645            vec![(0, 0, false), (1, 1, true), (2, 2, false)]
646        );
647    }
648
649    #[test]
650    fn the_subject_pass_never_loops_or_reparents_a_real_child() {
651        // A message already placed by its chain stays there, and the
652        // oldest root of a subject is nobody's child.
653        let envs = [
654            subj("a", "Re: same", &[], 30),
655            subj("b", "Re: same", &["a"], 10),
656        ];
657        let out = run_subject(&envs, true);
658        assert_eq!(out.len(), 2);
659        // b hangs off a by reference; a, though later, cannot then
660        // hang off b.
661        assert_eq!(out, vec![(0, 0, false), (1, 1, false)]);
662    }
663}