1#[cfg(feature = "text-processing")]
7use unicode_general_category::{GeneralCategory, get_general_category};
8#[cfg(feature = "text-processing")]
9use unicode_normalization::UnicodeNormalization;
10
11#[cfg(feature = "text-processing")]
13const NEWLINES: &[char] = &[
14 '\u{000A}', '\u{000B}', '\u{000C}', '\u{000D}', '\u{0085}', '\u{2028}', '\u{2029}', ];
22
23#[cfg(feature = "text-processing")]
25fn is_c_category(c: char) -> bool {
26 matches!(
27 get_general_category(c),
28 GeneralCategory::Control
29 | GeneralCategory::Format
30 | GeneralCategory::Unassigned
31 | GeneralCategory::PrivateUse
32 | GeneralCategory::Surrogate
33 )
34}
35
36#[cfg(feature = "text-processing")]
38fn is_cmp_category(c: char) -> bool {
39 matches!(
40 get_general_category(c),
41 GeneralCategory::Control
43 | GeneralCategory::Format
44 | GeneralCategory::Unassigned
45 | GeneralCategory::PrivateUse
46 | GeneralCategory::Surrogate
47 | GeneralCategory::NonspacingMark
49 | GeneralCategory::SpacingMark
50 | GeneralCategory::EnclosingMark
51 | GeneralCategory::ConnectorPunctuation
53 | GeneralCategory::DashPunctuation
54 | GeneralCategory::OpenPunctuation
55 | GeneralCategory::ClosePunctuation
56 | GeneralCategory::InitialPunctuation
57 | GeneralCategory::FinalPunctuation
58 | GeneralCategory::OtherPunctuation
59 )
60}
61
62#[cfg(feature = "text-processing")]
69pub fn text_clean(text: &str) -> String {
70 let text: String = text.nfkc().collect();
72
73 let mut cleaned = String::with_capacity(text.len());
75 let mut chars = text.chars().peekable();
76 while let Some(c) = chars.next() {
77 if NEWLINES.contains(&c) {
78 if c == '\r' && chars.peek() == Some(&'\n') {
80 chars.next();
81 }
82 cleaned.push('\n');
83 } else if is_c_category(c) {
84 } else {
86 cleaned.push(c);
87 }
88 }
89
90 let mut result_lines: Vec<&str> = Vec::new();
92 let mut prev_empty = false;
93 for line in cleaned.split('\n') {
94 let is_empty = line.trim().is_empty();
95 if is_empty {
96 if prev_empty {
97 continue;
98 }
99 prev_empty = true;
100 } else {
101 prev_empty = false;
102 }
103 result_lines.push(line);
104 }
105
106 result_lines.join("\n").trim().to_string()
108}
109
110pub fn text_remove_newlines(text: &str) -> String {
115 text.split_whitespace().collect::<Vec<_>>().join(" ")
116}
117
118pub fn text_trim(text: &str, nbytes: usize) -> String {
124 if text.len() <= nbytes {
125 return text.trim().to_string();
126 }
127 let bytes = &text.as_bytes()[..nbytes];
128 let s = match std::str::from_utf8(bytes) {
129 Ok(s) => s,
130 Err(e) => &text[..e.valid_up_to()],
131 };
132 s.trim().to_string()
133}
134
135#[cfg(feature = "text-processing")]
142pub fn text_collapse(text: &str) -> String {
143 let nfd_lower = text.nfd().collect::<String>().to_lowercase();
145
146 let filtered: String = nfd_lower
148 .chars()
149 .filter(|&c| !c.is_whitespace() && !is_cmp_category(c))
150 .collect();
151
152 filtered.nfkc().collect()
154}
155
156pub(crate) fn multi_hash_blake3(data: &[u8]) -> String {
161 let digest = blake3::hash(data);
162 let mut result = Vec::with_capacity(34);
163 result.push(0x1e); result.push(0x20); result.extend_from_slice(digest.as_bytes());
166 hex::encode(result)
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[cfg(feature = "text-processing")]
176 #[test]
177 fn test_text_clean_nfkc_normalization() {
178 assert!(text_clean("ℍ").contains('H'));
180 }
181
182 #[cfg(feature = "text-processing")]
183 #[test]
184 fn test_text_clean_removes_control_chars() {
185 assert_eq!(text_clean("hello\tworld"), "helloworld");
186 }
187
188 #[cfg(feature = "text-processing")]
189 #[test]
190 fn test_text_clean_preserves_newlines() {
191 assert_eq!(text_clean("hello\nworld"), "hello\nworld");
192 }
193
194 #[cfg(feature = "text-processing")]
195 #[test]
196 fn test_text_clean_collapses_empty_lines() {
197 assert_eq!(text_clean("a\n\n\nb"), "a\n\nb");
198 }
199
200 #[cfg(feature = "text-processing")]
201 #[test]
202 fn test_text_clean_strips_whitespace() {
203 assert_eq!(text_clean(" hello "), "hello");
204 }
205
206 #[cfg(feature = "text-processing")]
207 #[test]
208 fn test_text_clean_handles_crlf() {
209 assert_eq!(text_clean("a\r\nb"), "a\nb");
210 }
211
212 #[cfg(feature = "text-processing")]
213 #[test]
214 fn test_text_clean_empty() {
215 assert_eq!(text_clean(""), "");
216 }
217
218 #[test]
221 fn test_text_remove_newlines() {
222 assert_eq!(text_remove_newlines("hello\nworld"), "hello world");
223 }
224
225 #[test]
226 fn test_text_remove_newlines_collapses_spaces() {
227 assert_eq!(text_remove_newlines("a b c"), "a b c");
228 }
229
230 #[test]
233 fn test_text_trim_no_truncation() {
234 assert_eq!(text_trim("hello", 10), "hello");
235 }
236
237 #[test]
238 fn test_text_trim_exact() {
239 assert_eq!(text_trim("hello", 5), "hello");
240 }
241
242 #[test]
243 fn test_text_trim_truncates() {
244 assert_eq!(text_trim("hello world", 5), "hello");
245 }
246
247 #[test]
248 fn test_text_trim_unicode_boundary() {
249 assert_eq!(text_trim("é", 1), "");
251 }
252
253 #[test]
254 fn test_text_trim_strips() {
255 assert_eq!(text_trim("hello ", 6), "hello");
256 }
257
258 #[cfg(feature = "text-processing")]
261 #[test]
262 fn test_text_collapse_basic() {
263 assert_eq!(text_collapse("Hello World"), "helloworld");
264 }
265
266 #[cfg(feature = "text-processing")]
267 #[test]
268 fn test_text_collapse_strips_accents() {
269 assert_eq!(text_collapse("café"), "cafe");
271 }
272
273 #[cfg(feature = "text-processing")]
274 #[test]
275 fn test_text_collapse_strips_punctuation() {
276 assert_eq!(text_collapse("hello, world!"), "helloworld");
277 }
278
279 #[cfg(feature = "text-processing")]
280 #[test]
281 fn test_text_collapse_empty() {
282 assert_eq!(text_collapse(""), "");
283 }
284
285 #[test]
288 fn test_multi_hash_blake3_empty() {
289 assert_eq!(
290 multi_hash_blake3(b""),
291 "1e20af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
292 );
293 }
294
295 #[test]
296 fn test_multi_hash_blake3_hello_world() {
297 assert_eq!(
298 multi_hash_blake3(b"hello world"),
299 "1e20d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24"
300 );
301 }
302}