Skip to main content

mxr_export/
lib.rs

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/// Input data for export. The caller (daemon/CLI) assembles this from store queries.
16#[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
44/// Export a thread in the given format. Returns the exported string.
45pub 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    /// Fixed dates for deterministic test output.
64    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        // ExportFormat is in core, just verify it exists and serializes
171        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}