1use super::{CleanupProviderKind, CleanupResult, CleanupStyle, TextCleanup};
9use crate::error::Result;
10use async_trait::async_trait;
11use regex::Regex;
12use std::sync::LazyLock;
13
14#[derive(Debug, Default, Clone)]
16pub struct RulesCleanup {
17 pub language: Option<String>,
19}
20
21impl RulesCleanup {
22 pub fn new() -> Self {
23 Self { language: None }
24 }
25
26 pub fn with_language(mut self, language: impl Into<String>) -> Self {
27 self.language = Some(language.into());
28 self
29 }
30
31 fn is_english(&self) -> bool {
32 match self
33 .language
34 .as_deref()
35 .map(|s| s.trim().to_ascii_lowercase())
36 {
37 None => true, Some(l)
39 if l.is_empty()
40 || l == "auto"
41 || l == "en"
42 || l.starts_with("en-")
43 || l == "eng" =>
44 {
45 true
46 }
47 _ => false,
48 }
49 }
50}
51
52#[async_trait]
53impl TextCleanup for RulesCleanup {
54 fn name(&self) -> &'static str {
55 "rules"
56 }
57
58 fn kind(&self) -> CleanupProviderKind {
59 CleanupProviderKind::Rules
60 }
61
62 async fn cleanup(&self, text: &str, style: CleanupStyle) -> Result<CleanupResult> {
63 let original = text.to_string();
64 let english = self.is_english();
65 let body = if text.trim().is_empty() {
66 String::new()
67 } else {
68 match style {
69 CleanupStyle::Raw => text.trim().to_string(),
70 CleanupStyle::Clean => {
71 if english {
72 clean_english(text)
73 } else {
74 whitespace_safe(text)
75 }
76 }
77 CleanupStyle::Bullets => {
78 let base = if english {
79 clean_english(text)
80 } else {
81 whitespace_safe(text)
82 };
83 to_bullets(&base)
84 }
85 CleanupStyle::Professional => {
86 if english {
87 professional_english(&clean_english(text))
88 } else {
89 whitespace_safe(text)
90 }
91 }
92 CleanupStyle::Summary => {
93 let base = if english {
94 clean_english(text)
95 } else {
96 whitespace_safe(text)
97 };
98 summarize(&base)
99 }
100 }
101 };
102 Ok(CleanupResult {
103 text: body,
104 style,
105 provider: CleanupProviderKind::Rules,
106 original_text: original,
107 })
108 }
109}
110
111static RE_WS2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[ \t]{2,}").expect("regex"));
114static RE_SPACE_PUNCT: LazyLock<Regex> =
115 LazyLock::new(|| Regex::new(r"[ \t]+([,.!?;:])").expect("regex"));
116static RE_SENT_SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[.!?]+\s+").expect("regex"));
117
118static RE_FILLER_MULTI: LazyLock<Vec<Regex>> = LazyLock::new(|| {
120 [
121 "you know", "i mean", "sort of", "kind of", "okay so", "so yeah",
122 ]
123 .into_iter()
124 .map(|f| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(f))).expect("filler"))
125 .collect()
126});
127static RE_FILLER_SINGLE: LazyLock<Vec<Regex>> = LazyLock::new(|| {
128 ["um", "uh", "uhm", "erm", "ah", "eh"]
129 .into_iter()
130 .map(|f| Regex::new(&format!(r"(?i)\b{}\b[,.]?", regex::escape(f))).expect("filler"))
131 .collect()
132});
133
134static RE_PRO_SAFE: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
136 [
137 (r"(?i)\bcan't\b", "cannot"),
138 (r"(?i)\bwon't\b", "will not"),
139 (r"(?i)\bdon't\b", "do not"),
140 (r"(?i)\bdidn't\b", "did not"),
141 (r"(?i)\bisn't\b", "is not"),
142 (r"(?i)\baren't\b", "are not"),
143 (r"(?i)\bwasn't\b", "was not"),
144 (r"(?i)\bweren't\b", "were not"),
145 (r"(?i)\bhaven't\b", "have not"),
146 (r"(?i)\bhasn't\b", "has not"),
147 (r"(?i)\bhadn't\b", "had not"),
148 (r"(?i)\bI'm\b", "I am"),
149 (r"(?i)\bI've\b", "I have"),
150 (r"(?i)\bI'll\b", "I will"),
151 (r"(?i)\bwe're\b", "we are"),
153 (r"(?i)\bwe've\b", "we have"),
154 (r"(?i)\bwe'll\b", "we will"),
155 (r"(?i)\bthey're\b", "they are"),
156 (r"(?i)\bthey've\b", "they have"),
157 (r"(?i)\bgonna\b", "going to"),
158 (r"(?i)\bwanna\b", "want to"),
159 (r"(?i)\bkinda\b", "kind of"),
160 (r"(?i)\byeah\b", "yes"),
161 (r"(?i)\byep\b", "yes"),
162 (r"(?i)\bnope\b", "no"),
163 (r"(?i)\basap\b", "as soon as possible"),
164 (r"(?i)\bfyi\b", "for your information"),
165 ]
166 .into_iter()
167 .map(|(p, r)| (Regex::new(p).expect("pro"), r))
168 .collect()
169});
170
171fn whitespace_safe(text: &str) -> String {
173 let paragraphs: Vec<String> = text
174 .split("\n\n")
175 .map(|p| {
176 let line = p
177 .lines()
178 .map(|l| l.trim())
179 .filter(|l| !l.is_empty())
180 .collect::<Vec<_>>()
181 .join(" ");
182 RE_WS2.replace_all(&line, " ").into_owned()
183 })
184 .filter(|p| !p.trim().is_empty())
185 .collect();
186 paragraphs.join("\n\n")
187}
188
189fn clean_english(text: &str) -> String {
190 let paragraphs: Vec<String> = text
192 .split("\n\n")
193 .map(clean_english_paragraph)
194 .filter(|p| !p.is_empty())
195 .collect();
196 paragraphs.join("\n\n")
197}
198
199fn clean_english_paragraph(text: &str) -> String {
200 let mut result = text
202 .lines()
203 .map(|l| l.trim())
204 .filter(|l| !l.is_empty())
205 .collect::<Vec<_>>()
206 .join(" ");
207 result = RE_WS2.replace_all(&result, " ").into_owned();
208
209 for re in RE_FILLER_MULTI.iter() {
210 result = re.replace_all(&result, "").into_owned();
211 }
212 for re in RE_FILLER_SINGLE.iter() {
213 result = re.replace_all(&result, "").into_owned();
214 }
215
216 result = RE_WS2.replace_all(&result, " ").into_owned();
217 result = RE_SPACE_PUNCT.replace_all(&result, "$1").into_owned();
218 result = capitalize_sentences(result.trim());
219
220 if result.chars().count() > 12 {
221 if let Some(last) = result.chars().last() {
222 if !".!?".contains(last) {
223 result.push('.');
224 }
225 }
226 }
227 result
228}
229
230fn professional_english(text: &str) -> String {
231 let paragraphs: Vec<String> = text
232 .split("\n\n")
233 .map(|p| {
234 let mut result = p.to_string();
235 for (re, rep) in RE_PRO_SAFE.iter() {
236 result = re.replace_all(&result, *rep).into_owned();
237 }
238 result = RE_WS2.replace_all(&result, " ").into_owned();
239 capitalize_sentences(result.trim())
240 })
241 .filter(|p| !p.is_empty())
242 .collect();
243 paragraphs.join("\n\n")
244}
245
246fn to_bullets(text: &str) -> String {
247 let mut parts = split_sentences(text);
248 if parts.len() == 1 {
249 let commas: Vec<_> = text
250 .split(',')
251 .map(|s| s.trim())
252 .filter(|s| !s.is_empty())
253 .map(|s| s.to_string())
254 .collect();
255 if commas.len() >= 3 {
256 parts = commas;
257 }
258 }
259 if parts.len() <= 1 {
260 return format!("• {text}");
261 }
262 parts
263 .into_iter()
264 .map(|p| p.trim_matches(|c: char| ".!?;: ".contains(c)).to_string())
265 .filter(|p| !p.is_empty())
266 .map(|p| format!("• {}", capitalize_first(&p)))
267 .collect::<Vec<_>>()
268 .join("\n")
269}
270
271fn summarize(text: &str) -> String {
272 let sentences = split_sentences(text);
273 if sentences.len() <= 2 {
274 return text.to_string();
275 }
276 let first = sentences[0].clone();
277 let rest = &sentences[1..];
278 let longest = rest
279 .iter()
280 .max_by_key(|s| s.chars().count())
281 .cloned()
282 .unwrap_or_default();
283 if longest.is_empty() || longest == first {
284 first
285 } else {
286 format!("{first} {longest}")
287 }
288}
289
290fn split_sentences(text: &str) -> Vec<String> {
291 let mut out: Vec<String> = RE_SENT_SPLIT
292 .split(text)
293 .map(|s| s.trim().to_string())
294 .filter(|s| !s.is_empty())
295 .collect();
296 if out.is_empty() {
297 out.push(text.to_string());
298 }
299 out
300}
301
302fn capitalize_sentences(text: &str) -> String {
303 let mut result = String::with_capacity(text.len());
304 let mut capitalize_next = true;
305 for ch in text.chars() {
306 if capitalize_next && ch.is_alphabetic() {
307 for c in ch.to_uppercase() {
308 result.push(c);
309 }
310 capitalize_next = false;
311 } else {
312 result.push(ch);
313 if ".!?".contains(ch) {
314 capitalize_next = true;
315 }
316 }
317 }
318 result
319}
320
321fn capitalize_first(text: &str) -> String {
322 let mut chars = text.chars();
323 match chars.next() {
324 None => String::new(),
325 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[tokio::test]
334 async fn english_clean_strips_um() {
335 let r = RulesCleanup::new()
336 .with_language("en")
337 .cleanup("um, hello there", CleanupStyle::Clean)
338 .await
339 .unwrap();
340 assert!(r.text.to_ascii_lowercase().contains("hello"));
341 assert!(!r.text.to_ascii_lowercase().contains("um"));
342 }
343
344 #[tokio::test]
345 async fn french_does_not_strip_english_fillers_as_english() {
346 let r = RulesCleanup::new()
349 .with_language("fr")
350 .cleanup("Bonjour, um, le monde", CleanupStyle::Clean)
351 .await
352 .unwrap();
353 assert!(r.text.contains("Bonjour") || r.text.to_ascii_lowercase().contains("bonjour"));
355 assert!(r.text.contains("monde"));
356 }
357
358 #[tokio::test]
359 async fn professional_preserves_id_contraction() {
360 let r = RulesCleanup::new()
361 .with_language("en")
362 .cleanup("I'd like that", CleanupStyle::Professional)
363 .await
364 .unwrap();
365 assert!(
367 r.text.contains("I'd") || r.text.contains("I d"),
368 "got: {}",
369 r.text
370 );
371 assert!(!r.text.contains("I would"));
372 }
373
374 #[tokio::test]
375 async fn paragraphs_preserved_in_clean() {
376 let input = "First paragraph um here.\n\nSecond paragraph uh there.";
377 let r = RulesCleanup::new()
378 .with_language("en")
379 .cleanup(input, CleanupStyle::Clean)
380 .await
381 .unwrap();
382 assert!(
383 r.text.contains("\n\n"),
384 "paragraph break lost: {:?}",
385 r.text
386 );
387 }
388}