Skip to main content

imessage_database/message_types/
edited.rs

1/*!
2 Edited and unsent message metadata from `message_summary_info`.
3*/
4use crabstep::TypedStreamDeserializer;
5use plist::Value;
6
7use crate::{
8    error::plist::PlistParseError,
9    message_types::variants::BalloonProvider,
10    tables::messages::{body::parse_body_typedstream, models::BubbleComponent},
11    util::{
12        dates::TIMESTAMP_FACTOR,
13        plist::{
14            extract_array_key, extract_bytes_key, extract_dictionary, extract_int_key,
15            plist_as_dictionary,
16        },
17    },
18};
19
20/// Edit state for one message body part.
21#[derive(Debug, PartialEq, Eq)]
22pub enum EditStatus {
23    /// Body part was edited.
24    Edited,
25    /// Body part was unsent.
26    Unsent,
27    /// Body part was not changed.
28    Original,
29}
30
31/// One edit-history entry for a message part.
32#[derive(Debug, PartialEq)]
33pub struct EditedEvent {
34    /// Edit timestamp in Apple's nanosecond epoch.
35    pub date: i64,
36    /// The content of the edited message part, deserialized from the
37    /// [`typedstream`](crate::util::typedstream) format.
38    pub text: String,
39    /// Parsed body components for the edited text.
40    pub components: Vec<BubbleComponent>,
41    /// GUID reference to another message, when present.
42    pub guid: Option<String>,
43}
44
45impl EditedEvent {
46    pub(crate) fn new(
47        date: i64,
48        text: String,
49        components: Vec<BubbleComponent>,
50        guid: Option<String>,
51    ) -> Self {
52        Self {
53            date,
54            text,
55            components,
56            guid,
57        }
58    }
59}
60
61/// Edit state and history for one message body part.
62#[derive(Debug, PartialEq)]
63pub struct EditedMessagePart {
64    /// Current edit state for this part.
65    pub status: EditStatus,
66    /// Historical edit entries for this part.
67    pub edit_history: Vec<EditedEvent>,
68}
69
70impl Default for EditedMessagePart {
71    fn default() -> Self {
72        Self {
73            status: EditStatus::Original,
74            edit_history: vec![],
75        }
76    }
77}
78
79/// Parsed edit metadata for every body part in a message.
80///
81/// # Internal Representation
82///
83/// Edited or unsent messages are stored with a `NULL` `text` field.
84/// Edited messages include `message_summary_info` that contains a dictionary
85/// with message body part data, including [`typedstream`](crate::util::typedstream)-encoded
86/// edit history. The order of entries in the edit history represents the order
87/// the part changed: item `0` is the original text and the last item is the
88/// current text.
89///
90/// ## Message Body Parts
91///
92/// - `otr`: dictionary of message part indexes.
93/// - `rp`: list of unsent message part indexes.
94/// - `ec`: dictionary of edited message part indexes to edit history arrays.
95/// - Each `ec` item stores `d` (edit timestamp) and `t` (edited
96///   `attributedBody` typedstream).
97///
98/// # Documentation
99///
100/// Apple describes editing and unsending messages [here](https://support.apple.com/guide/iphone/unsend-and-edit-messages-iphe67195653/ios).
101#[derive(Debug, PartialEq)]
102pub struct EditedMessage {
103    /// One entry per message body part.
104    pub parts: Vec<EditedMessagePart>,
105}
106
107impl<'a> BalloonProvider<'a> for EditedMessage {
108    fn from_map(payload: &'a Value) -> Result<Self, PlistParseError> {
109        // Parse payload
110        let plist_root = plist_as_dictionary(payload)?;
111
112        // Get the parts of the message that may have been altered
113        let message_parts = extract_dictionary(plist_root, "otr")?;
114
115        // Prefill edited data
116        let mut edited = Self::with_capacity(message_parts.len());
117        message_parts
118            .values()
119            .for_each(|_| edited.parts.push(EditedMessagePart::default()));
120
121        if let Ok(edited_message_events) = extract_dictionary(plist_root, "ec") {
122            for (idx, (key, events)) in edited_message_events.iter().enumerate() {
123                let events = events
124                    .as_array()
125                    .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "array".to_string()))?;
126                let parsed_key = key.parse::<usize>().map_err(|_| {
127                    PlistParseError::InvalidType(
128                        "ec dictionary key".to_string(),
129                        "numeric string".to_string(),
130                    )
131                })?;
132
133                for (event_idx, event) in events.iter().enumerate() {
134                    let message_data = event.as_dictionary().ok_or_else(|| {
135                        PlistParseError::InvalidTypeIndex(event_idx, "dictionary".to_string())
136                    })?;
137
138                    let timestamp = extract_int_key(message_data, "d")?
139                        .checked_mul(TIMESTAMP_FACTOR)
140                        .ok_or_else(|| {
141                            PlistParseError::InvalidEditedMessage(
142                                "edit timestamp out of range".to_string(),
143                            )
144                        })?;
145
146                    let data = extract_bytes_key(message_data, "t")?;
147
148                    let mut typedstream = TypedStreamDeserializer::new(data);
149                    let result = parse_body_typedstream(Some(typedstream.iter_root()?), None)
150                        .ok_or_else(|| {
151                            PlistParseError::InvalidEditedMessage(
152                                "Failed to parse typedstream data".to_string(),
153                            )
154                        })?;
155
156                    let text = result.text.ok_or_else(|| {
157                        PlistParseError::InvalidEditedMessage(
158                            "Edit-history entry missing text!".to_string(),
159                        )
160                    })?;
161
162                    let guid = message_data
163                        .get("bcg")
164                        .and_then(|item| item.as_string())
165                        .map(Into::into);
166
167                    if let Some(item) = edited.parts.get_mut(parsed_key) {
168                        item.status = EditStatus::Edited;
169                        item.edit_history.push(EditedEvent::new(
170                            timestamp,
171                            text,
172                            result.components,
173                            guid,
174                        ));
175                    }
176                }
177            }
178        }
179
180        if let Ok(unsent_message_indexes) = extract_array_key(plist_root, "rp") {
181            for (idx, unsent_message_idx) in unsent_message_indexes.iter().enumerate() {
182                let parsed_idx = unsent_message_idx
183                    .as_signed_integer()
184                    .ok_or_else(|| PlistParseError::InvalidTypeIndex(idx, "int".to_string()))?
185                    as usize;
186                if let Some(item) = edited.parts.get_mut(parsed_idx) {
187                    item.status = EditStatus::Unsent;
188                }
189            }
190        }
191
192        Ok(edited)
193    }
194}
195
196impl EditedMessage {
197    /// Build an empty edit record with capacity for known body parts.
198    fn with_capacity(capacity: usize) -> Self {
199        EditedMessage {
200            parts: Vec::with_capacity(capacity),
201        }
202    }
203
204    /// Return edit metadata for the given body part index.
205    #[must_use]
206    pub fn part(&self, index: usize) -> Option<&EditedMessagePart> {
207        self.parts.get(index)
208    }
209
210    /// `true` when the given body part exists and has not been edited or unsent.
211    #[must_use]
212    pub fn is_unedited_at(&self, index: usize) -> bool {
213        match self.parts.get(index) {
214            Some(part) => matches!(part.status, EditStatus::Original),
215            None => false,
216        }
217    }
218
219    /// Number of body parts tracked by this edit payload.
220    #[must_use]
221    pub fn items(&self) -> usize {
222        self.parts.len()
223    }
224}
225
226#[cfg(test)]
227mod test_parser {
228    use crate::message_types::edited::{EditStatus, EditedEvent, EditedMessagePart};
229    use crate::message_types::text_effects::{style::Style, text_effect::TextEffect};
230    use crate::message_types::{edited::EditedMessage, variants::BalloonProvider};
231    use crate::tables::messages::models::{AttributedRange, BubbleComponent};
232
233    use plist::Value;
234    use std::env::current_dir;
235    use std::fs::File;
236
237    #[test]
238    fn test_parse_edited() {
239        let plist_path = current_dir()
240            .unwrap()
241            .as_path()
242            .join("test_data/edited_message/Edited.plist");
243        let plist_data = File::open(plist_path).unwrap();
244        let plist = Value::from_reader(plist_data).unwrap();
245        let parsed = EditedMessage::from_map(&plist).unwrap();
246
247        let expected = EditedMessage {
248            parts: vec![EditedMessagePart {
249                status: EditStatus::Edited,
250                edit_history: vec![
251                    EditedEvent::new(
252                        690513474000000000,
253                        "First message  ".to_string(),
254                        vec![BubbleComponent::Run(vec![AttributedRange::text(
255                            0,
256                            15,
257                            vec![TextEffect::Default],
258                        )])],
259                        None,
260                    ),
261                    EditedEvent::new(
262                        690513480000000000,
263                        "Edit 1".to_string(),
264                        vec![BubbleComponent::Run(vec![AttributedRange::text(
265                            0,
266                            6,
267                            vec![TextEffect::Default],
268                        )])],
269                        None,
270                    ),
271                    EditedEvent::new(
272                        690513485000000000,
273                        "Edit 2".to_string(),
274                        vec![BubbleComponent::Run(vec![AttributedRange::text(
275                            0,
276                            6,
277                            vec![TextEffect::Default],
278                        )])],
279                        None,
280                    ),
281                    EditedEvent::new(
282                        690513494000000000,
283                        "Edited message".to_string(),
284                        vec![BubbleComponent::Run(vec![AttributedRange::text(
285                            0,
286                            14,
287                            vec![TextEffect::Default],
288                        )])],
289                        None,
290                    ),
291                ],
292            }],
293        };
294
295        assert_eq!(parsed, expected);
296
297        let expected_item = Some(expected.parts.first().unwrap());
298        assert_eq!(parsed.part(0), expected_item);
299    }
300
301    #[test]
302    fn test_parse_edited_to_link() {
303        let plist_path = current_dir()
304            .unwrap()
305            .as_path()
306            .join("test_data/edited_message/EditedToLink.plist");
307        let plist_data = File::open(plist_path).unwrap();
308        let plist = Value::from_reader(plist_data).unwrap();
309        let parsed = EditedMessage::from_map(&plist).unwrap();
310
311        let expected = EditedMessage {
312            parts: vec![
313                EditedMessagePart {
314                    status: EditStatus::Original,
315                    edit_history: vec![], // The first part of this is the URL preview
316                },
317                EditedMessagePart {
318                    status: EditStatus::Edited,
319                    edit_history: vec![
320                        EditedEvent::new(
321                            690514004000000000,
322                            "here we go!".to_string(),
323                            vec![BubbleComponent::Run(vec![AttributedRange::text(
324                                0,
325                                11,
326                                vec![TextEffect::Default],
327                            )])],
328                            None,
329                        ),
330                        EditedEvent::new(
331                            690514772000000000,
332                            "https://github.com/ReagentX/imessage-exporter/issues/10".to_string(),
333                            vec![BubbleComponent::Run(vec![AttributedRange::text(
334                                0,
335                                55,
336                                vec![TextEffect::Default],
337                            )])],
338                            Some("292BF9C6-C9B8-4827-BE65-6EA1C9B5B384".to_string()),
339                        ),
340                    ],
341                },
342            ],
343        };
344
345        assert_eq!(parsed, expected);
346    }
347
348    #[test]
349    fn test_parse_edited_to_link_and_back() {
350        let plist_path = current_dir()
351            .unwrap()
352            .as_path()
353            .join("test_data/edited_message/EditedToLinkAndBack.plist");
354        let plist_data = File::open(plist_path).unwrap();
355        let plist = Value::from_reader(plist_data).unwrap();
356        let parsed = EditedMessage::from_map(&plist).unwrap();
357
358        let expected = EditedMessage {
359            parts: vec![EditedMessagePart {
360                status: EditStatus::Edited,
361                edit_history: vec![
362                    EditedEvent::new(
363                        690514809000000000,
364                        "This is a normal message".to_string(),
365                        vec![BubbleComponent::Run(vec![AttributedRange::text(
366                            0,
367                            24,
368                            vec![TextEffect::Default],
369                        )])],
370                        None,
371                    ),
372                    EditedEvent::new(
373                        690514819000000000,
374                        "Edit to a url https://github.com/ReagentX/imessage-exporter/issues/10"
375                            .to_string(),
376                        vec![BubbleComponent::Run(vec![AttributedRange::text(
377                            0,
378                            69,
379                            vec![TextEffect::Default],
380                        )])],
381                        Some("0B9103FE-280C-4BD0-A66F-4EDEE3443247".to_string()),
382                    ),
383                    EditedEvent::new(
384                        690514834000000000,
385                        "And edit it back to a normal message...".to_string(),
386                        vec![BubbleComponent::Run(vec![AttributedRange::text(
387                            0,
388                            39,
389                            vec![TextEffect::Default],
390                        )])],
391                        Some("0D93DF88-05BA-4418-9B20-79918ADD9923".to_string()),
392                    ),
393                ],
394            }],
395        };
396
397        assert_eq!(parsed, expected);
398    }
399
400    #[test]
401    fn test_parse_deleted() {
402        let plist_path = current_dir()
403            .unwrap()
404            .as_path()
405            .join("test_data/edited_message/Deleted.plist");
406        let plist_data = File::open(plist_path).unwrap();
407        let plist = Value::from_reader(plist_data).unwrap();
408        let parsed = EditedMessage::from_map(&plist).unwrap();
409
410        let expected = EditedMessage {
411            parts: vec![EditedMessagePart {
412                status: EditStatus::Unsent,
413                edit_history: vec![],
414            }],
415        };
416
417        assert_eq!(parsed, expected);
418    }
419
420    #[test]
421    fn test_parse_multipart_deleted() {
422        let plist_path = current_dir()
423            .unwrap()
424            .as_path()
425            .join("test_data/edited_message/MultiPartOneDeleted.plist");
426        let plist_data = File::open(plist_path).unwrap();
427        let plist = Value::from_reader(plist_data).unwrap();
428        let parsed = EditedMessage::from_map(&plist).unwrap();
429
430        let expected = EditedMessage {
431            parts: vec![
432                EditedMessagePart {
433                    status: EditStatus::Original,
434                    edit_history: vec![],
435                },
436                EditedMessagePart {
437                    status: EditStatus::Original,
438                    edit_history: vec![],
439                },
440                EditedMessagePart {
441                    status: EditStatus::Original,
442                    edit_history: vec![],
443                },
444                EditedMessagePart {
445                    status: EditStatus::Unsent,
446                    edit_history: vec![],
447                },
448            ],
449        };
450
451        assert_eq!(parsed, expected);
452    }
453
454    #[test]
455    fn test_parse_multipart_edited_and_deleted() {
456        let plist_path = current_dir()
457            .unwrap()
458            .as_path()
459            .join("test_data/edited_message/EditedAndDeleted.plist");
460        let plist_data = File::open(plist_path).unwrap();
461        let plist = Value::from_reader(plist_data).unwrap();
462        let parsed = EditedMessage::from_map(&plist).unwrap();
463
464        let expected = EditedMessage {
465            parts: vec![
466                EditedMessagePart {
467                    status: EditStatus::Original,
468                    edit_history: vec![],
469                },
470                EditedMessagePart {
471                    status: EditStatus::Original,
472                    edit_history: vec![],
473                },
474                EditedMessagePart {
475                    status: EditStatus::Edited,
476                    edit_history: vec![
477                        EditedEvent::new(
478                            743907180000000000,
479                            "Second message".to_string(),
480                            vec![BubbleComponent::Run(vec![AttributedRange::text(
481                                0,
482                                14,
483                                vec![TextEffect::Default],
484                            )])],
485                            None,
486                        ),
487                        EditedEvent::new(
488                            743907190000000000,
489                            "Second message got edited!".to_string(),
490                            vec![BubbleComponent::Run(vec![AttributedRange::text(
491                                0,
492                                26,
493                                vec![TextEffect::Default],
494                            )])],
495                            None,
496                        ),
497                    ],
498                },
499            ],
500        };
501
502        assert_eq!(parsed, expected);
503    }
504
505    #[test]
506    fn test_parse_multipart_edited_and_unsent() {
507        let plist_path = current_dir()
508            .unwrap()
509            .as_path()
510            .join("test_data/edited_message/EditedAndUnsent.plist");
511        let plist_data = File::open(plist_path).unwrap();
512        let plist = Value::from_reader(plist_data).unwrap();
513        let parsed = EditedMessage::from_map(&plist).unwrap();
514
515        let expected = EditedMessage {
516            parts: vec![
517                EditedMessagePart {
518                    status: EditStatus::Original,
519                    edit_history: vec![],
520                },
521                EditedMessagePart {
522                    status: EditStatus::Original,
523                    edit_history: vec![],
524                },
525                EditedMessagePart {
526                    status: EditStatus::Edited,
527                    edit_history: vec![
528                        EditedEvent::new(
529                            743907435000000000,
530                            "Second test".to_string(),
531                            vec![BubbleComponent::Run(vec![AttributedRange::text(
532                                0,
533                                11,
534                                vec![TextEffect::Default],
535                            )])],
536                            None,
537                        ),
538                        EditedEvent::new(
539                            743907448000000000,
540                            "Second test was edited!".to_string(),
541                            vec![BubbleComponent::Run(vec![AttributedRange::text(
542                                0,
543                                23,
544                                vec![TextEffect::Default],
545                            )])],
546                            None,
547                        ),
548                    ],
549                },
550                EditedMessagePart {
551                    status: EditStatus::Unsent,
552                    edit_history: vec![],
553                },
554            ],
555        };
556
557        assert_eq!(parsed, expected);
558    }
559
560    #[test]
561    fn test_parse_edited_with_formatting() {
562        let plist_path = current_dir()
563            .unwrap()
564            .as_path()
565            .join("test_data/edited_message/EditedWithFormatting.plist");
566        let plist_data = File::open(plist_path).unwrap();
567        let plist = Value::from_reader(plist_data).unwrap();
568        let parsed = EditedMessage::from_map(&plist).unwrap();
569
570        let expected = EditedMessage {
571            parts: vec![EditedMessagePart {
572                status: EditStatus::Edited,
573                edit_history: vec![
574                    EditedEvent::new(
575                        758573156000000000,
576                        "Test".to_string(),
577                        vec![BubbleComponent::Run(vec![AttributedRange::text(
578                            0,
579                            4,
580                            vec![TextEffect::Default],
581                        )])],
582                        None,
583                    ),
584                    EditedEvent::new(
585                        758573166000000000,
586                        "Test".to_string(),
587                        vec![BubbleComponent::Run(vec![AttributedRange::text(
588                            0,
589                            4,
590                            vec![TextEffect::Styles(vec![Style::Strikethrough])],
591                        )])],
592                        Some("76A466B8-D21E-4A20-AF62-FF2D3A20D31C".to_string()),
593                    ),
594                ],
595            }],
596        };
597
598        assert_eq!(parsed, expected);
599
600        let expected_item = Some(expected.parts.first().unwrap());
601        assert_eq!(parsed.part(0), expected_item);
602    }
603}
604
605#[cfg(test)]
606mod test_gen {
607    use plist::Value;
608    use std::env::current_dir;
609    use std::fs::File;
610
611    use crate::message_types::text_effects::{style::Style, text_effect::TextEffect};
612    use crate::message_types::{edited::EditedMessage, variants::BalloonProvider};
613    use crate::tables::messages::models::{AttributedRange, BubbleComponent};
614
615    #[test]
616    fn test_parse_edited_memoji() {
617        // The `MemojiEdited` fixture: a message edited from "Check this out: ‹Memoji›"
618        // to "Check this out: ‹Memoji› 😀". Both versions must parse the Memoji as
619        // an inline (`emoji_image`) attachment range so the exporter can render it
620        // as an image in the edit history rather than leaking the `\u{FFFC}` placeholder.
621        let plist_path = current_dir()
622            .unwrap()
623            .as_path()
624            .join("test_data/edited_message/MemojiEdited.plist");
625        let plist_data = File::open(plist_path).unwrap();
626        let plist = Value::from_reader(plist_data).unwrap();
627        let parsed = EditedMessage::from_map(&plist).unwrap();
628
629        let history = &parsed.parts[0].edit_history;
630        assert_eq!(history.len(), 2);
631        assert_eq!(history[1].text, "Check this out: \u{FFFC} 😀");
632
633        let BubbleComponent::Run(ranges) = &history[1].components[0] else {
634            panic!("expected a Run, got {:?}", history[1].components);
635        };
636        let memoji = ranges
637            .iter()
638            .find(|range| range.attachment.is_some())
639            .expect("the latest version should contain the Memoji attachment range");
640        assert!(
641            memoji.emoji_image,
642            "the Memoji must be flagged as an inline (emoji_image) sticker"
643        );
644        assert_eq!(
645            memoji.attachment.as_ref().unwrap().guid.as_deref(),
646            Some("F2C223DB-0140-4D49-B38A-C1A3553B4CBA"),
647        );
648    }
649
650    #[test]
651    fn test_parse_edited() {
652        let plist_path = current_dir()
653            .unwrap()
654            .as_path()
655            .join("test_data/edited_message/Edited.plist");
656        let plist_data = File::open(plist_path).unwrap();
657        let plist = Value::from_reader(plist_data).unwrap();
658        let parsed = EditedMessage::from_map(&plist).unwrap();
659
660        let expected_attrs = [
661            vec![BubbleComponent::Run(vec![AttributedRange::text(
662                0,
663                15,
664                vec![TextEffect::Default],
665            )])],
666            vec![BubbleComponent::Run(vec![AttributedRange::text(
667                0,
668                6,
669                vec![TextEffect::Default],
670            )])],
671            vec![BubbleComponent::Run(vec![AttributedRange::text(
672                0,
673                6,
674                vec![TextEffect::Default],
675            )])],
676            vec![BubbleComponent::Run(vec![AttributedRange::text(
677                0,
678                14,
679                vec![TextEffect::Default],
680            )])],
681        ];
682
683        for event in parsed.parts {
684            for (idx, part) in event.edit_history.iter().enumerate() {
685                assert_eq!(part.components, expected_attrs[idx]);
686            }
687        }
688    }
689
690    #[test]
691    fn test_parse_edited_to_link() {
692        let plist_path = current_dir()
693            .unwrap()
694            .as_path()
695            .join("test_data/edited_message/EditedToLink.plist");
696        let plist_data = File::open(plist_path).unwrap();
697        let plist = Value::from_reader(plist_data).unwrap();
698        let parsed = EditedMessage::from_map(&plist).unwrap();
699
700        let expected_attrs = [
701            vec![BubbleComponent::Run(vec![AttributedRange::text(
702                0,
703                11,
704                vec![TextEffect::Default],
705            )])],
706            vec![BubbleComponent::Run(vec![AttributedRange::text(
707                0,
708                55,
709                vec![TextEffect::Default],
710            )])],
711        ];
712
713        for event in parsed.parts {
714            for (idx, part) in event.edit_history.iter().enumerate() {
715                assert_eq!(part.components, expected_attrs[idx]);
716            }
717        }
718    }
719
720    #[test]
721    fn test_parse_edited_to_link_and_back() {
722        let plist_path = current_dir()
723            .unwrap()
724            .as_path()
725            .join("test_data/edited_message/EditedToLinkAndBack.plist");
726        let plist_data = File::open(plist_path).unwrap();
727        let plist = Value::from_reader(plist_data).unwrap();
728        let parsed = EditedMessage::from_map(&plist).unwrap();
729
730        let expected_attrs = [
731            vec![BubbleComponent::Run(vec![AttributedRange::text(
732                0,
733                24,
734                vec![TextEffect::Default],
735            )])],
736            vec![BubbleComponent::Run(vec![AttributedRange::text(
737                0,
738                69,
739                vec![TextEffect::Default],
740            )])],
741            vec![BubbleComponent::Run(vec![AttributedRange::text(
742                0,
743                39,
744                vec![TextEffect::Default],
745            )])],
746        ];
747
748        for event in parsed.parts {
749            for (idx, part) in event.edit_history.iter().enumerate() {
750                assert_eq!(part.components, expected_attrs[idx]);
751            }
752        }
753    }
754
755    #[test]
756    fn test_parse_deleted() {
757        let plist_path = current_dir()
758            .unwrap()
759            .as_path()
760            .join("test_data/edited_message/Deleted.plist");
761        let plist_data = File::open(plist_path).unwrap();
762        let plist = Value::from_reader(plist_data).unwrap();
763        let parsed = EditedMessage::from_map(&plist).unwrap();
764
765        let expected_attrs: [Vec<BubbleComponent>; 0] = [];
766
767        for event in parsed.parts {
768            for (idx, part) in event.edit_history.iter().enumerate() {
769                assert_eq!(part.components, expected_attrs[idx]);
770            }
771        }
772    }
773
774    #[test]
775    fn test_parse_multipart_deleted() {
776        let plist_path = current_dir()
777            .unwrap()
778            .as_path()
779            .join("test_data/edited_message/MultiPartOneDeleted.plist");
780        let plist_data = File::open(plist_path).unwrap();
781        let plist = Value::from_reader(plist_data).unwrap();
782        let parsed = EditedMessage::from_map(&plist).unwrap();
783
784        let expected_attrs: [Vec<BubbleComponent>; 0] = [];
785
786        for event in parsed.parts {
787            for (idx, part) in event.edit_history.iter().enumerate() {
788                assert_eq!(part.components, expected_attrs[idx]);
789            }
790        }
791    }
792
793    #[test]
794    fn test_parse_multipart_edited_and_deleted() {
795        let plist_path = current_dir()
796            .unwrap()
797            .as_path()
798            .join("test_data/edited_message/EditedAndDeleted.plist");
799        let plist_data = File::open(plist_path).unwrap();
800        let plist = Value::from_reader(plist_data).unwrap();
801        let parsed = EditedMessage::from_map(&plist).unwrap();
802
803        let expected_attrs = [
804            vec![BubbleComponent::Run(vec![AttributedRange::text(
805                0,
806                14,
807                vec![TextEffect::Default],
808            )])],
809            vec![BubbleComponent::Run(vec![AttributedRange::text(
810                0,
811                26,
812                vec![TextEffect::Default],
813            )])],
814        ];
815
816        for event in parsed.parts {
817            for (idx, part) in event.edit_history.iter().enumerate() {
818                assert_eq!(part.components, expected_attrs[idx]);
819            }
820        }
821    }
822
823    #[test]
824    fn test_parse_multipart_edited_and_unsent() {
825        let plist_path = current_dir()
826            .unwrap()
827            .as_path()
828            .join("test_data/edited_message/EditedAndUnsent.plist");
829        let plist_data = File::open(plist_path).unwrap();
830        let plist = Value::from_reader(plist_data).unwrap();
831        let parsed = EditedMessage::from_map(&plist).unwrap();
832
833        for parts in &parsed.parts {
834            for part in &parts.edit_history {
835                println!("{:#?}", part.components);
836            }
837        }
838
839        let expected_attrs: [Vec<BubbleComponent>; 2] = [
840            vec![BubbleComponent::Run(vec![AttributedRange::text(
841                0,
842                11,
843                vec![TextEffect::Default],
844            )])],
845            vec![BubbleComponent::Run(vec![AttributedRange::text(
846                0,
847                23,
848                vec![TextEffect::Default],
849            )])],
850        ];
851
852        for event in parsed.parts {
853            for (idx, part) in event.edit_history.iter().enumerate() {
854                assert_eq!(part.components, expected_attrs[idx]);
855            }
856        }
857    }
858
859    #[test]
860    fn test_parse_edited_with_formatting() {
861        let plist_path = current_dir()
862            .unwrap()
863            .as_path()
864            .join("test_data/edited_message/EditedWithFormatting.plist");
865        let plist_data = File::open(plist_path).unwrap();
866        let plist = Value::from_reader(plist_data).unwrap();
867        let parsed = EditedMessage::from_map(&plist).unwrap();
868
869        let expected_attrs: [Vec<BubbleComponent>; 2] = [
870            vec![BubbleComponent::Run(vec![AttributedRange::text(
871                0,
872                4,
873                vec![TextEffect::Default],
874            )])],
875            vec![BubbleComponent::Run(vec![AttributedRange::text(
876                0,
877                4,
878                vec![TextEffect::Styles(vec![Style::Strikethrough])],
879            )])],
880        ];
881
882        for event in parsed.parts {
883            for (idx, part) in event.edit_history.iter().enumerate() {
884                assert_eq!(part.components, expected_attrs[idx]);
885            }
886        }
887    }
888}