Skip to main content

mxr_reader/
quotes.rs

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
15/// Collapse quoted replies into summary blocks.
16/// Returns (cleaned_text, extracted_quotes).
17pub 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        // Check for "On {date}, {person} wrote:" pattern
25        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        // Check for standalone > quoted blocks
63        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                    // Only include blank lines if more quoted lines follow
72                    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}