1mod json;
2mod llm;
3mod markdown;
4mod mbox;
5
6pub use json::export_json;
7pub use llm::export_llm_context;
8pub use markdown::export_markdown;
9pub use mbox::export_mbox;
10
11use chrono::{DateTime, Utc};
12use mxr_core::types::ExportFormat;
13use mxr_reader::ReaderConfig;
14
15#[derive(Debug, Clone)]
17pub struct ExportThread {
18 pub thread_id: String,
19 pub subject: String,
20 pub messages: Vec<ExportMessage>,
21}
22
23#[derive(Debug, Clone)]
24pub struct ExportMessage {
25 pub id: String,
26 pub from_name: Option<String>,
27 pub from_email: String,
28 pub to: Vec<String>,
29 pub date: DateTime<Utc>,
30 pub subject: String,
31 pub body_text: Option<String>,
32 pub body_html: Option<String>,
33 pub headers_raw: Option<String>,
34 pub attachments: Vec<ExportAttachment>,
35}
36
37#[derive(Debug, Clone)]
38pub struct ExportAttachment {
39 pub filename: String,
40 pub size_bytes: u64,
41 pub local_path: Option<String>,
42}
43
44pub fn export(
46 thread: &ExportThread,
47 format: &ExportFormat,
48 reader_config: &ReaderConfig,
49) -> String {
50 match format {
51 ExportFormat::Markdown => export_markdown(thread),
52 ExportFormat::Json => export_json(thread),
53 ExportFormat::Mbox => export_mbox(thread),
54 ExportFormat::LlmContext => export_llm_context(thread, reader_config),
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61 use chrono::TimeZone;
62
63 fn date_1() -> DateTime<Utc> {
65 Utc.with_ymd_and_hms(2026, 3, 17, 9, 30, 0).unwrap()
66 }
67
68 fn date_2() -> DateTime<Utc> {
69 Utc.with_ymd_and_hms(2026, 3, 17, 10, 15, 0).unwrap()
70 }
71
72 pub(crate) fn sample_thread() -> ExportThread {
73 ExportThread {
74 thread_id: "thread_abc".into(),
75 subject: "Deployment rollback plan".into(),
76 messages: vec![
77 ExportMessage {
78 id: "msg_1".into(),
79 from_name: Some("Alice".into()),
80 from_email: "alice@example.com".into(),
81 to: vec!["team@example.com".into()],
82 date: date_1(),
83 subject: "Deployment rollback plan".into(),
84 body_text: Some("What's the rollback strategy for the v2.1 deploy?".into()),
85 body_html: None,
86 headers_raw: None,
87 attachments: vec![],
88 },
89 ExportMessage {
90 id: "msg_2".into(),
91 from_name: Some("Bob".into()),
92 from_email: "bob@example.com".into(),
93 to: vec!["team@example.com".into()],
94 date: date_2(),
95 subject: "Re: Deployment rollback plan".into(),
96 body_text: Some(
97 "Use blue-green deployment. Keep the old version running on port 8080."
98 .into(),
99 ),
100 body_html: None,
101 headers_raw: None,
102 attachments: vec![ExportAttachment {
103 filename: "runbook.pdf".into(),
104 size_bytes: 245_760,
105 local_path: Some("/tmp/mxr/runbook.pdf".into()),
106 }],
107 },
108 ],
109 }
110 }
111
112 pub(crate) fn single_message_thread() -> ExportThread {
113 ExportThread {
114 thread_id: "thread_solo".into(),
115 subject: "Quick question".into(),
116 messages: vec![ExportMessage {
117 id: "msg_solo".into(),
118 from_name: None,
119 from_email: "noreply@service.com".into(),
120 to: vec!["user@example.com".into()],
121 date: date_1(),
122 subject: "Quick question".into(),
123 body_text: Some("Is this working?".into()),
124 body_html: Some("<p>Is this working?</p>".into()),
125 headers_raw: None,
126 attachments: vec![],
127 }],
128 }
129 }
130
131 pub(crate) fn empty_body_thread() -> ExportThread {
132 ExportThread {
133 thread_id: "thread_empty".into(),
134 subject: "No body".into(),
135 messages: vec![ExportMessage {
136 id: "msg_empty".into(),
137 from_name: Some("Ghost".into()),
138 from_email: "ghost@void.com".into(),
139 to: vec![],
140 date: date_1(),
141 subject: "No body".into(),
142 body_text: None,
143 body_html: None,
144 headers_raw: None,
145 attachments: vec![],
146 }],
147 }
148 }
149
150 #[test]
151 fn export_dispatch_routes_to_correct_format() {
152 let thread = sample_thread();
153 let config = ReaderConfig::default();
154
155 let md = export(&thread, &ExportFormat::Markdown, &config);
156 assert!(md.starts_with("# Thread:"));
157
158 let json = export(&thread, &ExportFormat::Json, &config);
159 assert!(json.starts_with('{'));
160
161 let mbox_out = export(&thread, &ExportFormat::Mbox, &config);
162 assert!(mbox_out.starts_with("From "));
163
164 let llm = export(&thread, &ExportFormat::LlmContext, &config);
165 assert!(llm.starts_with("Thread:"));
166 }
167
168 #[test]
169 fn export_format_parsing() {
170 let fmt = ExportFormat::Markdown;
172 let json = serde_json::to_string(&fmt).unwrap();
173 let roundtrip: ExportFormat = serde_json::from_str(&json).unwrap();
174 assert_eq!(roundtrip, ExportFormat::Markdown);
175 }
176}