1pub struct StringHelper;
3
4impl StringHelper {
5 pub fn truncate(value: &str, max_len: usize, suffix: Option<&str>) -> String {
7 let suffix = suffix.unwrap_or("...");
8 let chars: Vec<char> = value.chars().collect();
9
10 if chars.len() <= max_len {
11 return value.to_string();
12 }
13
14 let truncated: String = chars[..max_len].iter().collect();
15 format!("{}{}", truncated.trim_end(), suffix)
16 }
17
18 pub fn excerpt(value: &str, keyword: &str, radius: usize, suffix: Option<&str>) -> String {
20 let suffix = suffix.unwrap_or("...");
21 let lower = value.to_lowercase();
22 let keyword_lower = keyword.to_lowercase();
23
24 let pos = match lower.find(&keyword_lower) {
25 Some(p) => p,
26 None => return Self::truncate(value, radius * 2, Some(suffix)),
27 };
28
29 let chars: Vec<char> = value.chars().collect();
30 let char_pos = value[..pos].chars().count();
31
32 let start = char_pos.saturating_sub(radius);
33 let end = (char_pos + keyword.chars().count() + radius).min(chars.len());
34
35 let mut result = String::new();
36
37 if start > 0 {
38 result.push_str(suffix);
39 }
40
41 let slice: String = chars[start..end].iter().collect();
42 result.push_str(slice.trim());
43
44 if end < chars.len() {
45 result.push_str(suffix);
46 }
47
48 result
49 }
50
51 pub fn capitalize(value: &str) -> String {
53 let mut chars = value.chars();
54 match chars.next() {
55 None => String::new(),
56 Some(c) => {
57 let upper: String = c.to_uppercase().collect();
58 format!("{}{}", upper, chars.as_str())
59 }
60 }
61 }
62
63 pub fn title_case(value: &str) -> String {
65 value
66 .split_whitespace()
67 .map(|word| Self::capitalize(word))
68 .collect::<Vec<_>>()
69 .join(" ")
70 }
71
72 pub fn camel_case(value: &str) -> String {
74 let parts: Vec<&str> = value.split(|c: char| c == '_' || c == '-' || c == ' ').collect();
75 let mut result = String::new();
76 for (i, part) in parts.iter().enumerate() {
77 if part.is_empty() {
78 continue;
79 }
80 if i == 0 {
81 result.push_str(&part.to_lowercase());
82 } else {
83 result.push_str(&Self::capitalize(&part.to_lowercase()));
84 }
85 }
86 result
87 }
88
89 pub fn snake_case(value: &str) -> String {
91 let mut result = String::new();
92 for (i, c) in value.chars().enumerate() {
93 if c.is_uppercase() {
94 if i > 0 {
95 result.push('_');
96 }
97 for lc in c.to_lowercase() {
98 result.push(lc);
99 }
100 } else if c == '-' || c == ' ' {
101 result.push('_');
102 } else {
103 result.push(c);
104 }
105 }
106 result
107 }
108
109 pub fn mask(value: &str, visible: usize) -> String {
112 let chars: Vec<char> = value.chars().collect();
113 let len = chars.len();
114
115 if len <= visible * 2 {
116 return "*".repeat(len);
117 }
118
119 let start: String = chars[..visible].iter().collect();
120 let end: String = chars[len - visible..].iter().collect();
121 let middle = "*".repeat(len - visible * 2);
122
123 format!("{}{}{}", start, middle, end)
124 }
125
126 pub fn mask_email(value: &str) -> String {
128 let parts: Vec<&str> = value.splitn(2, '@').collect();
129 if parts.len() != 2 {
130 return Self::mask(value, 2);
131 }
132
133 let local = parts[0];
134 let domain = parts[1];
135
136 let masked_local = if local.len() <= 2 {
137 format!("{}***", &local[..1.min(local.len())])
138 } else {
139 format!("{}***", &local[..2])
140 };
141
142 let domain_parts: Vec<&str> = domain.rsplitn(2, '.').collect();
143 let masked_domain = if domain_parts.len() == 2 {
144 format!("***.{}", domain_parts[0])
145 } else {
146 "***".to_string()
147 };
148
149 format!("{}@{}", masked_local, masked_domain)
150 }
151
152 pub fn initials(value: &str) -> String {
155 value
156 .split(|c: char| c.is_whitespace() || c == '-')
157 .filter(|w| !w.is_empty())
158 .filter_map(|w| w.chars().next())
159 .map(|c| c.to_uppercase().to_string())
160 .collect()
161 }
162
163 pub fn word_count(value: &str) -> usize {
165 value.split_whitespace().count()
166 }
167
168 pub fn is_ascii(value: &str) -> bool {
170 value.is_ascii()
171 }
172
173 pub fn strip_tags(value: &str) -> String {
175 let mut result = String::with_capacity(value.len());
176 let mut in_tag = false;
177
178 for c in value.chars() {
179 match c {
180 '<' => in_tag = true,
181 '>' => in_tag = false,
182 _ if !in_tag => result.push(c),
183 _ => {}
184 }
185 }
186
187 result
188 }
189
190 pub fn pad_left(value: &str, length: usize, pad_char: char) -> String {
192 let current_len = value.chars().count();
193 if current_len >= length {
194 return value.to_string();
195 }
196 let padding: String = std::iter::repeat(pad_char).take(length - current_len).collect();
197 format!("{}{}", padding, value)
198 }
199
200 pub fn pad_right(value: &str, length: usize, pad_char: char) -> String {
202 let current_len = value.chars().count();
203 if current_len >= length {
204 return value.to_string();
205 }
206 let padding: String = std::iter::repeat(pad_char).take(length - current_len).collect();
207 format!("{}{}", value, padding)
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
216 fn test_truncate() {
217 assert_eq!(StringHelper::truncate("Hello World", 5, None), "Hello...");
218 assert_eq!(StringHelper::truncate("Hi", 5, None), "Hi");
219 assert_eq!(StringHelper::truncate("Hello World", 5, Some("…")), "Hello…");
220 }
221
222 #[test]
223 fn test_truncate_unicode() {
224 assert_eq!(StringHelper::truncate("Café résumé", 4, None), "Café...");
225 }
226
227 #[test]
228 fn test_excerpt() {
229 let text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit";
230 let result = StringHelper::excerpt(text, "dolor", 10, None);
231 assert!(result.contains("dolor"));
232 assert!(result.starts_with("..."));
233 assert!(result.ends_with("..."));
234 }
235
236 #[test]
237 fn test_excerpt_keyword_not_found() {
238 let text = "Hello World";
239 let result = StringHelper::excerpt(text, "xyz", 5, None);
240 assert_eq!(result, "Hello Worl...");
241 }
242
243 #[test]
244 fn test_capitalize() {
245 assert_eq!(StringHelper::capitalize("hello"), "Hello");
246 assert_eq!(StringHelper::capitalize("Hello"), "Hello");
247 assert_eq!(StringHelper::capitalize(""), "");
248 assert_eq!(StringHelper::capitalize("é"), "É");
249 }
250
251 #[test]
252 fn test_title_case() {
253 assert_eq!(StringHelper::title_case("hello world"), "Hello World");
254 assert_eq!(StringHelper::title_case("HELLO WORLD"), "HELLO WORLD");
255 }
256
257 #[test]
258 fn test_camel_case() {
259 assert_eq!(StringHelper::camel_case("hello_world"), "helloWorld");
260 assert_eq!(StringHelper::camel_case("hello-world"), "helloWorld");
261 assert_eq!(StringHelper::camel_case("hello world"), "helloWorld");
262 }
263
264 #[test]
265 fn test_snake_case() {
266 assert_eq!(StringHelper::snake_case("helloWorld"), "hello_world");
267 assert_eq!(StringHelper::snake_case("HelloWorld"), "hello_world");
268 assert_eq!(StringHelper::snake_case("hello-world"), "hello_world");
269 }
270
271 #[test]
272 fn test_mask() {
273 assert_eq!(StringHelper::mask("secret", 2), "se**et");
274 assert_eq!(StringHelper::mask("hi", 2), "**");
275 assert_eq!(StringHelper::mask("password123", 3), "pas*****123");
276 }
277
278 #[test]
279 fn test_mask_email() {
280 assert_eq!(StringHelper::mask_email("user@example.com"), "us***@***.com");
281 assert_eq!(StringHelper::mask_email("a@b.io"), "a***@***.io");
282 }
283
284 #[test]
285 fn test_initials() {
286 assert_eq!(StringHelper::initials("Jean Dupont"), "JD");
287 assert_eq!(StringHelper::initials("Jean-Pierre Dupont"), "JPD");
288 assert_eq!(StringHelper::initials("alice"), "A");
289 }
290
291 #[test]
292 fn test_word_count() {
293 assert_eq!(StringHelper::word_count("Hello World"), 2);
294 assert_eq!(StringHelper::word_count(" spaces everywhere "), 2);
295 assert_eq!(StringHelper::word_count(""), 0);
296 }
297
298 #[test]
299 fn test_strip_tags() {
300 assert_eq!(
301 StringHelper::strip_tags("<p>Hello <b>World</b></p>"),
302 "Hello World"
303 );
304 assert_eq!(StringHelper::strip_tags("No tags here"), "No tags here");
305 }
306
307 #[test]
308 fn test_pad_left() {
309 assert_eq!(StringHelper::pad_left("42", 5, '0'), "00042");
310 assert_eq!(StringHelper::pad_left("hello", 3, '0'), "hello");
311 }
312
313 #[test]
314 fn test_pad_right() {
315 assert_eq!(StringHelper::pad_right("42", 5, ' '), "42 ");
316 assert_eq!(StringHelper::pad_right("hello", 3, ' '), "hello");
317 }
318}