Skip to main content

mxr_export/
json.rs

1use crate::ExportThread;
2use serde::Serialize;
3use std::collections::HashSet;
4
5#[derive(Serialize)]
6struct JsonThread {
7    thread_id: String,
8    subject: String,
9    participants: Vec<String>,
10    message_count: usize,
11    messages: Vec<JsonMessage>,
12}
13
14#[derive(Serialize)]
15struct JsonMessage {
16    id: String,
17    from: JsonAddress,
18    to: Vec<String>,
19    date: String,
20    subject: String,
21    body_text: Option<String>,
22    attachments: Vec<JsonAttachment>,
23}
24
25#[derive(Serialize)]
26struct JsonAddress {
27    name: Option<String>,
28    email: String,
29}
30
31#[derive(Serialize)]
32struct JsonAttachment {
33    filename: String,
34    size_bytes: u64,
35}
36
37pub fn export_json(thread: &ExportThread) -> String {
38    let participants: Vec<String> = thread
39        .messages
40        .iter()
41        .map(|m| m.from_email.clone())
42        .collect::<HashSet<_>>()
43        .into_iter()
44        .collect();
45
46    let json_thread = JsonThread {
47        thread_id: thread.thread_id.clone(),
48        subject: thread.subject.clone(),
49        message_count: thread.messages.len(),
50        participants,
51        messages: thread
52            .messages
53            .iter()
54            .map(|m| JsonMessage {
55                id: m.id.clone(),
56                from: JsonAddress {
57                    name: m.from_name.clone(),
58                    email: m.from_email.clone(),
59                },
60                to: m.to.clone(),
61                date: m.date.to_rfc3339(),
62                subject: m.subject.clone(),
63                body_text: m.body_text.clone(),
64                attachments: m
65                    .attachments
66                    .iter()
67                    .map(|a| JsonAttachment {
68                        filename: a.filename.clone(),
69                        size_bytes: a.size_bytes,
70                    })
71                    .collect(),
72            })
73            .collect(),
74    };
75
76    serde_json::to_string_pretty(&json_thread).unwrap_or_default()
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use crate::tests::{empty_body_thread, sample_thread, single_message_thread};
83
84    #[test]
85    fn json_is_valid_and_parseable() {
86        let result = export_json(&sample_thread());
87        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
88        assert!(parsed.is_object());
89    }
90
91    #[test]
92    fn json_has_correct_message_count() {
93        let result = export_json(&sample_thread());
94        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
95        assert_eq!(parsed["message_count"], 2);
96    }
97
98    #[test]
99    fn json_preserves_thread_metadata() {
100        let result = export_json(&sample_thread());
101        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
102        assert_eq!(parsed["thread_id"], "thread_abc");
103        assert_eq!(parsed["subject"], "Deployment rollback plan");
104    }
105
106    #[test]
107    fn json_messages_have_rfc3339_dates() {
108        let result = export_json(&sample_thread());
109        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
110        let date = parsed["messages"][0]["date"].as_str().unwrap();
111        // RFC3339 contains T separator and timezone
112        assert!(date.contains('T'));
113        assert!(date.ends_with("+00:00") || date.ends_with('Z'));
114    }
115
116    #[test]
117    fn json_includes_attachments_with_size() {
118        let result = export_json(&sample_thread());
119        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
120        let atts = &parsed["messages"][1]["attachments"];
121        assert_eq!(atts.as_array().unwrap().len(), 1);
122        assert_eq!(atts[0]["filename"], "runbook.pdf");
123        assert_eq!(atts[0]["size_bytes"], 245_760);
124    }
125
126    #[test]
127    fn json_from_address_has_name_and_email() {
128        let result = export_json(&sample_thread());
129        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
130        let from = &parsed["messages"][0]["from"];
131        assert_eq!(from["name"], "Alice");
132        assert_eq!(from["email"], "alice@example.com");
133    }
134
135    #[test]
136    fn json_from_name_is_null_when_missing() {
137        let result = export_json(&single_message_thread());
138        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
139        assert!(parsed["messages"][0]["from"]["name"].is_null());
140    }
141
142    #[test]
143    fn json_body_text_is_null_when_missing() {
144        let result = export_json(&empty_body_thread());
145        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
146        assert!(parsed["messages"][0]["body_text"].is_null());
147    }
148
149    #[test]
150    fn json_includes_to_recipients() {
151        let result = export_json(&sample_thread());
152        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
153        let to = parsed["messages"][0]["to"].as_array().unwrap();
154        assert_eq!(to[0], "team@example.com");
155    }
156
157    #[test]
158    fn json_roundtrip_preserves_message_ids() {
159        let result = export_json(&sample_thread());
160        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
161        let ids: Vec<&str> = parsed["messages"]
162            .as_array()
163            .unwrap()
164            .iter()
165            .map(|m| m["id"].as_str().unwrap())
166            .collect();
167        assert_eq!(ids, vec!["msg_1", "msg_2"]);
168    }
169}