1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub struct CleanOptions {
4 pub preserve_paragraphs: bool,
7
8 pub strip_markdown_artifacts: bool,
10
11 pub strip_invisible_chars: bool,
13
14 pub collapse_all_whitespace: bool,
16}
17
18impl CleanOptions {
19 pub fn builder() -> CleanBuilder {
21 CleanBuilder::default()
22 }
23}
24
25impl Default for CleanOptions {
26 fn default() -> Self {
27 Self {
28 preserve_paragraphs: true,
29 strip_markdown_artifacts: true,
30 strip_invisible_chars: true,
31 collapse_all_whitespace: false,
32 }
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct CleanBuilder {
39 preserve_paragraphs: bool,
40 strip_markdown_artifacts: bool,
41 strip_invisible_chars: bool,
42 collapse_all_whitespace: bool,
43}
44
45impl Default for CleanBuilder {
46 fn default() -> Self {
47 Self {
48 preserve_paragraphs: true,
49 strip_markdown_artifacts: true,
50 strip_invisible_chars: true,
51 collapse_all_whitespace: false,
52 }
53 }
54}
55
56impl CleanBuilder {
57 pub fn new() -> Self {
58 Self::default()
59 }
60
61 pub fn preserve_paragraphs(mut self, preserve: bool) -> Self {
62 self.preserve_paragraphs = preserve;
63 self
64 }
65
66 pub fn strip_markdown_artifacts(mut self, strip: bool) -> Self {
67 self.strip_markdown_artifacts = strip;
68 self
69 }
70
71 pub fn strip_invisible_chars(mut self, strip: bool) -> Self {
72 self.strip_invisible_chars = strip;
73 self
74 }
75
76 pub fn collapse_all_whitespace(mut self, collapse: bool) -> Self {
77 self.collapse_all_whitespace = collapse;
78 self
79 }
80
81 pub fn build(self) -> CleanOptions {
82 CleanOptions {
83 preserve_paragraphs: self.preserve_paragraphs,
84 strip_markdown_artifacts: self.strip_markdown_artifacts,
85 strip_invisible_chars: self.strip_invisible_chars,
86 collapse_all_whitespace: self.collapse_all_whitespace,
87 }
88 }
89
90 pub fn clean(self, text: &str) -> String {
91 clean_with_options(text, self.build())
92 }
93}
94
95pub fn clean(text: &str) -> String {
102 clean_with_options(text, CleanOptions::default())
103}
104
105pub fn clean_with_builder(text: &str, builder: CleanBuilder) -> String {
107 builder.clean(text)
108}
109
110pub fn clean_with_options(text: &str, options: CleanOptions) -> String {
112 let mut result = text.to_string();
113
114 if options.strip_invisible_chars {
115 result = trim_bom_and_zero_width(&result);
116 }
117
118 if options.strip_markdown_artifacts {
119 result = trim_html_markdown_artifacts(&result);
120 }
121
122 if options.collapse_all_whitespace {
123 collapse_whitespace_to_single_space(&result)
124 } else if options.preserve_paragraphs {
125 trim_consecutive_whitespaces(&result)
126 } else {
127 collapse_whitespace_to_single_space(&result)
128 }
129}
130
131pub fn trim_bom_and_zero_width(text: &str) -> String {
133 const REMOVED: [char; 5] = ['\u{feff}', '\u{200b}', '\u{200c}', '\u{200d}', '\u{2060}'];
134
135 text.chars()
136 .filter(|ch| !REMOVED.contains(ch))
137 .collect()
138}
139
140pub fn trim_consecutive_whitespaces(text: &str) -> String {
147 let normalized = normalize_newlines(text);
148 let mut output = String::with_capacity(normalized.len());
149 let mut newline_count = 0;
150 let mut pending_space = false;
151
152 for ch in normalized.chars() {
153 match ch {
154 '\n' => {
155 newline_count += 1;
156 pending_space = false;
157 }
158 ' ' | '\t' => {
159 if newline_count == 0 {
160 pending_space = true;
161 }
162 }
163 _ => {
164 if newline_count > 0 {
165 if !output.ends_with('\n') {
166 if newline_count == 1 {
167 output.push('\n');
168 } else {
169 output.push_str("\n\n");
170 }
171 }
172 newline_count = 0;
173 } else if pending_space && !output.ends_with(' ') && !output.ends_with('\n') {
174 output.push(' ');
175 }
176
177 pending_space = false;
178 output.push(ch);
179 }
180 }
181 }
182
183 output.trim_matches(|c: char| c.is_whitespace()).to_string()
184}
185
186fn collapse_whitespace_to_single_space(text: &str) -> String {
187 let mut output = String::with_capacity(text.len());
188 let mut pending_space = false;
189
190 for ch in normalize_newlines(text).chars() {
191 if ch.is_whitespace() {
192 pending_space = true;
193 continue;
194 }
195
196 if pending_space && !output.is_empty() {
197 output.push(' ');
198 }
199
200 pending_space = false;
201 output.push(ch);
202 }
203
204 output.trim().to_string()
205}
206
207fn normalize_newlines(text: &str) -> String {
208 text.replace("\r\n", "\n").replace('\r', "\n")
209}
210
211pub fn trim_html_markdown_artifacts(text: &str) -> String {
219 let mut current = text.trim().to_string();
220
221 loop {
222 let before = current.clone();
223 current = strip_edge_artifacts(¤t);
224 current = current.trim().to_string();
225 if current == before {
226 break;
227 }
228 }
229
230 current
231}
232
233fn strip_edge_artifacts(text: &str) -> String {
234 if let Some(stripped) = strip_html_tag_edge(text) {
235 return stripped;
236 }
237
238 if let Some(stripped) = strip_heading_prefix(text) {
239 return stripped.trim_start().to_string();
240 }
241
242 if let Some(stripped) = strip_blockquote_or_list_prefix(text) {
243 return stripped.trim_start().to_string();
244 }
245
246 if let Some(stripped) = strip_unpaired_edge_markers(text, true) {
247 return stripped;
248 }
249
250 if let Some(stripped) = strip_unpaired_edge_markers(text, false) {
251 return stripped;
252 }
253
254 if let Some(stripped) = strip_matching_wrappers(text) {
255 return stripped.trim().to_string();
256 }
257
258 text.to_string()
259}
260
261fn strip_html_tag_edge(text: &str) -> Option<String> {
262 let lower = text.to_lowercase();
263
264 let html_tags = ["<div>", "<p>", "<span>", "<strong>", "<em>", "<b>", "<i>"];
265 for tag in html_tags {
266 if lower.starts_with(tag) {
267 return Some(text[tag.len()..].trim_start().to_string());
268 }
269 }
270
271 let trimmed_end = text.trim_end();
272 let lower_end = trimmed_end.to_lowercase();
273 for tag in html_tags {
274 if lower_end.ends_with(tag) {
275 return Some(trimmed_end[..trimmed_end.len() - tag.len()].trim_end().to_string());
276 }
277 }
278
279 let closing_tags = ["</div>", "</p>", "</span>", "</strong>", "</em>", "</b>", "</i>"];
280 for tag in closing_tags {
281 if lower.starts_with(tag) {
282 return Some(text[tag.len()..].trim_start().to_string());
283 }
284 }
285
286 for tag in closing_tags {
287 if lower_end.ends_with(tag) {
288 return Some(trimmed_end[..trimmed_end.len() - tag.len()].trim_end().to_string());
289 }
290 }
291
292 if lower.starts_with("<!--") {
293 return Some(text[4..].trim_start().to_string());
294 }
295
296 if lower_end.ends_with("-->") {
297 return Some(trimmed_end[..trimmed_end.len() - 3].trim_end().to_string());
298 }
299
300 None
301}
302
303fn strip_blockquote_or_list_prefix(text: &str) -> Option<String> {
304 let trimmed = text.trim_start();
305
306 if trimmed.starts_with("> ") {
307 return Some(trimmed[2..].to_string());
308 }
309
310 if trimmed == ">" {
311 return Some(String::new());
312 }
313
314 for prefix in ["- ", "* ", "+ "] {
315 if trimmed.starts_with(prefix) {
316 return Some(trimmed[prefix.len()..].to_string());
317 }
318 }
319
320 None
321}
322
323fn strip_matching_wrappers(text: &str) -> Option<&str> {
324 const MARKERS: [char; 4] = ['*', '_', '~', '`'];
325
326 for marker in MARKERS {
327 let prefix = count_leading_chars(text, marker);
328 let suffix = count_trailing_chars(text, marker);
329
330 if prefix > 0 && suffix > 0 {
331 let matched = prefix.min(suffix);
332 if matched > 0 {
333 let start = text.char_indices().nth(matched).map(|(idx, _)| idx).unwrap_or(text.len());
334 let end = text.len() - text.chars().rev().take(matched).map(|c| c.len_utf8()).sum::<usize>();
335 if start < end {
336 return Some(&text[start..end]);
337 }
338 }
339 }
340 }
341
342 None
343}
344
345fn strip_unpaired_edge_markers(text: &str, leading: bool) -> Option<String> {
346 const MARKERS: [char; 4] = ['*', '_', '~', '`'];
347
348 if leading {
349 let count = count_leading_chars_set(text, &MARKERS);
350 if count > 0 {
351 let after = &text[text.char_indices().nth(count).map(|(idx, _)| idx).unwrap_or(text.len())..];
352 if after.is_empty()
353 || after.chars().next().map_or(false, |c| {
354 c.is_whitespace() || c == '#' || c == '>' || c == '<'
355 })
356 {
357 return Some(after.trim_start().to_string());
358 }
359 }
360 } else {
361 let count = count_trailing_chars_set(text, &MARKERS);
362 if count > 0 {
363 let before_end = text.char_indices().rev().nth(count - 1).map(|(idx, _ch)| idx).unwrap_or(0);
364 let before = &text[..before_end];
365 if before.is_empty()
366 || before.chars().rev().next().map_or(false, |c| {
367 c.is_whitespace() || c == '#' || c == '>' || c == '<'
368 })
369 {
370 return Some(before.trim_end().to_string());
371 }
372 }
373 }
374
375 None
376}
377
378fn count_leading_chars(text: &str, marker: char) -> usize {
379 text.chars().take_while(|&c| c == marker).count()
380}
381
382fn count_trailing_chars(text: &str, marker: char) -> usize {
383 text.chars().rev().take_while(|&c| c == marker).count()
384}
385
386fn count_leading_chars_set(text: &str, markers: &[char]) -> usize {
387 text.chars().take_while(|c| markers.contains(c)).count()
388}
389
390fn count_trailing_chars_set(text: &str, markers: &[char]) -> usize {
391 text.chars().rev().take_while(|c| markers.contains(c)).count()
392}
393
394fn strip_heading_prefix(text: &str) -> Option<&str> {
395 for level in (1..=6).rev() {
396 let prefix = "#".repeat(level);
397 if let Some(stripped) = text.strip_prefix(&prefix) {
398 if stripped.starts_with(char::is_whitespace) {
399 return Some(stripped);
400 }
401 }
402 }
403
404 None
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn trim_bom_and_zero_width_removes_invisible_chars() {
413 let input = "\u{feff}Hello\u{200B} world\u{200C}!";
414 assert_eq!(trim_bom_and_zero_width(input), "Hello world!");
415 }
416
417 #[test]
418 fn trim_consecutive_whitespaces_collapses_space_tab_newline() {
419 let input = " foo bar\t\tbaz\n\n\nqux ";
420 assert_eq!(trim_consecutive_whitespaces(input), "foo bar baz\n\nqux");
421 }
422
423 #[test]
424 fn collapse_whitespace_to_single_space_works() {
425 let input = "foo\n\nbar\t\t baz";
426 assert_eq!(collapse_whitespace_to_single_space(input), "foo bar baz");
427 }
428
429 #[test]
430 fn trim_html_markdown_artifacts_removes_edge_tokens() {
431 let input = " **# Hello *world* <div> ";
432 assert_eq!(trim_html_markdown_artifacts(input), "Hello *world*");
433 }
434
435 #[test]
436 fn trim_html_markdown_artifacts_removes_heading_and_html_wrappers() {
437 let input = "## <div>**Hello**</div>";
438 assert_eq!(trim_html_markdown_artifacts(input), "Hello");
439 }
440
441 #[test]
442 fn clean_with_builder_can_collapse_all_whitespace() {
443 let input = "foo\n\nbar\t baz";
444 let output = CleanBuilder::new().collapse_all_whitespace(true).clean(input);
445 assert_eq!(output, "foo bar baz");
446 }
447
448 #[test]
449 fn builder_api_allows_full_configuration() {
450 let input = "\u{feff} **# Hello \n\n\n world! **\u{200B} ";
451 let options = CleanOptions::builder()
452 .strip_invisible_chars(true)
453 .strip_markdown_artifacts(true)
454 .preserve_paragraphs(false)
455 .collapse_all_whitespace(true)
456 .build();
457
458 assert_eq!(clean_with_options(input, options), "Hello world!");
459 }
460
461 #[test]
462 fn clean_applies_all_steps() {
463 let input = "\u{feff}**\n Hello \n\n\n world! **\u{200B} ";
464 assert_eq!(clean(input), "Hello\n\nworld!");
465 }
466}