1pub fn to_camel_case(s: &str) -> String {
10 let words = split_words(s);
11 words
12 .iter()
13 .enumerate()
14 .map(|(i, w)| {
15 if i == 0 {
16 w.to_lowercase()
17 } else {
18 capitalize(w)
19 }
20 })
21 .collect()
22}
23
24pub fn to_pascal_case(s: &str) -> String {
26 split_words(s).iter().map(|w| capitalize(w)).collect()
27}
28
29pub fn to_snake_case(s: &str) -> String {
31 split_words(s)
32 .iter()
33 .map(|w| w.to_lowercase())
34 .collect::<Vec<_>>()
35 .join("_")
36}
37
38pub fn to_kebab_case(s: &str) -> String {
40 split_words(s)
41 .iter()
42 .map(|w| w.to_lowercase())
43 .collect::<Vec<_>>()
44 .join("-")
45}
46
47pub fn to_screaming_snake(s: &str) -> String {
49 split_words(s)
50 .iter()
51 .map(|w| w.to_uppercase())
52 .collect::<Vec<_>>()
53 .join("_")
54}
55
56pub fn to_dot_case(s: &str) -> String {
58 split_words(s)
59 .iter()
60 .map(|w| w.to_lowercase())
61 .collect::<Vec<_>>()
62 .join(".")
63}
64
65pub fn to_title_case(s: &str) -> String {
67 split_words(s)
68 .iter()
69 .map(|w| capitalize(w))
70 .collect::<Vec<_>>()
71 .join(" ")
72}
73
74pub fn to_headline(s: &str) -> String {
76 to_title_case(s)
77}
78
79pub fn to_sentence_case(s: &str) -> String {
81 let words = split_words(s);
82 if words.is_empty() {
83 return String::new();
84 }
85 let mut result = words[0].to_lowercase();
86 for word in &words[1..] {
87 result.push(' ');
88 result.push_str(&word.to_lowercase());
89 }
90 result
91}
92
93pub fn to_no_case(s: &str) -> String {
95 split_words(s)
96 .iter()
97 .map(|w| w.to_lowercase())
98 .collect::<Vec<_>>()
99 .join(" ")
100}
101
102pub fn to_upper(s: &str) -> String {
104 s.to_uppercase()
105}
106
107pub fn to_lower(s: &str) -> String {
109 s.to_lowercase()
110}
111
112pub fn ucfirst(s: &str) -> String {
114 let mut chars = s.chars();
115 match chars.next() {
116 None => String::new(),
117 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
118 }
119}
120
121pub fn lcfirst(s: &str) -> String {
123 let mut chars = s.chars();
124 match chars.next() {
125 None => String::new(),
126 Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
127 }
128}
129
130pub fn invert_case(s: &str) -> String {
132 s.chars()
133 .map(|c| {
134 if c.is_uppercase() {
135 c.to_lowercase().collect()
136 } else if c.is_lowercase() {
137 c.to_uppercase().collect()
138 } else {
139 c.to_string()
140 }
141 })
142 .collect()
143}
144
145pub fn truncate(s: &str, max: usize) -> String {
157 if s.len() <= max {
158 return s.to_string();
159 }
160 let cut = max.saturating_sub(3);
162 let mut end = cut;
163 while end > 0 && !s.is_char_boundary(end) {
164 end -= 1;
165 }
166 format!("{}...", &s[..end])
167}
168
169pub fn pluralize(word: &str) -> String {
186 if word.is_empty() {
187 return word.to_string();
188 }
189 let lower = word.to_lowercase();
190 if lower.ends_with("sis") {
191 return format!("{}es", &word[..word.len() - 3]);
192 }
193 if lower.ends_with("fe") {
194 return format!("{}ves", &word[..word.len() - 2]);
195 }
196 if lower.ends_with('f') && !lower.ends_with("ff") {
197 return format!("{}ves", &word[..word.len() - 1]);
198 }
199 if lower.ends_with("us") {
200 return format!("{}i", &word[..word.len() - 2]);
201 }
202 if lower.ends_with("ch") || lower.ends_with("sh") {
203 return format!("{}es", word);
204 }
205 if lower.ends_with('s') || lower.ends_with('x') || lower.ends_with('z') {
206 return format!("{}es", word);
207 }
208 if lower.ends_with('y') {
209 let prev = lower.chars().rev().nth(1);
210 if !matches!(prev, Some('a' | 'e' | 'i' | 'o' | 'u')) {
211 return format!("{}ies", &word[..word.len() - 1]);
212 }
213 }
214 format!("{}s", word)
215}
216
217pub fn slug(s: &str, separator: char) -> String {
219 let words = split_words(s);
220 words
221 .iter()
222 .map(|w| w.to_lowercase())
223 .collect::<Vec<_>>()
224 .join(&separator.to_string())
225}
226
227pub fn squish(s: &str) -> String {
229 let mut result = String::new();
230 let mut last_was_space = false;
231 let mut started = false;
232
233 for c in s.chars() {
234 if c.is_whitespace() {
235 if !started {
236 continue;
237 }
238 if !last_was_space {
239 result.push(' ');
240 last_was_space = true;
241 }
242 } else {
243 result.push(c);
244 last_was_space = false;
245 started = true;
246 }
247 }
248 if result.ends_with(' ') {
249 result.pop();
250 }
251 result
252}
253
254pub fn mask(s: &str, mask_char: char, index: usize) -> String {
256 if index >= s.len() {
257 return s.chars().map(|_| mask_char).collect();
258 }
259 let mut result = s[..index].to_string();
260 result.extend(s.chars().skip(index).map(|_| mask_char));
261 result
262}
263
264pub fn wrap(s: &str, before: &str, after: &str) -> String {
266 format!("{}{}{}", before, s, after)
267}
268
269pub fn unwrap(s: &str, before: &str, after: &str) -> String {
271 if s.starts_with(before) && s.ends_with(after) {
272 let start = before.len();
273 let end = s.len() - after.len();
274 s[start..end].to_string()
275 } else {
276 s.to_string()
277 }
278}
279
280pub fn pad_left(s: &str, length: usize, pad: char) -> String {
282 if s.len() >= length {
283 return s.to_string();
284 }
285 let count = length - s.len();
286 let padding: String = pad.to_string().repeat(count);
287 format!("{}{}", padding, s)
288}
289
290pub fn pad_right(s: &str, length: usize, pad: char) -> String {
292 if s.len() >= length {
293 return s.to_string();
294 }
295 let count = length - s.len();
296 let padding: String = pad.to_string().repeat(count);
297 format!("{}{}", s, padding)
298}
299
300pub fn pad_both(s: &str, length: usize, pad: char) -> String {
302 if s.len() >= length {
303 return s.to_string();
304 }
305 let count = length - s.len();
306 let left = count / 2;
307 let right = count - left;
308 let left_pad: String = pad.to_string().repeat(left);
309 let right_pad: String = pad.to_string().repeat(right);
310 format!("{}{}{}", left_pad, s, right_pad)
311}
312
313pub fn repeat(s: &str, times: usize) -> String {
315 s.repeat(times)
316}
317
318pub fn reverse(s: &str) -> String {
320 s.chars().rev().collect()
321}
322
323pub fn replace_first(s: &str, from: &str, to: &str) -> String {
325 if let Some(pos) = s.find(from) {
326 let mut result = s[..pos].to_string();
327 result.push_str(to);
328 result.push_str(&s[pos + from.len()..]);
329 result
330 } else {
331 s.to_string()
332 }
333}
334
335pub fn replace_last(s: &str, from: &str, to: &str) -> String {
337 if let Some(pos) = s.rfind(from) {
338 let mut result = s[..pos].to_string();
339 result.push_str(to);
340 result.push_str(&s[pos + from.len()..]);
341 result
342 } else {
343 s.to_string()
344 }
345}
346
347pub fn finish(s: &str, cap: &str) -> String {
349 if s.ends_with(cap) {
350 s.to_string()
351 } else {
352 format!("{}{}", s, cap)
353 }
354}
355
356pub fn ensure_start(s: &str, prefix: &str) -> String {
358 if s.starts_with(prefix) {
359 s.to_string()
360 } else {
361 format!("{}{}", prefix, s)
362 }
363}
364
365fn split_words(s: &str) -> Vec<String> {
371 let mut words: Vec<String> = Vec::new();
372 let mut current = String::new();
373
374 let chars: Vec<char> = s.chars().collect();
375 for (i, &c) in chars.iter().enumerate() {
376 if c == '_' || c == '-' || c == ' ' {
377 if !current.is_empty() {
378 words.push(current.clone());
379 current.clear();
380 }
381 } else if c.is_uppercase() {
382 let prev_lower = i > 0 && chars[i - 1].is_lowercase();
385 let next_lower = chars
386 .get(i + 1)
387 .map(|ch| ch.is_lowercase())
388 .unwrap_or(false);
389 let acronym_end = i > 0 && chars[i - 1].is_uppercase() && next_lower;
390 if !current.is_empty() && (prev_lower || acronym_end) {
391 words.push(current.clone());
392 current.clear();
393 }
394 current.push(c);
395 } else {
396 current.push(c);
397 }
398 }
399 if !current.is_empty() {
400 words.push(current);
401 }
402 words
403}
404
405fn capitalize(s: &str) -> String {
406 let mut chars = s.chars();
407 match chars.next() {
408 None => String::new(),
409 Some(first) => first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
410 }
411}
412
413pub fn is_empty(s: &str) -> bool {
415 s.trim().is_empty()
416}
417
418pub fn is_ascii(s: &str) -> bool {
420 s.is_ascii()
421}
422
423#[cfg(feature = "json")]
425pub fn is_json(s: &str) -> bool {
426 serde_json::from_str::<serde_json::Value>(s).is_ok()
427}
428
429pub fn is_url(s: &str) -> bool {
431 s.starts_with("http://") || s.starts_with("https://") || s.starts_with("ftp://")
432}
433
434#[cfg(feature = "ids")]
436pub fn is_uuid(s: &str) -> bool {
437 uuid::Uuid::parse_str(s).is_ok()
438}
439
440pub fn is_ulid(s: &str) -> bool {
442 s.len() == 26 && s.chars().all(|c| c.is_ascii_alphanumeric())
443}
444
445pub fn is_alphanumeric(s: &str) -> bool {
447 !s.is_empty() && s.chars().all(|c| c.is_alphanumeric())
448}
449
450pub fn length(s: &str) -> usize {
452 s.chars().count()
453}
454
455pub fn word_count(s: &str) -> usize {
457 s.split_whitespace().count()
458}
459
460pub fn char_at(s: &str, index: usize) -> Option<char> {
462 s.chars().nth(index)
463}
464
465pub fn position(s: &str, needle: &str) -> Option<usize> {
467 s.find(needle)
468}
469
470pub fn substr_count(s: &str, needle: &str) -> usize {
472 s.matches(needle).count()
473}
474
475pub fn starts_with(s: &str, needle: &str) -> bool {
477 s.starts_with(needle)
478}
479
480pub fn ends_with(s: &str, needle: &str) -> bool {
482 s.ends_with(needle)
483}
484
485pub fn contains(s: &str, needle: &str) -> bool {
487 s.contains(needle)
488}
489
490pub fn contains_all(s: &str, needles: &[&str]) -> bool {
492 needles.iter().all(|n| s.contains(n))
493}
494
495pub fn doesnt_contain(s: &str, needle: &str) -> bool {
497 !s.contains(needle)
498}
499
500pub fn pretty_duration(nanos: u64) -> String {
502 if nanos < 1_000 {
503 return format!("{}ns", nanos);
504 }
505 let micros = nanos / 1_000;
506 if micros < 1_000 {
507 return format!("{}μs", micros);
508 }
509 let millis = micros / 1_000;
510 if millis < 1_000 {
511 return format!("{}ms", millis);
512 }
513 let seconds = millis / 1_000;
514 if seconds < 60 {
515 return format!("{}s", seconds);
516 }
517 let minutes = seconds / 60;
518 if minutes < 60 {
519 return format!("{}m", minutes);
520 }
521 let hours = minutes / 60;
522 format!("{}h", hours)
523}
524
525#[cfg(feature = "random")]
528pub fn random(length: usize) -> String {
529 use rand::RngCore;
530 let mut bytes = vec![0u8; length];
531 rand::thread_rng().fill_bytes(&mut bytes);
532 use base64::Engine;
533 base64::engine::general_purpose::URL_SAFE.encode(&bytes)[..length].to_string()
534}
535
536#[cfg(feature = "random")]
538pub fn password(length: usize, symbols: bool) -> String {
539 use rand::Rng;
540 const LETTERS: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
541 const DIGITS: &[u8] = b"0123456789";
542 const SYMBOLS: &[u8] = b"!@#$%^&*()_+-=[]{}|;:,.<>?";
543
544 let mut rng = rand::thread_rng();
545 let mut chars = Vec::new();
546 chars.extend_from_slice(LETTERS);
547 chars.extend_from_slice(DIGITS);
548 if symbols {
549 chars.extend_from_slice(SYMBOLS);
550 }
551
552 (0..length)
553 .map(|_| chars[rng.gen_range(0..chars.len())] as char)
554 .collect()
555}
556
557pub fn to_base64(s: &str) -> String {
559 use base64::Engine;
560 base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
561}
562
563#[cfg(feature = "json")]
565pub fn escape_html(s: &str) -> String {
566 s.replace('&', "&")
567 .replace('<', "<")
568 .replace('>', ">")
569 .replace('"', """)
570 .replace('\'', "'")
571}
572
573#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn snake_to_camel() {
581 assert_eq!(to_camel_case("hello_world"), "helloWorld");
582 assert_eq!(to_camel_case("foo_bar_baz"), "fooBarBaz");
583 }
584
585 #[test]
586 fn snake_to_pascal() {
587 assert_eq!(to_pascal_case("hello_world"), "HelloWorld");
588 }
589
590 #[test]
591 fn camel_to_snake() {
592 assert_eq!(to_snake_case("helloWorld"), "hello_world");
593 assert_eq!(to_snake_case("FooBarBaz"), "foo_bar_baz");
594 }
595
596 #[test]
597 fn camel_to_kebab() {
598 assert_eq!(to_kebab_case("helloWorld"), "hello-world");
599 }
600
601 #[test]
602 fn screaming() {
603 assert_eq!(to_screaming_snake("hello_world"), "HELLO_WORLD");
604 }
605
606 #[test]
607 fn passthrough_unchanged() {
608 assert_eq!(to_snake_case("already_snake"), "already_snake");
609 assert_eq!(to_pascal_case("AlreadyPascal"), "AlreadyPascal");
610 }
611
612 #[test]
613 fn truncate_short_string_unchanged() {
614 assert_eq!(truncate("hi", 10), "hi");
615 assert_eq!(truncate("hello", 5), "hello");
616 }
617
618 #[test]
619 fn truncate_long_string() {
620 assert_eq!(truncate("hello world", 5), "he...");
621 assert_eq!(truncate("abcdefgh", 6), "abc...");
622 }
623
624 #[test]
625 fn pluralize_regular() {
626 assert_eq!(pluralize("user"), "users");
627 assert_eq!(pluralize("item"), "items");
628 }
629
630 #[test]
631 fn pluralize_sibilant() {
632 assert_eq!(pluralize("box"), "boxes");
633 assert_eq!(pluralize("church"), "churches");
634 assert_eq!(pluralize("dish"), "dishes");
635 assert_eq!(pluralize("buzz"), "buzzes");
636 }
637
638 #[test]
639 fn pluralize_y_ending() {
640 assert_eq!(pluralize("category"), "categories");
641 assert_eq!(pluralize("city"), "cities");
642 assert_eq!(pluralize("day"), "days"); }
644
645 #[test]
646 fn pluralize_f_ending() {
647 assert_eq!(pluralize("leaf"), "leaves");
648 assert_eq!(pluralize("knife"), "knives");
649 }
650
651 #[test]
652 fn pluralize_us_ending() {
653 assert_eq!(pluralize("cactus"), "cacti");
654 assert_eq!(pluralize("focus"), "foci");
655 }
656}