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. Subject grouping (JWZ step 5) is not done.
6
7use std::collections::HashMap;
8
9use crate::message::Envelope;
10
11/// One index entry in thread order.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct ThreadedItem {
14    /// Index into the input slice.
15    pub index: usize,
16    /// Nesting depth (0 = thread root).
17    pub depth: usize,
18    /// Input index of this thread's first (root) message.
19    pub root: usize,
20}
21
22struct Container {
23    message: Option<usize>,
24    parent: Option<usize>,
25    children: Vec<usize>,
26}
27
28fn get_or_create(
29    by_id: &mut HashMap<String, usize>,
30    arena: &mut Vec<Container>,
31    id: &str,
32) -> usize {
33    if let Some(&c) = by_id.get(id) {
34        return c;
35    }
36    arena.push(Container {
37        message: None,
38        parent: None,
39        children: Vec::new(),
40    });
41    let idx = arena.len() - 1;
42    by_id.insert(id.to_string(), idx);
43    idx
44}
45
46fn is_ancestor(arena: &[Container], ancestor: usize, mut node: usize) -> bool {
47    loop {
48        if node == ancestor {
49            return true;
50        }
51        match arena[node].parent {
52            Some(p) => node = p,
53            None => return false,
54        }
55    }
56}
57
58/// Link parent→child unless it would create a loop, the child is
59/// already placed, or the child is a message that carries no
60/// references of its own. That last one is what makes break-thread
61/// stick: a reply's References chain still names the broken message's
62/// old ancestors, and without it the chain would quietly put the
63/// message back under them.
64fn link(arena: &mut [Container], rooted: &[bool], parent: usize, child: usize) {
65    if parent == child
66        || arena[child].parent.is_some()
67        || rooted[child]
68        || is_ancestor(arena, child, parent)
69    {
70        return;
71    }
72    arena[child].parent = Some(parent);
73    arena[parent].children.push(child);
74}
75
76/// Earliest (or, with `newest`, latest) date in a container's
77/// subtree, for ordering threads and siblings.
78fn subtree_date(arena: &[Container], envs: &[&Envelope], node: usize, newest: bool) -> i64 {
79    let own = arena[node]
80        .message
81        .map(|m| envs[m].date)
82        .unwrap_or(if newest { i64::MIN } else { i64::MAX });
83    let fold = if newest { i64::max } else { i64::min };
84    arena[node]
85        .children
86        .iter()
87        .map(|&c| subtree_date(arena, envs, c, newest))
88        .fold(own, fold)
89}
90
91/// Children of `node` that carry messages, looking through empty
92/// containers (their children are promoted transparently).
93fn real_children(arena: &[Container], node: usize, out: &mut Vec<usize>) {
94    for &child in &arena[node].children {
95        if arena[child].message.is_some() {
96            out.push(child);
97        } else {
98            real_children(arena, child, out);
99        }
100    }
101}
102
103fn emit(
104    arena: &[Container],
105    envs: &[&Envelope],
106    node: usize,
107    depth: usize,
108    root: usize,
109    newest: bool,
110    out: &mut Vec<ThreadedItem>,
111) {
112    let index = arena[node].message.expect("emit called on empty container");
113    out.push(ThreadedItem { index, depth, root });
114    let mut kids = Vec::new();
115    real_children(arena, node, &mut kids);
116    kids.sort_by_key(|&k| subtree_date(arena, envs, k, newest));
117    for kid in kids {
118        emit(arena, envs, kid, depth + 1, root, newest, out);
119    }
120}
121
122pub fn thread(envs: &[&Envelope]) -> Vec<ThreadedItem> {
123    thread_by(envs, ThreadOrder::default())
124}
125
126/// Like `thread`, ordering threads by their newest message when
127/// `newest` (mutt's sort_aux = last-date-sent).
128/// How the threads themselves are ordered, from mutt's $sort_aux:
129/// by the root's date or by the newest message under it, oldest
130/// first or newest first.
131#[derive(Clone, Copy, Default, PartialEq, Eq, Debug)]
132pub struct ThreadOrder {
133    /// mutt's `last-`: order a thread by its newest message rather
134    /// than by its root.
135    pub newest: bool,
136    /// mutt's `reverse-`: the newest thread first.
137    pub reverse: bool,
138}
139
140impl ThreadOrder {
141    /// mutt's $sort_aux, as far as threads care: everything else is
142    /// a date order under another name.
143    pub fn parse(spec: &str) -> ThreadOrder {
144        let spec = spec.trim().to_lowercase();
145        let (reverse, rest) = match spec.strip_prefix("reverse-") {
146            Some(rest) => (true, rest.to_string()),
147            None => (false, spec),
148        };
149        ThreadOrder {
150            newest: rest.starts_with("last-"),
151            reverse,
152        }
153    }
154}
155
156pub fn thread_by(envs: &[&Envelope], order: ThreadOrder) -> Vec<ThreadedItem> {
157    let newest = order.newest;
158    let mut arena: Vec<Container> = Vec::new();
159    let mut by_id: HashMap<String, usize> = HashMap::new();
160
161    // Every message gets its container first, so a message that
162    // explicitly carries no references is known as a root before any
163    // other message's chain can claim it.
164    let mut container_of = Vec::with_capacity(envs.len());
165    for (i, env) in envs.iter().enumerate() {
166        let id = env
167            .msg_id
168            .clone()
169            .unwrap_or_else(|| format!("<rmut-missing-{i}>"));
170        let mut container = get_or_create(&mut by_id, &mut arena, &id);
171        if arena[container].message.is_some() {
172            // Duplicate Message-ID: give this message its own container.
173            arena.push(Container {
174                message: None,
175                parent: None,
176                children: Vec::new(),
177            });
178            container = arena.len() - 1;
179        }
180        arena[container].message = Some(i);
181        container_of.push(container);
182    }
183    let mut rooted: Vec<bool> = arena
184        .iter()
185        .map(|c| c.message.is_some_and(|m| envs[m].references.is_empty()))
186        .collect();
187
188    for (i, env) in envs.iter().enumerate() {
189        let container = container_of[i];
190        let mut prev: Option<usize> = None;
191        for rid in &env.references {
192            let r = get_or_create(&mut by_id, &mut arena, rid);
193            rooted.resize(arena.len(), false);
194            if r == container {
195                continue;
196            }
197            if let Some(p) = prev {
198                link(&mut arena, &rooted, p, r);
199            }
200            prev = Some(r);
201        }
202        if let Some(p) = prev {
203            link(&mut arena, &rooted, p, container);
204        }
205    }
206
207    // Top-level containers with messages: roots, with empty roots
208    // replaced by their (recursively) real children.
209    let mut top = Vec::new();
210    for i in 0..arena.len() {
211        if arena[i].parent.is_none() {
212            if arena[i].message.is_some() {
213                top.push(i);
214            } else {
215                real_children(&arena, i, &mut top);
216            }
217        }
218    }
219    let envs_ref = envs;
220    top.sort_by_key(|&t| subtree_date(&arena, envs_ref, t, newest));
221    if order.reverse {
222        // mutt's reverse-: the threads turn round, the messages
223        // inside one keep their order.
224        top.reverse();
225    }
226
227    let mut out = Vec::new();
228    for t in top {
229        let root = arena[t].message.expect("top containers carry messages");
230        emit(&arena, envs_ref, t, 0, root, newest, &mut out);
231    }
232    out
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::maildir::{Flags, MailFile};
239
240    fn env(id: &str, refs: &[&str], date: i64) -> Envelope {
241        Envelope {
242            file: MailFile {
243                path: format!("/mail/{id}").into(),
244                is_new: false,
245                flags: Flags::default(),
246                size: 0,
247            },
248            from: "x".into(),
249            from_full: "x".into(),
250            subject: id.into(),
251            date,
252            msg_id: (!id.is_empty()).then(|| format!("<{id}>")),
253            references: refs.iter().map(|r| format!("<{r}>")).collect(),
254            tagged: false,
255            to: vec![],
256            cc: vec![],
257            lines: Some(0),
258            list: None,
259            label: None,
260        }
261    }
262
263    fn run(envs: &[Envelope]) -> Vec<(usize, usize)> {
264        let refs: Vec<&Envelope> = envs.iter().collect();
265        thread(&refs).iter().map(|i| (i.index, i.depth)).collect()
266    }
267
268    #[test]
269    fn chain_nests_by_references() {
270        let envs = [
271            env("a", &[], 1),
272            env("b", &["a"], 2),
273            env("c", &["a", "b"], 3),
274        ];
275        assert_eq!(run(&envs), vec![(0, 0), (1, 1), (2, 2)]);
276        let refs: Vec<&Envelope> = envs.iter().collect();
277        assert!(thread(&refs).iter().all(|i| i.root == 0));
278    }
279
280    #[test]
281    fn unrelated_messages_are_separate_threads_by_date() {
282        let envs = [env("b", &[], 5), env("a", &[], 2)];
283        assert_eq!(run(&envs), vec![(1, 0), (0, 0)]);
284    }
285
286    #[test]
287    fn missing_parent_promotes_children() {
288        // Both reference a message we never saw; they become top-level.
289        let envs = [env("b", &["ghost"], 2), env("c", &["ghost"], 3)];
290        assert_eq!(run(&envs), vec![(0, 0), (1, 0)]);
291    }
292
293    #[test]
294    fn missing_middle_of_chain_is_bridged() {
295        // c references a and the missing b; c still lands under a.
296        let envs = [env("a", &[], 1), env("c", &["a", "b-missing"], 3)];
297        assert_eq!(run(&envs), vec![(0, 0), (1, 1)]);
298    }
299
300    #[test]
301    fn newest_orders_threads_by_their_latest_message() {
302        // Thread A: root at 10, reply at 100. Thread B: single at 50.
303        let envs = [env("a", &[], 10), env("a2", &["a"], 100), env("b", &[], 50)];
304        let refs: Vec<&Envelope> = envs.iter().collect();
305        let order = |spec: &str| -> Vec<usize> {
306            thread_by(&refs, ThreadOrder::parse(spec))
307                .iter()
308                .map(|t| t.index)
309                .collect()
310        };
311        assert_eq!(order("date"), vec![0, 1, 2], "thread A first by its oldest");
312        assert_eq!(
313            order("last-date-sent"),
314            vec![2, 0, 1],
315            "thread B first, A has the newest last"
316        );
317        // mutt's reverse-: the threads turn round, the messages
318        // inside one keep their order.
319        assert_eq!(order("reverse-date"), vec![2, 0, 1]);
320        assert_eq!(order("reverse-last-date-received"), vec![0, 1, 2]);
321    }
322
323    #[test]
324    fn sort_aux_spellings_parse_the_way_mutt_writes_them() {
325        assert_eq!(ThreadOrder::parse("date"), ThreadOrder::default());
326        assert_eq!(
327            ThreadOrder::parse("last-date-received"),
328            ThreadOrder {
329                newest: true,
330                reverse: false
331            }
332        );
333        assert_eq!(
334            ThreadOrder::parse("reverse-last-date-sent"),
335            ThreadOrder {
336                newest: true,
337                reverse: true
338            }
339        );
340        assert_eq!(
341            ThreadOrder::parse("REVERSE-DATE"),
342            ThreadOrder {
343                newest: false,
344                reverse: true
345            }
346        );
347    }
348
349    #[test]
350    fn siblings_sorted_by_date() {
351        let envs = [
352            env("a", &[], 1),
353            env("late", &["a"], 9),
354            env("early", &["a"], 2),
355        ];
356        assert_eq!(run(&envs), vec![(0, 0), (2, 1), (1, 1)]);
357    }
358
359    #[test]
360    fn duplicates_and_self_references_do_not_panic() {
361        let envs = [
362            env("a", &[], 1),
363            env("a", &[], 2),    // duplicate id
364            env("s", &["s"], 3), // references itself
365            env("", &[], 4),     // no id at all
366        ];
367        let out = run(&envs);
368        assert_eq!(out.len(), 4);
369        let mut seen: Vec<usize> = out.iter().map(|(i, _)| *i).collect();
370        seen.sort_unstable();
371        assert_eq!(seen, vec![0, 1, 2, 3]);
372    }
373
374    #[test]
375    fn reference_loops_are_broken() {
376        // a references b, b references a: whoever links second must not loop.
377        let envs = [env("a", &["b"], 1), env("b", &["a"], 2)];
378        let out = run(&envs);
379        assert_eq!(out.len(), 2);
380    }
381
382    #[test]
383    fn a_message_without_references_is_never_reparented_by_its_replies() {
384        // b answered a, and c answered b, so c's chain names a and b.
385        // break-thread then cleared b's own headers: b must stand as
386        // a root with c under it, and c's chain must not put it back
387        // under a (mutt's mutt_break_thread edits the one message).
388        let envs = [env("a", &[], 1), env("b", &[], 2), env("c", &["a", "b"], 3)];
389        assert_eq!(run(&envs), vec![(0, 0), (1, 0), (2, 1)]);
390        // A message with references of its own still hangs where its
391        // chain says, including through a missing ancestor.
392        let envs = [
393            env("a", &[], 1),
394            env("b", &["a"], 2),
395            env("c", &["a", "b"], 3),
396        ];
397        assert_eq!(run(&envs), vec![(0, 0), (1, 1), (2, 2)]);
398        let envs = [env("p", &[], 1), env("c", &["gone", "p"], 2)];
399        assert_eq!(run(&envs), vec![(0, 0), (1, 1)]);
400    }
401}