Skip to main content

imessage_database/tables/
chat_handle.rs

1/*!
2 Chat-to-handle join table helpers.
3*/
4
5use std::collections::{BTreeSet, HashMap, HashSet};
6
7use crate::{
8    error::table::TableError,
9    tables::{
10        chat::Chat,
11        diagnostic::{ChatHandleDiagnostic, column_exists},
12        handle::HandleGroups,
13        table::{CHAT_HANDLE_JOIN, CHAT_MESSAGE_JOIN, MESSAGE, Table},
14    },
15    util::union_find::UnionFind,
16};
17use rusqlite::{CachedStatement, Connection, Result, Row};
18
19// MARK: Struct
20/// Row from the `chat_handle_join` table.
21#[derive(Debug)]
22pub struct ChatToHandle {
23    chat_id: i32,
24    handle_id: i32,
25}
26
27impl Table for ChatToHandle {
28    fn from_row(row: &Row) -> Result<ChatToHandle> {
29        Ok(ChatToHandle {
30            chat_id: row.get("chat_id")?,
31            handle_id: row.get("handle_id")?,
32        })
33    }
34}
35
36// MARK: Rows
37impl ChatToHandle {
38    /// Prepare the `SELECT *` statement for the `chat_handle_join` table.
39    pub fn get(db: &'_ Connection) -> Result<CachedStatement<'_>, TableError> {
40        Ok(db.prepare_cached(&format!("SELECT * FROM {CHAT_HANDLE_JOIN}"))?)
41    }
42
43    /// Map participant handle IDs by chat row ID.
44    ///
45    /// # Example:
46    ///
47    /// ```no_run
48    /// use imessage_database::util::dirs::default_db_path;
49    /// use imessage_database::tables::table::get_connection;
50    /// use imessage_database::tables::chat_handle::ChatToHandle;
51    ///
52    /// let db_path = default_db_path().unwrap();
53    /// let conn = get_connection(&db_path).unwrap();
54    /// let chatrooms = ChatToHandle::participants_by_chat(&conn);
55    /// ```
56    pub fn participants_by_chat(
57        db: &Connection,
58    ) -> Result<HashMap<i32, BTreeSet<i32>>, TableError> {
59        let mut cache: HashMap<i32, BTreeSet<i32>> = HashMap::new();
60
61        let mut rows = ChatToHandle::get(db)?;
62        for mapping in ChatToHandle::rows(&mut rows, [])? {
63            let joiner = mapping?;
64            if let Some(handles) = cache.get_mut(&joiner.chat_id) {
65                handles.insert(joiner.handle_id);
66            } else {
67                let mut data_to_cache = BTreeSet::new();
68                data_to_cache.insert(joiner.handle_id);
69                cache.insert(joiner.chat_id, data_to_cache);
70            }
71        }
72
73        Ok(cache)
74    }
75}
76
77/// Participant identity used when comparing chats.
78///
79/// Use separate variants to distinguish handle row IDs from group IDs:
80/// both are represented as `i32`.
81#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
82pub enum Participant {
83    /// Handle group from [`HandleGroups::finalize`].
84    Group(i32),
85    /// Handle row with no entry in the group map.
86    Handle(i32),
87}
88
89/// `true` when the chat row has a display name.
90fn named(chats: &HashMap<i32, Chat>, chat_id: i32) -> bool {
91    chats.get(&chat_id).and_then(Chat::display_name).is_some()
92}
93
94// MARK: Diagnostic
95impl ChatToHandle {
96    /// Compute diagnostic data for the `chat_handle_join` table.
97    ///
98    /// Counts chats referenced by messages but missing from this join table.
99    ///
100    /// # Example:
101    ///
102    /// ```no_run
103    /// use imessage_database::util::dirs::default_db_path;
104    /// use imessage_database::tables::table::get_connection;
105    /// use imessage_database::tables::chat_handle::ChatToHandle;
106    ///
107    /// let db_path = default_db_path().unwrap();
108    /// let conn = get_connection(&db_path).unwrap();
109    /// ChatToHandle::run_diagnostic(&conn);
110    /// ```
111    pub fn run_diagnostic(db: &Connection) -> Result<ChatHandleDiagnostic, TableError> {
112        // Chats referenced by messages.
113        let mut statement_message_chats =
114            db.prepare(&format!("SELECT DISTINCT chat_id from {CHAT_MESSAGE_JOIN}"))?;
115        let statement_message_chat_rows =
116            statement_message_chats.query_map([], |row: &Row| -> Result<i32> { row.get(0) })?;
117        let mut unique_chats_from_messages: HashSet<i32> = HashSet::new();
118        statement_message_chat_rows.into_iter().for_each(|row| {
119            if let Ok(row) = row {
120                unique_chats_from_messages.insert(row);
121            }
122        });
123
124        // Chats with associated handles.
125        let mut statement_handle_chats =
126            db.prepare(&format!("SELECT DISTINCT chat_id from {CHAT_HANDLE_JOIN}"))?;
127        let statement_handle_chat_rows =
128            statement_handle_chats.query_map([], |row: &Row| -> Result<i32> { row.get(0) })?;
129        let mut unique_chats_from_handles: HashSet<i32> = HashSet::new();
130        statement_handle_chat_rows.into_iter().for_each(|row| {
131            if let Ok(row) = row {
132                unique_chats_from_handles.insert(row);
133            }
134        });
135
136        // Cache all chats
137        let all_chats = Self::participants_by_chat(db)?;
138
139        // Cache chatroom participants
140        let chat_handle_lookup = ChatToHandle::get_chat_lookup_map(db)?;
141        let handle_groups = HandleGroups::from_db(db)?.finalize();
142
143        // Deduplicate chatroom participants
144        let chats = Chat::by_rowid(db)?;
145        let real_chatrooms =
146            ChatToHandle::dedupe(&all_chats, &chat_handle_lookup, &handle_groups, &chats);
147
148        // Count only chats with handle rows in both totals. Include lookup-only
149        // components in `real_chatrooms` for routing, but exclude them here to
150        // match `Total chats`.
151        let conversations: HashSet<&i32> = all_chats
152            .keys()
153            .filter_map(|chat_id| real_chatrooms.get(chat_id))
154            .collect();
155        let total_duplicated = all_chats.len() - conversations.len();
156
157        // Find the set difference
158        let chats_with_no_handles = unique_chats_from_messages
159            .difference(&unique_chats_from_handles)
160            .count();
161
162        Ok(ChatHandleDiagnostic {
163            total_chats: all_chats.len(),
164            total_duplicated,
165            chats_with_no_handles,
166        })
167    }
168}
169
170impl ChatToHandle {
171    /// Load the `chat_lookup` canonical chat map when the table exists.
172    ///
173    /// The map links chat IDs split across services to a canonical chat ID for
174    /// deduplication.
175    ///
176    /// # Example:
177    ///
178    /// ```no_run
179    /// use imessage_database::util::dirs::default_db_path;
180    /// use imessage_database::tables::table::get_connection;
181    /// use imessage_database::tables::chat_handle::ChatToHandle;
182    ///
183    /// let db_path = default_db_path().unwrap();
184    /// let conn = get_connection(&db_path).unwrap();
185    /// ChatToHandle::get_chat_lookup_map(&conn);
186    /// ```
187    pub fn get_chat_lookup_map(conn: &Connection) -> Result<HashMap<i32, i32>, TableError> {
188        // `chat_lookup` links chats split across services.
189        let mut stmt = conn.prepare(
190            "
191WITH RECURSIVE
192  adj AS (
193    SELECT DISTINCT a.chat AS u, b.chat AS v
194    FROM chat_lookup a
195    JOIN chat_lookup b
196      ON a.identifier = b.identifier
197  ),
198  reach(root, chat) AS (
199    SELECT u AS root, v AS chat FROM adj
200    UNION
201    SELECT r.root, a.v
202    FROM reach r
203    JOIN adj a ON a.u = r.chat
204  ),
205  canon AS (
206    SELECT chat, MAX(root) AS canonical_chat
207    FROM reach
208    GROUP BY chat
209  )
210SELECT chat, canonical_chat
211FROM canon
212ORDER BY chat;
213        ",
214        );
215        let mut chat_lookup_map: HashMap<i32, i32> = HashMap::new();
216
217        if let Ok(statement) = stmt.as_mut() {
218            // Build chat lookup map
219            let chat_lookup_rows = statement.query_map([], |row| {
220                let chat: i32 = row.get(0)?;
221                let canonical: i32 = row.get(1)?;
222                Ok((chat, canonical))
223            });
224
225            // Populate chat lookup map
226            if let Ok(chat_lookup_rows) = chat_lookup_rows {
227                for row in chat_lookup_rows {
228                    let (chat_id, canonical_chat) = row?;
229                    chat_lookup_map.insert(chat_id, canonical_chat);
230                }
231            }
232        }
233        Ok(chat_lookup_map)
234    }
235
236    /// Assign stable deduplicated IDs to chats.
237    ///
238    /// Use handle groups rather than handle row IDs to compare participants.
239    /// Merge chats connected through `chat_lookup`, even with different names.
240    /// For rows with matching participant groups:
241    ///
242    /// - Merge named rows only with matching display names.
243    /// - Attach unnamed rows to the lowest-row-ID named row with the same
244    ///   participant groups.
245    /// - For an unnamed row linked to a named row through `chat_lookup`, use
246    ///   that conversation when attaching other unnamed rows with matching
247    ///   participant groups. Preserve existing assignments to other named
248    ///   conversations from `chat_lookup`.
249    /// - Without a matching named row, merge the unnamed rows.
250    ///
251    /// Assign an ID to every row referenced in `chat_lookup_map`, including
252    /// rows without handle rows, to route their messages to the conversation.
253    /// Assign a separate ID to each component made entirely of such rows.
254    ///
255    /// - `duplicated_data`: raw participant handle IDs keyed by chat row ID
256    /// - `chat_lookup_map`: canonical chat ID keyed by each related chat ID,
257    ///   from [`ChatToHandle::get_chat_lookup_map`]
258    /// - `handle_groups`: group ID keyed by each handle row ID, from [`HandleGroups::finalize`];
259    ///   an unmapped handle keeps its own identity
260    /// - `chats`: chat rows keyed by row ID; treat a missing row as unnamed
261    ///
262    /// Assuming no new chat-handle relationships have been written to the database, deduplicated data is deterministic across runs.
263    ///
264    /// # Example:
265    ///
266    /// ```no_run
267    /// use std::collections::HashMap;
268    ///
269    /// use imessage_database::util::dirs::default_db_path;
270    /// use imessage_database::tables::table::get_connection;
271    /// use imessage_database::tables::chat::Chat;
272    /// use imessage_database::tables::chat_handle::ChatToHandle;
273    /// use imessage_database::tables::handle::HandleGroups;
274    ///
275    /// let db_path = default_db_path().unwrap();
276    /// let conn = get_connection(&db_path).unwrap();
277    /// let chatrooms = ChatToHandle::participants_by_chat(&conn).unwrap();
278    /// let handle_groups = HandleGroups::from_db(&conn).unwrap().finalize();
279    /// let chats = Chat::by_rowid(&conn).unwrap();
280    /// let deduped_chatrooms = ChatToHandle::dedupe(&chatrooms, &HashMap::new(), &handle_groups, &chats);
281    /// ```
282    pub fn dedupe(
283        duplicated_data: &HashMap<i32, BTreeSet<i32>>,
284        chat_lookup_map: &HashMap<i32, i32>,
285        handle_groups: &HashMap<i32, i32>,
286        chats: &HashMap<i32, Chat>,
287    ) -> HashMap<i32, i32> {
288        let mut uf = UnionFind::new();
289
290        for chat_id in duplicated_data.keys() {
291            uf.make_set(*chat_id);
292        }
293
294        // Merge chats that the chat_lookup table says are the same conversation,
295        // iterating in sorted order so union-by-rank picks deterministic representatives.
296        // Union every link, including rows absent from duplicated_data: a chat
297        // with no handle rows still bridges the rows that map to it, and its
298        // display name still counts for the conversation.
299        let mut sorted_lookup: Vec<(&i32, &i32)> = chat_lookup_map.iter().collect();
300        sorted_lookup.sort_by_key(|(chat_id, _)| *chat_id);
301        for (chat_id, canonical) in sorted_lookup {
302            uf.union(*chat_id, *canonical);
303        }
304
305        // Merge chats that share the same participant set, iterating in
306        // sorted order so union-by-rank picks deterministic representatives
307        let mut keyed: Vec<(i32, BTreeSet<Participant>, Option<&str>)> = duplicated_data
308            .iter()
309            .map(|(chat_id, participants)| {
310                let key = participants
311                    .iter()
312                    .map(|handle_id| match handle_groups.get(handle_id) {
313                        Some(group) => Participant::Group(*group),
314                        None => Participant::Handle(*handle_id),
315                    })
316                    .collect();
317                let name = chats.get(chat_id).and_then(Chat::display_name);
318                (*chat_id, key, name)
319            })
320            .collect();
321        keyed.sort_by_key(|(id, _, _)| *id);
322
323        // Merge rows that share participants and a name. Then attach unnamed
324        // rows to the lowest-ID named row with the same participants.
325        let mut first_named: HashMap<(&BTreeSet<Participant>, &str), i32> = HashMap::new();
326        let mut anchor: HashMap<&BTreeSet<Participant>, i32> = HashMap::new();
327        for (chat_id, key, name) in &keyed {
328            let Some(name) = name else { continue };
329            match first_named.get(&(key, *name)) {
330                Some(first_chat) => uf.union(*chat_id, *first_chat),
331                None => {
332                    first_named.insert((key, name), *chat_id);
333                }
334            }
335            anchor.entry(key).or_insert(*chat_id);
336        }
337
338        // Preserve named conversation assignments from `chat_lookup`: do not
339        // merge two named components merely for matching participant groups.
340        // Check every linked row for a name, including those without handle
341        // rows and therefore absent from `keyed`. For an unnamed row already
342        // grouped with a named row, merge any earlier unnamed anchor into that
343        // component, then attach later unnamed rows with matching participants.
344        // Record each merged root to recognize named membership through
345        // subsequent links.
346        let mut lookup_members: Vec<i32> = chat_lookup_map
347            .iter()
348            .flat_map(|(chat_id, canonical)| [*chat_id, *canonical])
349            .collect();
350        lookup_members.sort_unstable();
351        lookup_members.dedup();
352        let mut named_roots: HashSet<i32> = keyed
353            .iter()
354            .filter(|(_, _, name)| name.is_some())
355            .map(|(chat_id, _, _)| *chat_id)
356            .chain(
357                lookup_members
358                    .iter()
359                    .copied()
360                    .filter(|chat_id| named(chats, *chat_id)),
361            )
362            .map(|chat_id| uf.find(chat_id))
363            .collect();
364        for (chat_id, key, name) in &keyed {
365            if name.is_some() {
366                continue;
367            }
368            let row_named = named_roots.contains(&uf.find(*chat_id));
369            match anchor.get(key) {
370                Some(first_chat) => {
371                    let anchor_named = named_roots.contains(&uf.find(*first_chat));
372                    if row_named && anchor_named {
373                        continue;
374                    }
375                    uf.union(*chat_id, *first_chat);
376                    if row_named || anchor_named {
377                        named_roots.insert(uf.find(*chat_id));
378                    }
379                }
380                None => {
381                    anchor.insert(key, *chat_id);
382                }
383            }
384        }
385
386        // Label keyed rows in chat ID order before labeling lookup-only rows,
387        // also in chat ID order, to preserve IDs for keyed components. Assign
388        // a separate ID to each lookup-only component without keyed members.
389        let lookup_only = lookup_members
390            .iter()
391            .copied()
392            .filter(|member| !duplicated_data.contains_key(member));
393        uf.label(keyed.iter().map(|(id, _, _)| *id).chain(lookup_only))
394    }
395}
396
397// MARK: Representatives
398impl ChatToHandle {
399    /// Return the latest message date for each chat row ID.
400    ///
401    /// We query `chat_message_join.message_date` when that column exists.
402    /// Otherwise, we join `chat_message_join` to `message` and read
403    /// `message.date`. This fallback is slower, but we can use it with every
404    /// supported schema. We omit chats without messages from the result.
405    ///
406    /// # Example:
407    ///
408    /// ```no_run
409    /// use imessage_database::util::dirs::default_db_path;
410    /// use imessage_database::tables::table::get_connection;
411    /// use imessage_database::tables::chat_handle::ChatToHandle;
412    ///
413    /// let db_path = default_db_path().unwrap();
414    /// let conn = get_connection(&db_path).unwrap();
415    /// let last_activity = ChatToHandle::get_last_activity(&conn);
416    /// ```
417    pub fn get_last_activity(db: &Connection) -> Result<HashMap<i32, i64>, TableError> {
418        let query = if column_exists(db, CHAT_MESSAGE_JOIN, "message_date")? {
419            format!("SELECT chat_id, MAX(message_date) FROM {CHAT_MESSAGE_JOIN} GROUP BY chat_id")
420        } else {
421            format!(
422                "SELECT j.chat_id, MAX(m.date) FROM {CHAT_MESSAGE_JOIN} j JOIN {MESSAGE} m ON m.ROWID = j.message_id GROUP BY j.chat_id"
423            )
424        };
425
426        let mut statement = db.prepare(&query)?;
427        let rows = statement.query_map([], |row| {
428            Ok((row.get::<_, i32>(0)?, row.get::<_, Option<i64>>(1)?))
429        })?;
430
431        let mut last_activity = HashMap::new();
432        for row in rows {
433            let (chat_id, date) = row?;
434            if let Some(date) = date {
435                last_activity.insert(chat_id, date);
436            }
437        }
438        Ok(last_activity)
439    }
440
441    /// Choose one chat row to represent each deduplicated conversation.
442    ///
443    /// Account for different `display_name` values within a group:
444    ///
445    /// 1. Use the most recently active row if named.
446    /// 2. Otherwise, use the named row with the lowest ID.
447    /// 3. For an entirely unnamed group, use the most recently active row with
448    ///    participant rows.
449    /// 4. For an unnamed group without participant rows, use the most recently
450    ///    active row.
451    ///
452    /// Prefer rows with a name or participants for building filenames; use an
453    /// unnamed lookup-only row only for a group without either. When viewing
454    /// a message in Messages.app, you see the name stored on its chat row.
455    ///
456    /// - `deduplicated_chats`: map from each chat row ID to its deduplicated ID, from [`ChatToHandle::dedupe`]
457    /// - `chats`: chat rows keyed by row ID; treat a missing row as unnamed
458    /// - `last_activity`: map from each chat row ID to its latest message date, from [`ChatToHandle::get_last_activity`]
459    /// - `participants_by_chat`: raw participant handle IDs keyed by chat row ID, from [`ChatToHandle::participants_by_chat`]
460    /// - Returns: representative chat row ID for each input chat row ID
461    pub fn representatives(
462        deduplicated_chats: &HashMap<i32, i32>,
463        chats: &HashMap<i32, Chat>,
464        last_activity: &HashMap<i32, i64>,
465        participants_by_chat: &HashMap<i32, BTreeSet<i32>>,
466    ) -> HashMap<i32, i32> {
467        let mut members: HashMap<i32, Vec<i32>> = HashMap::new();
468        for (chat_id, dedup_id) in deduplicated_chats {
469            members.entry(*dedup_id).or_default().push(*chat_id);
470        }
471
472        // Rank by latest activity, breaking ties by lowest row ID to keep the
473        // choice stable across runs.
474        let activity = |chat_id: &i32| {
475            (
476                last_activity.get(chat_id).copied().unwrap_or(i64::MIN),
477                std::cmp::Reverse(*chat_id),
478            )
479        };
480
481        let mut representatives = HashMap::with_capacity(deduplicated_chats.len());
482        for rows in members.into_values() {
483            let most_active = rows
484                .iter()
485                .copied()
486                .max_by_key(activity)
487                .expect("a deduplicated group has at least one row");
488
489            let representative = if named(chats, most_active) {
490                most_active
491            } else {
492                rows.iter()
493                    .copied()
494                    .filter(|chat_id| named(chats, *chat_id))
495                    .min()
496                    .or_else(|| {
497                        rows.iter()
498                            .copied()
499                            .filter(|chat_id| participants_by_chat.contains_key(chat_id))
500                            .max_by_key(activity)
501                    })
502                    .unwrap_or(most_active)
503            };
504
505            for chat_id in rows {
506                representatives.insert(chat_id, representative);
507            }
508        }
509
510        representatives
511    }
512}
513
514// MARK: Tests
515#[cfg(test)]
516mod tests {
517    use crate::tables::chat_handle::ChatToHandle;
518    use std::collections::{BTreeSet, HashMap, HashSet};
519
520    #[test]
521    fn can_dedupe() {
522        let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
523        input.insert(1, BTreeSet::from([1])); // 0
524        input.insert(2, BTreeSet::from([1])); // 0
525        input.insert(3, BTreeSet::from([1])); // 0
526        input.insert(4, BTreeSet::from([2])); // 1
527        input.insert(5, BTreeSet::from([2])); // 1
528        input.insert(6, BTreeSet::from([3])); // 2
529
530        let output =
531            ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &HashMap::new());
532        let expected: HashMap<i32, i32> =
533            HashMap::from([(1, 0), (2, 0), (3, 0), (4, 1), (5, 1), (6, 2)]);
534        assert_eq!(output, expected);
535    }
536
537    #[test]
538    fn can_dedupe_multi() {
539        let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
540        input.insert(1, BTreeSet::from([1, 2])); // 0
541        input.insert(2, BTreeSet::from([1])); // 1
542        input.insert(3, BTreeSet::from([1])); // 1
543        input.insert(4, BTreeSet::from([2, 1])); // 0
544        input.insert(5, BTreeSet::from([2, 3])); // 2
545        input.insert(6, BTreeSet::from([3])); // 3
546
547        let output =
548            ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &HashMap::new());
549        let expected: HashMap<i32, i32> =
550            HashMap::from([(1, 0), (2, 1), (3, 1), (4, 0), (5, 2), (6, 3)]);
551        assert_eq!(output, expected);
552    }
553
554    #[test]
555    fn can_dedupe_with_chat_lookup_map() {
556        let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
557        input.insert(0, BTreeSet::from([1])); // Canonical 0
558        input.insert(1, BTreeSet::from([1])); // Maps to 0
559        input.insert(2, BTreeSet::from([3])); // Maps to 5
560        input.insert(4, BTreeSet::from([2])); // Maps to 0
561        input.insert(5, BTreeSet::from([1])); // Canonical 5
562
563        let chat_lookup_map = HashMap::from([(2, 5), (4, 0)]);
564
565        let output =
566            ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
567
568        // Chats 0,1,5 share participants {1}, and chat 2 maps to 5 via lookup,
569        // and chat 4 maps to 0 via lookup. All are the same conversation
570        assert_eq!(output.get(&0), output.get(&1));
571        assert_eq!(output.get(&0), output.get(&4));
572        assert_eq!(output.get(&0), output.get(&5));
573        assert_eq!(output.get(&2), output.get(&5));
574    }
575
576    #[test]
577    fn can_dedupe_with_lookup_map_overriding_participants() {
578        let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
579        input.insert(0, BTreeSet::from([1, 2])); // Canonical 0
580        input.insert(1, BTreeSet::from([1, 2])); // Maps to 0
581        input.insert(2, BTreeSet::from([3, 4])); // Maps to 0
582        input.insert(3, BTreeSet::from([1, 2])); // No mapping
583
584        let chat_lookup_map = HashMap::from([(1, 0), (2, 0)]);
585
586        let output =
587            ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
588
589        // Chats 0,1,2 all map to 0, so same deduplicated ID
590        assert_eq!(output.get(&0), output.get(&1));
591        assert_eq!(output.get(&0), output.get(&2));
592        // Chat 3 no mapping, same participants as 0 and 1, so same
593        assert_eq!(output.get(&3), output.get(&0));
594    }
595
596    #[test]
597    fn can_dedupe_mixed_lookup_and_participants() {
598        let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
599        input.insert(0, BTreeSet::from([1])); // Canonical 0
600        input.insert(1, BTreeSet::from([1])); // Maps to 0
601        input.insert(2, BTreeSet::from([3])); // No mapping
602        input.insert(3, BTreeSet::from([2])); // Maps to 0
603        input.insert(4, BTreeSet::from([3])); // No mapping
604
605        let chat_lookup_map = HashMap::from([(1, 0), (3, 0)]);
606
607        let output =
608            ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
609
610        // Chats 0,1,3 map to 0, same ID
611        assert_eq!(output.get(&0), output.get(&1));
612        assert_eq!(output.get(&0), output.get(&3));
613        // Chat 2 no mapping, different from 1
614        assert_ne!(output.get(&2), output.get(&1));
615        // Chat 4 same participants as 2, same as 2
616        assert_eq!(output.get(&4), output.get(&2));
617        // 3 and 4 different
618        assert_ne!(output.get(&3), output.get(&4));
619    }
620
621    #[test]
622    fn lookup_merge_is_order_independent() {
623        // Chats linked through `chat_lookup` must land in one conversation no
624        // matter which linked chat is canonical. The SQL picks MAX(root), so
625        // the higher-ID cases match production.
626        let cases = [
627            (
628                "canonical is the lower id",
629                HashMap::from([(0, BTreeSet::from([1, 2])), (5, BTreeSet::from([3, 4]))]),
630                HashMap::from([(0, 0), (5, 0)]),
631                HashMap::from([(0, 0), (5, 0)]),
632            ),
633            (
634                "canonical is the higher id",
635                HashMap::from([(0, BTreeSet::from([1, 2])), (5, BTreeSet::from([3, 4]))]),
636                HashMap::from([(0, 5), (5, 5)]),
637                HashMap::from([(0, 0), (5, 0)]),
638            ),
639            (
640                "iMessage, SMS, and RCS splits share one canonical",
641                HashMap::from([
642                    (100, BTreeSet::from([10])),
643                    (200, BTreeSet::from([20])),
644                    (300, BTreeSet::from([30])),
645                ]),
646                HashMap::from([(100, 300), (200, 300), (300, 300)]),
647                HashMap::from([(100, 0), (200, 0), (300, 0)]),
648            ),
649        ];
650        for (label, input, chat_lookup_map, expected) in cases {
651            let output =
652                ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
653            assert_eq!(output, expected, "{label}");
654        }
655    }
656
657    #[test]
658    fn lookup_merges_through_missing_canonical() {
659        // Chat 99 has no handle rows, so it is absent from duplicated_data.
660        // Map chats 10 and 20 to the same canonical ID, 99; keep them in one group.
661        let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([2]))]);
662
663        let chat_lookup_map = HashMap::from([(10, 99), (20, 99)]);
664
665        let output =
666            ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
667
668        // Keep both chats in one group even though their canonical chat is absent.
669        assert_eq!(
670            output.get(&10),
671            output.get(&20),
672            "Chats sharing a canonical absent from duplicated_data should still merge"
673        );
674        // Route the canonical chat's messages to the same conversation, even
675        // without handle rows.
676        assert_eq!(output.len(), 3);
677        assert_eq!(output.get(&99), output.get(&10));
678    }
679
680    #[test]
681    fn lookup_component_without_handle_rows_gets_its_own_id() {
682        let input = HashMap::from([(10, BTreeSet::from([1]))]);
683
684        let chat_lookup_map = HashMap::from([(10, 20), (30, 40)]);
685
686        let output =
687            ChatToHandle::dedupe(&input, &chat_lookup_map, &HashMap::new(), &HashMap::new());
688
689        assert_eq!(output.len(), 4);
690        assert_eq!(output.get(&10), Some(&0));
691        assert_eq!(output.get(&20), Some(&0));
692        assert_eq!(output.get(&30), Some(&1));
693        assert_eq!(output.get(&40), Some(&1));
694    }
695
696    #[test]
697    fn chats_differing_only_by_grouped_handles_merge() {
698        // Treat handles 1 and 2 as one person because they use the same number
699        // on iMessage and SMS; handle 3 belongs to someone else.
700        let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
701        input.insert(10, BTreeSet::from([1, 3]));
702        input.insert(20, BTreeSet::from([2, 3]));
703        input.insert(30, BTreeSet::from([1, 2, 3]));
704
705        let groups: HashMap<i32, i32> = HashMap::from([(1, 0), (2, 0), (3, 1)]);
706
707        let output = ChatToHandle::dedupe(&input, &HashMap::new(), &groups, &HashMap::new());
708
709        assert_eq!(output.get(&10), output.get(&20));
710        assert_eq!(
711            output.get(&10),
712            output.get(&30),
713            "a chat listing both of a person's handles is the same participant set"
714        );
715    }
716
717    #[test]
718    fn chats_differing_by_group_stay_separate() {
719        let input = HashMap::from([(10, BTreeSet::from([1, 3])), (20, BTreeSet::from([2, 3]))]);
720
721        let groups: HashMap<i32, i32> = HashMap::from([(1, 0), (2, 5), (3, 1)]);
722
723        let output = ChatToHandle::dedupe(&input, &HashMap::new(), &groups, &HashMap::new());
724
725        assert_ne!(output.get(&10), output.get(&20));
726    }
727
728    #[test]
729    fn ungrouped_handle_does_not_collide_with_group_id() {
730        // Handle 7 is absent from the group map; group 7 exists for handle 1.
731        // Keep the chat with raw handle 7 separate from the chat whose member
732        // belongs to group 7.
733        let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([7]))]);
734
735        let groups: HashMap<i32, i32> = HashMap::from([(1, 7)]);
736
737        let output = ChatToHandle::dedupe(&input, &HashMap::new(), &groups, &HashMap::new());
738
739        assert_ne!(output.get(&10), output.get(&20));
740    }
741
742    #[test]
743    fn grouped_merge_is_transitive_with_lookup() {
744        // Merge 10 and 20 because their participants share a handle group.
745        // Then add a chat_lookup edge from 30 to 20.
746        let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
747        input.insert(10, BTreeSet::from([1]));
748        input.insert(20, BTreeSet::from([2]));
749        input.insert(30, BTreeSet::from([9]));
750
751        let groups: HashMap<i32, i32> = HashMap::from([(1, 0), (2, 0), (9, 4)]);
752        let lookup: HashMap<i32, i32> = HashMap::from([(30, 20), (20, 20)]);
753
754        let output = ChatToHandle::dedupe(&input, &lookup, &groups, &HashMap::new());
755
756        let unique: HashSet<i32> = output.values().copied().collect();
757        assert_eq!(unique.len(), 1);
758    }
759
760    // MARK: Representatives
761    fn chat(rowid: i32, name: Option<&str>) -> crate::tables::chat::Chat {
762        crate::tables::chat::Chat {
763            rowid,
764            chat_identifier: String::new(),
765            service_name: None,
766            display_name: name.map(str::to_string),
767            is_filtered: None,
768            is_blackholed: None,
769            is_pending_review: None,
770        }
771    }
772
773    #[test]
774    fn representative_is_the_most_active_named_row() {
775        let dedup: HashMap<i32, i32> = HashMap::from([(10, 0), (20, 0), (30, 1)]);
776        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
777            (10, chat(10, Some("Old Name"))),
778            (20, chat(20, Some("New Name"))),
779            (30, chat(30, None)),
780        ]);
781        let activity: HashMap<i32, i64> = HashMap::from([(10, 100), (20, 200)]);
782
783        let reps = ChatToHandle::representatives(&dedup, &chats, &activity, &HashMap::new());
784
785        assert_eq!(reps[&10], 20);
786        assert_eq!(reps[&20], 20);
787        assert_eq!(reps[&30], 30);
788    }
789
790    #[test]
791    fn unnamed_active_row_defers_to_a_named_sibling() {
792        // Use the older named row because the newest row has no group name.
793        let dedup: HashMap<i32, i32> = HashMap::from([(10, 0), (20, 0), (25, 0)]);
794        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
795            (10, chat(10, Some("Sardegnas"))),
796            (20, chat(20, Some(""))),
797            (25, chat(25, None)),
798        ]);
799        let activity: HashMap<i32, i64> = HashMap::from([(10, 100), (20, 300), (25, 200)]);
800
801        let reps = ChatToHandle::representatives(&dedup, &chats, &activity, &HashMap::new());
802
803        assert_eq!(reps[&10], 10);
804        assert_eq!(reps[&20], 10);
805        assert_eq!(reps[&25], 10);
806    }
807
808    #[test]
809    fn unnamed_group_uses_its_most_active_row_with_lowest_id_tiebreak() {
810        let dedup: HashMap<i32, i32> = HashMap::from([(10, 0), (20, 0), (30, 0)]);
811        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
812            (10, chat(10, None)),
813            (20, chat(20, None)),
814            (30, chat(30, None)),
815        ]);
816        let activity: HashMap<i32, i64> = HashMap::from([(20, 500), (30, 500)]);
817
818        let reps = ChatToHandle::representatives(&dedup, &chats, &activity, &HashMap::new());
819
820        assert_eq!(reps[&10], 20);
821        assert_eq!(reps[&20], 20);
822        assert_eq!(reps[&30], 20);
823    }
824
825    #[test]
826    fn unnamed_group_representative_has_participant_rows() {
827        let dedup: HashMap<i32, i32> = HashMap::from([(10, 0), (20, 0), (99, 0)]);
828        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
829            (10, chat(10, None)),
830            (20, chat(20, None)),
831            (99, chat(99, None)),
832        ]);
833        let activity: HashMap<i32, i64> = HashMap::from([(10, 100), (20, 200), (99, 300)]);
834        let participants: HashMap<i32, BTreeSet<i32>> =
835            HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
836
837        let reps = ChatToHandle::representatives(&dedup, &chats, &activity, &participants);
838
839        assert_eq!(reps[&10], 20);
840        assert_eq!(reps[&20], 20);
841        assert_eq!(reps[&99], 20);
842    }
843
844    #[test]
845    fn diagnostic_counts_duplicates_over_chats_with_handle_rows() {
846        let db = rusqlite::Connection::open_in_memory().unwrap();
847        db.execute_batch(
848            "CREATE TABLE chat (ROWID INTEGER PRIMARY KEY, chat_identifier TEXT, service_name TEXT, display_name TEXT);
849             CREATE TABLE handle (ROWID INTEGER PRIMARY KEY, id TEXT, person_centric_id TEXT);
850             CREATE TABLE chat_handle_join (chat_id INTEGER, handle_id INTEGER);
851             CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);
852             CREATE TABLE chat_lookup (chat INTEGER, identifier TEXT);
853             INSERT INTO chat VALUES (10, 'a', 'iMessage', NULL), (30, 'b', 'iMessage', NULL), (40, 'c', 'SMS', NULL);
854             INSERT INTO handle VALUES (1, '+15555550100', NULL);
855             INSERT INTO chat_handle_join VALUES (10, 1);
856             INSERT INTO chat_message_join VALUES (10, 1), (30, 2);
857             INSERT INTO chat_lookup VALUES (30, 'x'), (40, 'x');",
858        )
859        .unwrap();
860
861        let diagnostic = ChatToHandle::run_diagnostic(&db).unwrap();
862
863        assert_eq!(diagnostic.total_chats, 1);
864        assert_eq!(diagnostic.total_duplicated, 0);
865        assert_eq!(diagnostic.chats_with_no_handles, 1);
866    }
867
868    #[test]
869    fn last_activity_reads_message_date_when_present() {
870        let db = rusqlite::Connection::open_in_memory().unwrap();
871        db.execute_batch(
872            "CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER, message_date INTEGER DEFAULT 0);
873             INSERT INTO chat_message_join VALUES (1, 1, 10), (1, 2, 30), (2, 3, 20);",
874        )
875        .unwrap();
876
877        let activity = ChatToHandle::get_last_activity(&db).unwrap();
878
879        assert_eq!(activity[&1], 30);
880        assert_eq!(activity[&2], 20);
881    }
882
883    #[test]
884    fn last_activity_falls_back_to_message_table() {
885        let db = rusqlite::Connection::open_in_memory().unwrap();
886        db.execute_batch(
887            "CREATE TABLE chat_message_join (chat_id INTEGER, message_id INTEGER);
888             CREATE TABLE message (ROWID INTEGER PRIMARY KEY, date INTEGER);
889             INSERT INTO message VALUES (1, 10), (2, 30), (3, 20);
890             INSERT INTO chat_message_join VALUES (1, 1), (1, 2), (2, 3);",
891        )
892        .unwrap();
893
894        let activity = ChatToHandle::get_last_activity(&db).unwrap();
895
896        assert_eq!(activity[&1], 30);
897        assert_eq!(activity[&2], 20);
898    }
899
900    // MARK: Display names
901    #[test]
902    fn same_people_with_different_names_are_different_groups() {
903        let input = HashMap::from([(10, BTreeSet::from([1, 2])), (20, BTreeSet::from([1, 2]))]);
904        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
905            (10, chat(10, Some("Sunday Call"))),
906            (20, chat(20, Some("Da Boys"))),
907        ]);
908
909        let output = ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &chats);
910
911        assert_ne!(output.get(&10), output.get(&20));
912    }
913
914    #[test]
915    fn same_people_with_the_same_name_merge() {
916        let input = HashMap::from([(10, BTreeSet::from([1, 2])), (20, BTreeSet::from([1, 2]))]);
917        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
918            (10, chat(10, Some("Family"))),
919            (20, chat(20, Some("Family"))),
920        ]);
921
922        let output = ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &chats);
923
924        assert_eq!(output.get(&10), output.get(&20));
925    }
926
927    #[test]
928    fn unnamed_row_joins_the_first_named_row_with_its_people() {
929        let input = HashMap::from([10, 20, 30, 40].map(|id| (id, BTreeSet::from([1, 2]))));
930        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
931            (10, chat(10, Some("Sunday Call"))),
932            (20, chat(20, Some("Da Boys"))),
933            (30, chat(30, None)),
934            (40, chat(40, Some(""))),
935        ]);
936
937        let output = ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &chats);
938
939        assert_eq!(output.get(&30), output.get(&10));
940        assert_eq!(output.get(&40), output.get(&10));
941        assert_ne!(output.get(&30), output.get(&20));
942    }
943
944    #[test]
945    fn unnamed_rows_merge_with_each_other_when_nothing_is_named() {
946        let input = HashMap::from([(10, BTreeSet::from([1, 2])), (20, BTreeSet::from([1, 2]))]);
947        let chats: HashMap<i32, crate::tables::chat::Chat> =
948            HashMap::from([(10, chat(10, None)), (20, chat(20, None))]);
949
950        let output = ChatToHandle::dedupe(&input, &HashMap::new(), &HashMap::new(), &chats);
951
952        assert_eq!(output.get(&10), output.get(&20));
953    }
954
955    #[test]
956    fn unnamed_row_keeps_its_chat_lookup_conversation() {
957        let input = HashMap::from([10, 20, 30].map(|id| (id, BTreeSet::from([1, 2]))));
958        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
959            (10, chat(10, Some("Alpha"))),
960            (20, chat(20, Some("Beta"))),
961            (30, chat(30, None)),
962        ]);
963        let lookup: HashMap<i32, i32> = HashMap::from([(30, 20)]);
964
965        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
966
967        assert_eq!(output.get(&30), output.get(&20));
968        assert_ne!(output.get(&10), output.get(&20));
969    }
970
971    #[test]
972    fn unnamed_row_linked_to_an_unnamed_row_still_joins_a_named_row() {
973        let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
974        input.insert(10, BTreeSet::from([1, 2]));
975        input.insert(30, BTreeSet::from([1, 2]));
976        input.insert(40, BTreeSet::from([1, 2, 3]));
977        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
978            (10, chat(10, Some("Alpha"))),
979            (30, chat(30, None)),
980            (40, chat(40, None)),
981        ]);
982        let lookup: HashMap<i32, i32> = HashMap::from([(40, 30)]);
983
984        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
985
986        assert_eq!(output.get(&30), output.get(&10));
987        assert_eq!(output.get(&40), output.get(&10));
988    }
989
990    #[test]
991    fn unnamed_anchor_does_not_block_later_unnamed_merges() {
992        let mut input: HashMap<i32, BTreeSet<i32>> = HashMap::new();
993        input.insert(10, BTreeSet::from([1]));
994        input.insert(20, BTreeSet::from([1]));
995        input.insert(30, BTreeSet::from([2]));
996        input.insert(40, BTreeSet::from([2]));
997        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
998            (10, chat(10, None)),
999            (20, chat(20, None)),
1000            (30, chat(30, None)),
1001            (40, chat(40, None)),
1002        ]);
1003        let lookup: HashMap<i32, i32> = HashMap::from([(20, 30)]);
1004
1005        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
1006
1007        assert_eq!(output.get(&10), output.get(&20));
1008        assert_eq!(output.get(&20), output.get(&30));
1009        assert_eq!(output.get(&30), output.get(&40));
1010    }
1011
1012    #[test]
1013    fn named_lookup_canonical_without_handle_rows_still_counts_as_named() {
1014        let input = HashMap::from([(10, BTreeSet::from([1, 2])), (20, BTreeSet::from([1, 2]))]);
1015        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
1016            (10, chat(10, Some("Alpha"))),
1017            (20, chat(20, None)),
1018            (99, chat(99, Some("Beta"))),
1019        ]);
1020        let lookup: HashMap<i32, i32> = HashMap::from([(20, 99)]);
1021
1022        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
1023
1024        assert_ne!(output.get(&10), output.get(&20));
1025    }
1026
1027    #[test]
1028    fn lookup_members_without_handle_rows_join_the_conversation() {
1029        let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
1030        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
1031            (10, chat(10, Some("Beta"))),
1032            (20, chat(20, None)),
1033            (88, chat(88, Some("Alpha"))),
1034            (99, chat(99, None)),
1035        ]);
1036        let lookup: HashMap<i32, i32> = HashMap::from([(20, 99), (88, 99), (99, 99)]);
1037
1038        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
1039
1040        assert_ne!(output.get(&10), output.get(&20));
1041        // Route messages from both linked rows to the conversation, even
1042        // without handle rows.
1043        assert_eq!(output.get(&88), output.get(&20));
1044        assert_eq!(output.get(&99), output.get(&20));
1045    }
1046
1047    #[test]
1048    fn unnamed_rows_join_a_sibling_linked_to_a_named_row() {
1049        let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
1050        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
1051            (10, chat(10, None)),
1052            (20, chat(20, None)),
1053            (88, chat(88, Some("Alpha"))),
1054        ]);
1055        let lookup: HashMap<i32, i32> = HashMap::from([(10, 88), (88, 88)]);
1056
1057        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
1058
1059        assert_eq!(output.get(&10), output.get(&20));
1060        assert_eq!(output.get(&10), output.get(&88));
1061    }
1062
1063    #[test]
1064    fn linked_row_pulls_in_an_earlier_unnamed_anchor() {
1065        let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
1066        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
1067            (10, chat(10, None)),
1068            (20, chat(20, None)),
1069            (88, chat(88, Some("Alpha"))),
1070        ]);
1071        let lookup: HashMap<i32, i32> = HashMap::from([(20, 88), (88, 88)]);
1072
1073        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
1074
1075        assert_eq!(output.get(&10), output.get(&20));
1076        assert_eq!(output.get(&10), output.get(&88));
1077    }
1078
1079    #[test]
1080    fn unlinked_row_joins_the_lower_of_two_linked_conversations() {
1081        let input = HashMap::from([10, 20, 30].map(|id| (id, BTreeSet::from([1]))));
1082        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
1083            (10, chat(10, None)),
1084            (20, chat(20, None)),
1085            (30, chat(30, None)),
1086            (88, chat(88, Some("Alpha"))),
1087            (99, chat(99, Some("Beta"))),
1088        ]);
1089        let lookup: HashMap<i32, i32> = HashMap::from([(10, 88), (88, 88), (20, 99), (99, 99)]);
1090
1091        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
1092
1093        assert_ne!(output.get(&10), output.get(&20));
1094        assert_eq!(output.get(&30), output.get(&10));
1095        assert_eq!(output.get(&88), output.get(&10));
1096        assert_eq!(output.get(&99), output.get(&20));
1097    }
1098
1099    #[test]
1100    fn lookup_only_names_reach_the_representative() {
1101        let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([1]))]);
1102        let chats: HashMap<i32, crate::tables::chat::Chat> = HashMap::from([
1103            (10, chat(10, None)),
1104            (20, chat(20, None)),
1105            (88, chat(88, Some("Alpha"))),
1106            (99, chat(99, Some("Beta"))),
1107        ]);
1108        let lookup: HashMap<i32, i32> = HashMap::from([(10, 88), (88, 88), (20, 99), (99, 99)]);
1109
1110        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
1111        let representatives =
1112            ChatToHandle::representatives(&output, &chats, &HashMap::new(), &input);
1113
1114        assert_ne!(output.get(&10), output.get(&20));
1115        assert_eq!(representatives.get(&10), Some(&88));
1116        assert_eq!(representatives.get(&20), Some(&99));
1117    }
1118
1119    #[test]
1120    fn chat_lookup_still_joins_differently_named_rows() {
1121        // Honor Apple's `chat_lookup` link even when display names differ.
1122        let input = HashMap::from([(10, BTreeSet::from([1])), (20, BTreeSet::from([2]))]);
1123        let chats: HashMap<i32, crate::tables::chat::Chat> =
1124            HashMap::from([(10, chat(10, Some("A"))), (20, chat(20, Some("B")))]);
1125        let lookup: HashMap<i32, i32> = HashMap::from([(10, 20), (20, 20)]);
1126
1127        let output = ChatToHandle::dedupe(&input, &lookup, &HashMap::new(), &chats);
1128
1129        assert_eq!(output.get(&10), output.get(&20));
1130    }
1131}