1use crate::ExportThread;
2use std::collections::HashSet;
3
4pub fn export_markdown(thread: &ExportThread) -> String {
5 let mut out = String::new();
6 out.push_str(&format!("# Thread: {}\n\n", thread.subject));
7
8 for msg in &thread.messages {
9 let sender = msg.from_name.as_deref().unwrap_or(&msg.from_email);
10 let date = msg.date.format("%b %d, %Y %H:%M");
11 out.push_str(&format!("## {} — {}\n\n", sender, date));
12
13 if let Some(text) = &msg.body_text {
14 out.push_str(text.trim());
15 }
16 out.push_str("\n\n");
17
18 if !msg.attachments.is_empty() {
19 out.push_str("**Attachments:**\n");
20 for att in &msg.attachments {
21 let size_kb = att.size_bytes / 1024;
22 out.push_str(&format!("- {} ({}KB)\n", att.filename, size_kb));
23 }
24 out.push('\n');
25 }
26 }
27
28 let participants: HashSet<&str> = thread
29 .messages
30 .iter()
31 .map(|m| m.from_email.as_str())
32 .collect();
33
34 out.push_str(&format!(
35 "---\nExported from mxr | {} messages | {} participants\n",
36 thread.messages.len(),
37 participants.len(),
38 ));
39
40 out
41}
42
43#[cfg(test)]
44mod tests {
45 use super::*;
46 use crate::tests::{empty_body_thread, sample_thread, single_message_thread};
47 use pretty_assertions::assert_eq;
48
49 #[test]
50 fn markdown_starts_with_thread_subject() {
51 let result = export_markdown(&sample_thread());
52 assert!(result.starts_with("# Thread: Deployment rollback plan\n"));
53 }
54
55 #[test]
56 fn markdown_uses_sender_name_when_available() {
57 let result = export_markdown(&sample_thread());
58 assert!(result.contains("## Alice —"));
59 assert!(result.contains("## Bob —"));
60 }
61
62 #[test]
63 fn markdown_falls_back_to_email_when_no_name() {
64 let result = export_markdown(&single_message_thread());
65 assert!(result.contains("## noreply@service.com —"));
66 }
67
68 #[test]
69 fn markdown_includes_deterministic_dates() {
70 let result = export_markdown(&sample_thread());
71 assert!(result.contains("Mar 17, 2026 09:30"));
72 assert!(result.contains("Mar 17, 2026 10:15"));
73 }
74
75 #[test]
76 fn markdown_includes_message_body() {
77 let result = export_markdown(&sample_thread());
78 assert!(result.contains("What's the rollback strategy"));
79 assert!(result.contains("blue-green deployment"));
80 }
81
82 #[test]
83 fn markdown_lists_attachments() {
84 let result = export_markdown(&sample_thread());
85 assert!(result.contains("**Attachments:**"));
86 assert!(result.contains("- runbook.pdf (240KB)"));
87 }
88
89 #[test]
90 fn markdown_footer_has_correct_counts() {
91 let result = export_markdown(&sample_thread());
92 assert!(result.contains("2 messages"));
93 assert!(result.contains("2 participants"));
94 }
95
96 #[test]
97 fn markdown_deduplicates_participants() {
98 let mut thread = sample_thread();
100 thread.messages[1].from_email = "alice@example.com".into();
101 let result = export_markdown(&thread);
102 assert!(result.contains("1 participants"));
103 }
104
105 #[test]
106 fn markdown_handles_empty_body() {
107 let result = export_markdown(&empty_body_thread());
108 assert!(result.contains("## Ghost —"));
110 }
111
112 #[test]
113 fn markdown_is_valid_structure() {
114 let result = export_markdown(&sample_thread());
115 let h1_count = result.matches("# Thread:").count();
117 let h2_count = result.matches("\n## ").count();
118 assert_eq!(h1_count, 1);
119 assert_eq!(h2_count, 2);
120 assert!(result.contains("\n---\n"));
121 }
122}