Skip to main content

karbon_framework/util/
string.rs

1/// String manipulation helpers
2pub struct StringHelper;
3
4impl StringHelper {
5    /// Truncate a string to `max_len` characters, appending a suffix (default "...")
6    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    /// Extract an excerpt around a keyword with surrounding context
19    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    /// Capitalize the first letter of a string
52    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    /// Title case: capitalize the first letter of each word
64    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    /// Convert to camelCase
73    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    /// Convert to snake_case
90    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    /// Mask a string, showing only the first and last `visible` characters
110    /// e.g., mask("secret@email.com", 3) => "sec**********com"
111    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    /// Mask an email address: "us***@***.com"
127    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    /// Get the initials from a name
153    /// e.g., "Jean-Pierre Dupont" => "JD"
154    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    /// Count words in a string
164    pub fn word_count(value: &str) -> usize {
165        value.split_whitespace().count()
166    }
167
168    /// Check if a string contains only ASCII characters
169    pub fn is_ascii(value: &str) -> bool {
170        value.is_ascii()
171    }
172
173    /// Remove all HTML tags from a string
174    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    /// Pad a string to a given length from the left
191    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    /// Pad a string to a given length from the right
201    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}