mail_parser/decoders/
preview.rs1use std::borrow::Cow;
10
11use memchr::memchr;
12
13use super::html::html_to_text_prefix;
14
15const ELLIPSIS: &str = "...";
16const MIN_LEN_WITH_ELLIPSIS: usize = 7;
17const COMMENT_OPEN: &[u8] = b"!--";
18const COMMENT_CLOSE: &[u8] = b"--";
19const LOOKAHEAD: usize = 4;
20const PREVIEW_SLACK: usize = 64;
21
22pub fn preview_text(text: &str, max_len: usize) -> Cow<'_, str> {
34 match strip_cr_prefix(text, max_len.saturating_add(1)) {
35 Stripped::Complete(text) if text.len() <= max_len => text,
36 Stripped::Complete(text) | Stripped::Prefix(text) => Cow::Owned(match text {
37 Cow::Borrowed(text) => truncate_chars(text, max_len),
38 Cow::Owned(text) => truncate_owned(text, max_len),
39 }),
40 }
41}
42
43pub fn preview_html(html: &str, max_len: usize) -> String {
47 preview_html_prefix(html, max_len, true).unwrap_or_default()
48}
49
50pub(crate) fn preview_html_prefix(html: &str, max_len: usize, complete: bool) -> Option<String> {
51 let mut text = String::with_capacity(max_len.saturating_add(PREVIEW_SLACK).min(html.len()));
52 if !html_to_text_prefix(html, max_len, &mut text) && !complete {
53 return None;
54 }
55 if text.len() > max_len {
56 let (keep, ellipsis) = cut(max_len);
57 text.truncate(text.floor_char_boundary(keep));
58 text.extend(ellipsis);
59 }
60 Some(text)
61}
62
63pub fn truncate_text(text: &str, max_len: usize) -> Cow<'_, str> {
65 preview_text(text, max_len)
66}
67
68pub fn truncate_html(html: &str, max_len: usize) -> Cow<'_, str> {
73 match strip_cr_prefix(html, max_len.saturating_add(LOOKAHEAD)) {
74 Stripped::Complete(html) if html.len() <= max_len => html,
75 Stripped::Complete(html) | Stripped::Prefix(html) => {
76 Cow::Owned(truncate_markup(&html, max_len))
77 }
78 }
79}
80
81enum Stripped<'x> {
82 Complete(Cow<'x, str>),
83 Prefix(Cow<'x, str>),
84}
85
86fn strip_cr_prefix(text: &str, needed: usize) -> Stripped<'_> {
87 let window = text.as_bytes().get(..needed).unwrap_or(text.as_bytes());
88 if memchr(b'\r', window).is_none() {
89 return if text.len() < needed {
90 Stripped::Complete(Cow::Borrowed(text))
91 } else {
92 Stripped::Prefix(Cow::Borrowed(text))
93 };
94 }
95 let mut stripped = String::with_capacity(needed.min(text.len()));
96 let mut rest = text;
97 while stripped.len() < needed {
98 let missing = needed - stripped.len();
99 let window = rest.as_bytes().get(..missing).unwrap_or(rest.as_bytes());
100 match memchr(b'\r', window) {
101 Some(cr) => {
102 let (segment, tail) = rest.split_at(cr);
103 stripped.push_str(segment);
104 rest = tail.get(1..).unwrap_or_default();
105 }
106 None if rest.len() <= missing => {
107 stripped.push_str(rest);
108 return Stripped::Complete(Cow::Owned(stripped));
109 }
110 None => {
111 stripped.push_str(
112 rest.get(..rest.ceil_char_boundary(missing))
113 .unwrap_or_default(),
114 );
115 return Stripped::Prefix(Cow::Owned(stripped));
116 }
117 }
118 }
119 Stripped::Prefix(Cow::Owned(stripped))
120}
121
122fn cut(max_len: usize) -> (usize, Option<&'static str>) {
123 if max_len >= MIN_LEN_WITH_ELLIPSIS {
124 (max_len - ELLIPSIS.len(), Some(ELLIPSIS))
125 } else {
126 (max_len, None)
127 }
128}
129
130fn truncate_chars(text: &str, max_len: usize) -> String {
131 let (keep, ellipsis) = cut(max_len);
132 let kept = text
133 .get(..text.floor_char_boundary(keep))
134 .unwrap_or_default();
135 let mut result = String::with_capacity(kept.len() + ELLIPSIS.len());
136 result.push_str(kept);
137 result.extend(ellipsis);
138 result
139}
140
141fn truncate_owned(mut text: String, max_len: usize) -> String {
142 let (keep, ellipsis) = cut(max_len);
143 text.truncate(text.floor_char_boundary(keep));
144 text.extend(ellipsis);
145 text
146}
147
148fn truncate_markup(html: &str, max_len: usize) -> String {
149 let (keep, ellipsis) = cut(max_len);
150 let bytes = html.as_bytes();
151 let mut in_tag = false;
152 let mut in_comment = false;
153 let mut last_tag_end = 0;
154 let mut end = html.len();
155 for (pos, ch) in html.char_indices() {
156 let mut tag_boundary = 0;
157 match ch {
158 '<' if !in_tag => {
159 in_tag = true;
160 if bytes.get(pos + 1..pos + 1 + COMMENT_OPEN.len()) == Some(COMMENT_OPEN) {
161 in_comment = true;
162 }
163 tag_boundary = pos;
164 }
165 '>' if in_tag => {
166 if !in_comment {
167 in_tag = false;
168 tag_boundary = pos + 1;
169 } else if pos
170 .checked_sub(COMMENT_CLOSE.len())
171 .and_then(|start| bytes.get(start..pos))
172 == Some(COMMENT_CLOSE)
173 {
174 in_comment = false;
175 in_tag = false;
176 tag_boundary = pos + 1;
177 }
178 }
179 _ => (),
180 }
181 if ch.len_utf8() + pos > keep {
182 end = if (in_tag || tag_boundary > 0) && last_tag_end > 0 {
183 last_tag_end
184 } else {
185 pos
186 };
187 break;
188 } else if tag_boundary > 0 {
189 last_tag_end = tag_boundary;
190 }
191 }
192 let kept = html.get(..end).unwrap_or_default();
193 let mut result = String::with_capacity(kept.len() + ELLIPSIS.len());
194 result.push_str(kept);
195 result.extend(ellipsis);
196 result
197}
198
199#[cfg(test)]
200mod tests {
201 use crate::fields::tests::{case_text, load_cases};
202 use serde_json::Value as Json;
203 use std::borrow::Cow;
204
205 fn truncations(name: &str) -> Vec<(String, usize, String)> {
206 load_cases(name)
207 .iter()
208 .map(|case| {
209 let max_len = case
210 .get("max_len")
211 .and_then(Json::as_u64)
212 .and_then(|len| usize::try_from(len).ok())
213 .unwrap_or_else(|| panic!("\"max_len\" is a length in {case}"));
214 (
215 case_text(case, "input").to_string(),
216 max_len,
217 case_text(case, "expected").to_string(),
218 )
219 })
220 .collect()
221 }
222
223 #[test]
224 fn truncate_text_fixtures() {
225 let tests = truncations("decoders/truncate_text.json");
226 assert_eq!(tests.len(), 2);
227 for (input, max_len, expected) in tests {
228 assert_eq!(super::truncate_text(&input, max_len), expected, "{input:?}");
229 }
230 }
231
232 #[test]
233 fn truncate_html_fixtures() {
234 let tests = truncations("decoders/truncate_html.json");
235 assert_eq!(tests.len(), 9);
236 for (input, max_len, expected) in tests {
237 assert_eq!(super::truncate_html(&input, max_len), expected, "{input:?}");
238 }
239 }
240
241 #[test]
242 fn carriage_returns_are_stripped() {
243 assert_eq!(
244 super::preview_text("hello\r\nworld\r\n", 100),
245 "hello\nworld\n"
246 );
247 assert_eq!(super::preview_text("hello\r\nworld", 8), "hello...");
248 assert_eq!(super::preview_text("ab\r\rcd", 4), "abcd");
249 assert_eq!(super::preview_text("ab\r\rcde", 4), "abcd");
250 assert!(matches!(
251 super::preview_text("short", 10),
252 Cow::Borrowed("short")
253 ));
254 assert_eq!(
255 super::preview_html("<p>a b</p>\r\n<p>c</p>", 100),
256 "ab\n c\n"
257 );
258 assert_eq!(super::preview_html("<p>hello world</p>", 8), "hello...");
259 assert_eq!(
260 super::truncate_html("<p>\r\nhello</p>", 100),
261 "<p>\nhello</p>"
262 );
263 }
264}