1use once_cell::sync::Lazy;
2use regex::Regex;
3
4#[derive(Debug, Clone)]
5pub struct QuotedBlock {
6 pub from: Option<String>,
7 pub date: Option<String>,
8 pub content: String,
9}
10
11static ON_WROTE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^On .+wrote:\s*$").unwrap());
12
13static QUOTE_PREFIX: Lazy<Regex> = Lazy::new(|| Regex::new(r"^>+\s?").unwrap());
14
15pub fn collapse(text: &str) -> (String, Vec<QuotedBlock>) {
18 let lines: Vec<&str> = text.lines().collect();
19 let mut result = Vec::new();
20 let mut quotes = Vec::new();
21 let mut i = 0;
22
23 while i < lines.len() {
24 if ON_WROTE.is_match(lines[i]) {
26 let header = lines[i];
27 let from = extract_from_on_wrote(header);
28 let mut quote_lines = Vec::new();
29 i += 1;
30
31 while i < lines.len() {
32 if QUOTE_PREFIX.is_match(lines[i]) {
33 let stripped = QUOTE_PREFIX.replace(lines[i], "").to_string();
34 quote_lines.push(stripped);
35 i += 1;
36 } else if lines[i].trim().is_empty()
37 && i + 1 < lines.len()
38 && QUOTE_PREFIX.is_match(lines[i + 1])
39 {
40 quote_lines.push(String::new());
41 i += 1;
42 } else {
43 break;
44 }
45 }
46
47 let label = if let Some(ref f) = from {
48 format!("[previous message from {f}]")
49 } else {
50 "[previous message]".to_string()
51 };
52 result.push(label);
53
54 quotes.push(QuotedBlock {
55 from,
56 date: None,
57 content: quote_lines.join("\n"),
58 });
59 continue;
60 }
61
62 if QUOTE_PREFIX.is_match(lines[i]) {
64 let mut quote_lines = Vec::new();
65 while i < lines.len() && (QUOTE_PREFIX.is_match(lines[i]) || lines[i].trim().is_empty())
66 {
67 if QUOTE_PREFIX.is_match(lines[i]) {
68 let stripped = QUOTE_PREFIX.replace(lines[i], "").to_string();
69 quote_lines.push(stripped);
70 } else {
71 if i + 1 < lines.len() && QUOTE_PREFIX.is_match(lines[i + 1]) {
73 quote_lines.push(String::new());
74 } else {
75 break;
76 }
77 }
78 i += 1;
79 }
80
81 result.push("[previous message]".to_string());
82 quotes.push(QuotedBlock {
83 from: None,
84 date: None,
85 content: quote_lines.join("\n"),
86 });
87 continue;
88 }
89
90 result.push(lines[i].to_string());
91 i += 1;
92 }
93
94 (result.join("\n"), quotes)
95}
96
97fn extract_from_on_wrote(header: &str) -> Option<String> {
98 let lower = header.to_lowercase();
99 if let Some(wrote_pos) = lower.rfind("wrote:") {
100 let before = header[..wrote_pos].trim();
101 if let Some(last_comma) = before.rfind(',') {
102 let candidate = before[last_comma + 1..].trim();
103 if !candidate.is_empty() {
104 return Some(candidate.to_string());
105 }
106 }
107 }
108 None
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114
115 #[test]
116 fn collapses_on_wrote_block() {
117 let text = "Thanks for the update.\n\nOn Mon, Mar 15, alice@example.com wrote:\n> Original message here\n> Second line";
118 let (cleaned, quotes) = collapse(text);
119 assert!(cleaned.contains("[previous message from alice@example.com]"));
120 assert_eq!(quotes.len(), 1);
121 assert!(quotes[0].content.contains("Original message here"));
122 }
123
124 #[test]
125 fn collapses_bare_quotes() {
126 let text = "My reply.\n\n> Some quoted text\n> More quoted text";
127 let (cleaned, quotes) = collapse(text);
128 assert!(cleaned.contains("[previous message]"));
129 assert_eq!(quotes.len(), 1);
130 }
131
132 #[test]
133 fn preserves_non_quoted_text() {
134 let text = "First line.\nSecond line.\nThird line.";
135 let (cleaned, quotes) = collapse(text);
136 assert_eq!(cleaned, text);
137 assert!(quotes.is_empty());
138 }
139}