Skip to main content

rok_utils/
string.rs

1//! String case-conversion helpers.
2//!
3//! All functions are pure and allocation-free when the input already matches
4//! the target convention.
5
6/// Convert a string to `camelCase`.
7///
8/// Words are split on `_`, `-`, spaces, and existing case boundaries.
9pub 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
24/// Convert a string to `PascalCase`.
25pub fn to_pascal_case(s: &str) -> String {
26    split_words(s).iter().map(|w| capitalize(w)).collect()
27}
28
29/// Convert a string to `snake_case`.
30pub 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
38/// Convert a string to `kebab-case`.
39pub 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
47/// Convert a string to `SCREAMING_SNAKE_CASE`.
48pub 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
56/// Convert a string to `dot.case`.
57pub 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
65/// Convert a string to `Title Case`.
66pub 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
74/// Convert a string to `Title Case` (alias for to_title_case).
75pub fn to_headline(s: &str) -> String {
76    to_title_case(s)
77}
78
79/// Convert a string to `Sentence case`.
80pub 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
93/// Convert a string to `noCase` (strips all casing).
94pub 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
102/// Convert a string to uppercase.
103pub fn to_upper(s: &str) -> String {
104    s.to_uppercase()
105}
106
107/// Convert a string to lowercase.
108pub fn to_lower(s: &str) -> String {
109    s.to_lowercase()
110}
111
112/// Convert first character of string to uppercase.
113pub 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
121/// Convert first character of string to lowercase.
122pub 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
130/// Invert the case of a string.
131pub 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
145/// Truncate `s` to at most `max` characters, appending `"..."` if truncation
146/// occurs.
147///
148/// Truncation is always on a valid UTF-8 character boundary, so the result may
149/// be shorter than `max` for multi-byte inputs.
150///
151/// ```rust
152/// use rok_utils::string::truncate;
153/// assert_eq!(truncate("hello world", 5), "he...");
154/// assert_eq!(truncate("hi", 10), "hi");
155/// ```
156pub fn truncate(s: &str, max: usize) -> String {
157    if s.len() <= max {
158        return s.to_string();
159    }
160    // Reserve 3 chars for "..."
161    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
169/// Return a naive English plural of `word`.
170///
171/// Handles common English inflection rules:
172/// * words ending in `s`, `x`, `z`, `ch`, `sh` → `+es`
173/// * words ending in a consonant + `y` → `-y` + `ies`
174/// * words ending in `f` / `fe` → `-f[e]` + `ves`
175/// * words ending in `us` → `-us` + `i`
176/// * everything else → `+s`
177///
178/// ```rust
179/// use rok_utils::string::pluralize;
180/// assert_eq!(pluralize("user"), "users");
181/// assert_eq!(pluralize("box"), "boxes");
182/// assert_eq!(pluralize("category"), "categories");
183/// assert_eq!(pluralize("leaf"), "leaves");
184/// ```
185pub 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
217/// Convert a string to a URL-safe slug with the given separator.
218pub 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
227/// Collapse consecutive whitespace to single spaces and trim edges.
228pub 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
254/// Mask characters from `index` onward with the given character.
255pub 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
264/// Wrap a string with prefix and suffix.
265pub fn wrap(s: &str, before: &str, after: &str) -> String {
266    format!("{}{}{}", before, s, after)
267}
268
269/// Remove prefix and suffix if present.
270pub 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
280/// Pad string to length on the left.
281pub 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
290/// Pad string to length on the right.
291pub 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
300/// Pad string to length on both sides.
301pub 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
313/// Repeat a string `times` times.
314pub fn repeat(s: &str, times: usize) -> String {
315    s.repeat(times)
316}
317
318/// Reverse a string.
319pub fn reverse(s: &str) -> String {
320    s.chars().rev().collect()
321}
322
323/// Replace first occurrence of `from` with `to`.
324pub 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
335/// Replace last occurrence of `from` with `to`.
336pub 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
347/// Ensure string ends with the given value.
348pub 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
356/// Ensure string starts with the given value.
357pub 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
365// ── internal helpers ─────────────────────────────────────────────────────────
366
367/// Split a string into lowercase words by:
368///   - `_` / `-` / space separators
369///   - camelCase / PascalCase boundaries
370fn 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            // Start a new word on uppercase unless it follows another uppercase
383            // (acronym) or is the first character.
384            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
413/// Check if string is empty (after trimming).
414pub fn is_empty(s: &str) -> bool {
415    s.trim().is_empty()
416}
417
418/// Check if string contains only ASCII characters.
419pub fn is_ascii(s: &str) -> bool {
420    s.is_ascii()
421}
422
423/// Check if string is a valid JSON string.
424#[cfg(feature = "json")]
425pub fn is_json(s: &str) -> bool {
426    serde_json::from_str::<serde_json::Value>(s).is_ok()
427}
428
429/// Check if string is a valid URL.
430pub fn is_url(s: &str) -> bool {
431    s.starts_with("http://") || s.starts_with("https://") || s.starts_with("ftp://")
432}
433
434/// Check if string is a valid UUID.
435#[cfg(feature = "ids")]
436pub fn is_uuid(s: &str) -> bool {
437    uuid::Uuid::parse_str(s).is_ok()
438}
439
440/// Check if string is a valid ULID.
441pub fn is_ulid(s: &str) -> bool {
442    s.len() == 26 && s.chars().all(|c| c.is_ascii_alphanumeric())
443}
444
445/// Check if string contains only alphanumeric characters.
446pub fn is_alphanumeric(s: &str) -> bool {
447    !s.is_empty() && s.chars().all(|c| c.is_alphanumeric())
448}
449
450/// Return the character count (not byte count).
451pub fn length(s: &str) -> usize {
452    s.chars().count()
453}
454
455/// Return the word count.
456pub fn word_count(s: &str) -> usize {
457    s.split_whitespace().count()
458}
459
460/// Get character at index.
461pub fn char_at(s: &str, index: usize) -> Option<char> {
462    s.chars().nth(index)
463}
464
465/// Find position of needle in string.
466pub fn position(s: &str, needle: &str) -> Option<usize> {
467    s.find(needle)
468}
469
470/// Count occurrences of substring.
471pub fn substr_count(s: &str, needle: &str) -> usize {
472    s.matches(needle).count()
473}
474
475/// Check if string starts with needle.
476pub fn starts_with(s: &str, needle: &str) -> bool {
477    s.starts_with(needle)
478}
479
480/// Check if string ends with needle.
481pub fn ends_with(s: &str, needle: &str) -> bool {
482    s.ends_with(needle)
483}
484
485/// Check if string contains needle.
486pub fn contains(s: &str, needle: &str) -> bool {
487    s.contains(needle)
488}
489
490/// Check if string contains all needles.
491pub fn contains_all(s: &str, needles: &[&str]) -> bool {
492    needles.iter().all(|n| s.contains(n))
493}
494
495/// Check if string does NOT contain needle.
496pub fn doesnt_contain(s: &str, needle: &str) -> bool {
497    !s.contains(needle)
498}
499
500/// Convert nanoseconds to human-readable duration string.
501pub 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/// Generate a cryptographically secure random string of given length.
526/// Uses URL-safe base64 encoding for the result.
527#[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/// Generate a secure password with letters, digits, and optionally symbols.
537#[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
557/// Convert string to base64 encoding.
558pub fn to_base64(s: &str) -> String {
559    use base64::Engine;
560    base64::engine::general_purpose::STANDARD.encode(s.as_bytes())
561}
562
563/// Escape HTML special characters.
564#[cfg(feature = "json")]
565pub fn escape_html(s: &str) -> String {
566    s.replace('&', "&amp;")
567        .replace('<', "&lt;")
568        .replace('>', "&gt;")
569        .replace('"', "&quot;")
570        .replace('\'', "&#39;")
571}
572
573// ── tests ────────────────────────────────────────────────────────────────────
574
575#[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"); // vowel + y → just +s
643    }
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}