1use super::{CleanupProviderKind, CleanupResult, CleanupStyle, TextCleanup};
6use crate::error::Result;
7use async_trait::async_trait;
8use regex::Regex;
9use std::sync::LazyLock;
10
11#[derive(Debug, Default, Clone)]
13pub struct RulesCleanup;
14
15impl RulesCleanup {
16 pub fn new() -> Self {
17 Self
18 }
19}
20
21#[async_trait]
22impl TextCleanup for RulesCleanup {
23 fn name(&self) -> &'static str {
24 "rules"
25 }
26
27 fn kind(&self) -> CleanupProviderKind {
28 CleanupProviderKind::Rules
29 }
30
31 async fn cleanup(&self, text: &str, style: CleanupStyle) -> Result<CleanupResult> {
32 let original = text.to_string();
33 let trimmed = text.trim();
34 let body = if trimmed.is_empty() {
35 String::new()
36 } else {
37 match style {
38 CleanupStyle::Raw => trimmed.to_string(),
39 CleanupStyle::Clean => clean(trimmed),
40 CleanupStyle::Bullets => to_bullets(&clean(trimmed)),
41 CleanupStyle::Professional => professional(&clean(trimmed)),
42 CleanupStyle::Summary => summarize(&clean(trimmed)),
43 }
44 };
45 Ok(CleanupResult {
46 text: body,
47 style,
48 provider: CleanupProviderKind::Rules,
49 original_text: original,
50 })
51 }
52}
53
54static RE_WS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").expect("regex"));
55static RE_WS2: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s{2,}").expect("regex"));
56static RE_SPACE_PUNCT: LazyLock<Regex> =
57 LazyLock::new(|| Regex::new(r"\s+([,.!?;:])").expect("regex"));
58static RE_SENT_SPLIT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[.!?]+\s+").expect("regex"));
59
60fn clean(text: &str) -> String {
61 let mut result = RE_WS.replace_all(text, " ").into_owned();
62
63 let multi = [
64 "you know", "i mean", "sort of", "kind of", "okay so", "so yeah",
65 ];
66 for filler in multi {
67 let pat = format!(r"(?i)\b{}\b[,.]?", regex::escape(filler));
68 result = Regex::new(&pat)
69 .expect("filler regex")
70 .replace_all(&result, "")
71 .into_owned();
72 }
73
74 let single = ["um", "uh", "uhm", "erm", "ah", "eh"];
75 for filler in single {
76 let pat = format!(r"(?i)\b{}\b[,.]?", regex::escape(filler));
77 result = Regex::new(&pat)
78 .expect("filler regex")
79 .replace_all(&result, "")
80 .into_owned();
81 }
82
83 result = RE_WS2.replace_all(&result, " ").into_owned();
84 result = RE_SPACE_PUNCT.replace_all(&result, "$1").into_owned();
85 result = capitalize_sentences(result.trim());
86
87 if result.chars().count() > 12 {
88 if let Some(last) = result.chars().last() {
89 if !".!?".contains(last) {
90 result.push('.');
91 }
92 }
93 }
94 result
95}
96
97fn to_bullets(text: &str) -> String {
98 let mut parts = split_sentences(text);
99 if parts.len() == 1 {
100 let commas: Vec<_> = text
101 .split(',')
102 .map(|s| s.trim())
103 .filter(|s| !s.is_empty())
104 .map(|s| s.to_string())
105 .collect();
106 if commas.len() >= 3 {
107 parts = commas;
108 }
109 }
110 if parts.len() <= 1 {
111 return format!("• {text}");
112 }
113 parts
114 .into_iter()
115 .map(|p| p.trim_matches(|c: char| ".!?;: ".contains(c)).to_string())
116 .filter(|p| !p.is_empty())
117 .map(|p| format!("• {}", capitalize_first(&p)))
118 .collect::<Vec<_>>()
119 .join("\n")
120}
121
122fn professional(text: &str) -> String {
123 let replacements: &[(&str, &str)] = &[
124 (r"(?i)\bcan't\b", "cannot"),
125 (r"(?i)\bwon't\b", "will not"),
126 (r"(?i)\bdon't\b", "do not"),
127 (r"(?i)\bdidn't\b", "did not"),
128 (r"(?i)\bisn't\b", "is not"),
129 (r"(?i)\baren't\b", "are not"),
130 (r"(?i)\bwasn't\b", "was not"),
131 (r"(?i)\bweren't\b", "were not"),
132 (r"(?i)\bhaven't\b", "have not"),
133 (r"(?i)\bhasn't\b", "has not"),
134 (r"(?i)\bhadn't\b", "had not"),
135 (r"(?i)\bI'm\b", "I am"),
136 (r"(?i)\bI've\b", "I have"),
137 (r"(?i)\bI'll\b", "I will"),
138 (r"(?i)\bI'd\b", "I would"),
139 (r"(?i)\bwe're\b", "we are"),
140 (r"(?i)\bwe've\b", "we have"),
141 (r"(?i)\bwe'll\b", "we will"),
142 (r"(?i)\bthey're\b", "they are"),
143 (r"(?i)\bthey've\b", "they have"),
144 (r"(?i)\bit's\b", "it is"),
145 (r"(?i)\bthat's\b", "that is"),
146 (r"(?i)\bthere's\b", "there is"),
147 (r"(?i)\bgonna\b", "going to"),
148 (r"(?i)\bwanna\b", "want to"),
149 (r"(?i)\bkinda\b", "kind of"),
150 (r"(?i)\byeah\b", "yes"),
151 (r"(?i)\byep\b", "yes"),
152 (r"(?i)\bnope\b", "no"),
153 (r"(?i)\bokay\b", "all right"),
154 (r"(?i)\bok\b", "all right"),
155 (r"(?i)\bhey\b", "hello"),
156 (r"(?i)\basap\b", "as soon as possible"),
157 (r"(?i)\bfyi\b", "for your information"),
158 ];
159
160 let mut result = text.to_string();
161 for (pat, rep) in replacements {
162 result = Regex::new(pat)
163 .expect("pro regex")
164 .replace_all(&result, *rep)
165 .into_owned();
166 }
167 result = RE_WS2.replace_all(&result, " ").into_owned();
168 capitalize_sentences(result.trim())
169}
170
171fn summarize(text: &str) -> String {
172 let sentences = split_sentences(text);
173 if sentences.len() <= 2 {
174 return text.to_string();
175 }
176 let first = sentences[0].clone();
177 let rest = &sentences[1..];
178 let longest = rest
179 .iter()
180 .max_by_key(|s| s.chars().count())
181 .cloned()
182 .unwrap_or_default();
183 if longest.is_empty() || longest == first {
184 first
185 } else {
186 format!("{first} {longest}")
187 }
188}
189
190fn split_sentences(text: &str) -> Vec<String> {
191 let mut out: Vec<String> = RE_SENT_SPLIT
193 .split(text)
194 .map(|s| s.trim().to_string())
195 .filter(|s| !s.is_empty())
196 .collect();
197 if out.is_empty() {
198 out.push(text.to_string());
199 }
200 out
202}
203
204fn capitalize_sentences(text: &str) -> String {
205 let mut result = String::with_capacity(text.len());
206 let mut capitalize_next = true;
207 for ch in text.chars() {
208 if capitalize_next && ch.is_alphabetic() {
209 for c in ch.to_uppercase() {
210 result.push(c);
211 }
212 capitalize_next = false;
213 } else {
214 result.push(ch);
215 if ".!?".contains(ch) {
216 capitalize_next = true;
217 }
218 }
219 }
220 result
221}
222
223fn capitalize_first(text: &str) -> String {
224 let mut chars = text.chars();
225 match chars.next() {
226 None => String::new(),
227 Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
228 }
229}