Skip to main content

bnto_core/
case.rs

1// Centralized string case/formatting — the Rust equivalent of JS's `change-case`.
2//
3// Every case transformation in the engine goes through this module.
4// No external deps beyond `unicode-normalization` (already a workspace dep).
5
6/// Lowercase the entire string.
7pub fn lower(s: &str) -> String {
8    s.to_lowercase()
9}
10
11/// Uppercase the entire string.
12pub fn upper(s: &str) -> String {
13    s.to_uppercase()
14}
15
16/// Capitalize first letter of a single word, lowercase the rest.
17/// "hello" -> "Hello", "HELLO" -> "Hello"
18pub fn capitalize(s: &str) -> String {
19    let mut chars = s.chars();
20    match chars.next() {
21        None => String::new(),
22        Some(first) => {
23            let upper_first: String = first.to_uppercase().collect();
24            let lower_rest: String = chars.as_str().to_lowercase();
25            format!("{upper_first}{lower_rest}")
26        }
27    }
28}
29
30/// Title Case — capitalize each whitespace-delimited word.
31/// "hello world" -> "Hello World"
32pub fn title(s: &str) -> String {
33    s.split_whitespace()
34        .map(capitalize)
35        .collect::<Vec<_>>()
36        .join(" ")
37}
38
39/// Convert to slug — lowercase, replace non-alphanumeric with hyphens, collapse consecutive.
40/// "Hello World!" -> "hello-world", "  Multiple   Spaces  " -> "multiple-spaces"
41pub fn slug(s: &str) -> String {
42    slug_with_separator(s, "-")
43}
44
45/// Slug with a custom separator character.
46pub fn slug_with_separator(s: &str, separator: &str) -> String {
47    let lowered = s.to_lowercase();
48    let mut result = String::with_capacity(lowered.len());
49    let mut prev_was_sep = true; // avoid leading separator
50
51    for ch in lowered.chars() {
52        if ch.is_ascii_alphanumeric() {
53            result.push(ch);
54            prev_was_sep = false;
55        } else if !prev_was_sep {
56            result.push_str(separator);
57            prev_was_sep = true;
58        }
59    }
60
61    if result.ends_with(separator) {
62        result.truncate(result.len() - separator.len());
63    }
64
65    result
66}
67
68/// Replace hyphens and underscores with spaces.
69/// "vehicles-and-monsters" -> "vehicles and monsters"
70pub fn deslug(s: &str) -> String {
71    s.chars()
72        .map(|c| if c == '-' || c == '_' { ' ' } else { c })
73        .collect()
74}
75
76/// Deslug + title case (the common composition).
77/// "vehicles-and-monsters" -> "Vehicles And Monsters"
78pub fn deslug_title(s: &str) -> String {
79    title(&deslug(s))
80}
81
82/// Remove non-ASCII characters, replace spaces/punctuation with separator.
83/// Preserves case (unlike slug which lowercases).
84pub fn strip_non_ascii(s: &str, separator: &str) -> String {
85    let mut result = String::with_capacity(s.len());
86    let mut prev_was_sep = true;
87
88    for ch in s.chars() {
89        if ch.is_ascii_alphanumeric() {
90            result.push(ch);
91            prev_was_sep = false;
92        } else if ch.is_ascii() && !prev_was_sep {
93            result.push_str(separator);
94            prev_was_sep = true;
95        }
96    }
97
98    if result.ends_with(separator) {
99        result.truncate(result.len() - separator.len());
100    }
101
102    result
103}
104
105/// NFC Unicode normalization (compose accented characters).
106pub fn normalize_unicode(s: &str) -> String {
107    unicode_normalization::UnicodeNormalization::nfc(s).collect()
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    // --- lower ---
115
116    #[test]
117    fn lower_basic() {
118        assert_eq!(lower("Hello World"), "hello world");
119    }
120
121    #[test]
122    fn lower_unicode() {
123        assert_eq!(lower("CAFÉ"), "café");
124    }
125
126    #[test]
127    fn lower_empty() {
128        assert_eq!(lower(""), "");
129    }
130
131    // --- upper ---
132
133    #[test]
134    fn upper_basic() {
135        assert_eq!(upper("hello world"), "HELLO WORLD");
136    }
137
138    #[test]
139    fn upper_unicode() {
140        assert_eq!(upper("café"), "CAFÉ");
141    }
142
143    #[test]
144    fn upper_empty() {
145        assert_eq!(upper(""), "");
146    }
147
148    // --- capitalize ---
149
150    #[test]
151    fn capitalize_basic() {
152        assert_eq!(capitalize("hello"), "Hello");
153    }
154
155    #[test]
156    fn capitalize_all_caps() {
157        assert_eq!(capitalize("HELLO"), "Hello");
158    }
159
160    #[test]
161    fn capitalize_empty() {
162        assert_eq!(capitalize(""), "");
163    }
164
165    #[test]
166    fn capitalize_unicode_first_char() {
167        assert_eq!(capitalize("über"), "Über");
168    }
169
170    #[test]
171    fn capitalize_single_char() {
172        assert_eq!(capitalize("a"), "A");
173    }
174
175    // --- title ---
176
177    #[test]
178    fn title_multi_word() {
179        assert_eq!(title("hello world"), "Hello World");
180    }
181
182    #[test]
183    fn title_mixed_case() {
184        assert_eq!(title("hELLO wORLD"), "Hello World");
185    }
186
187    #[test]
188    fn title_extra_spaces() {
189        // split_whitespace collapses multiple spaces
190        assert_eq!(title("hello   world"), "Hello World");
191    }
192
193    #[test]
194    fn title_single_word() {
195        assert_eq!(title("hello"), "Hello");
196    }
197
198    #[test]
199    fn title_empty() {
200        assert_eq!(title(""), "");
201    }
202
203    // --- slug ---
204
205    #[test]
206    fn slug_spaces() {
207        assert_eq!(slug("Hello World"), "hello-world");
208    }
209
210    #[test]
211    fn slug_special_chars() {
212        assert_eq!(slug("file (copy).bak"), "file-copy-bak");
213    }
214
215    #[test]
216    fn slug_consecutive_separators() {
217        assert_eq!(slug("file---name...here"), "file-name-here");
218    }
219
220    #[test]
221    fn slug_leading_trailing() {
222        assert_eq!(slug("  Hello World!  "), "hello-world");
223    }
224
225    #[test]
226    fn slug_unicode() {
227        // Non-ASCII alphanumeric gets stripped by is_ascii_alphanumeric
228        assert_eq!(slug("Café Résumé"), "caf-r-sum");
229    }
230
231    #[test]
232    fn slug_already_clean() {
233        assert_eq!(slug("already-clean"), "already-clean");
234    }
235
236    #[test]
237    fn slug_with_custom_separator() {
238        assert_eq!(slug_with_separator("Hello World", "_"), "hello_world");
239    }
240
241    // --- deslug ---
242
243    #[test]
244    fn deslug_hyphens() {
245        assert_eq!(deslug("vehicles-and-monsters"), "vehicles and monsters");
246    }
247
248    #[test]
249    fn deslug_underscores() {
250        assert_eq!(deslug("my_project_name"), "my project name");
251    }
252
253    #[test]
254    fn deslug_mixed() {
255        assert_eq!(deslug("my-project_name"), "my project name");
256    }
257
258    #[test]
259    fn deslug_no_separators() {
260        assert_eq!(deslug("hello"), "hello");
261    }
262
263    #[test]
264    fn deslug_empty() {
265        assert_eq!(deslug(""), "");
266    }
267
268    // --- deslug_title ---
269
270    #[test]
271    fn deslug_title_hyphens() {
272        assert_eq!(
273            deslug_title("vehicles-and-monsters"),
274            "Vehicles And Monsters"
275        );
276    }
277
278    #[test]
279    fn deslug_title_underscores() {
280        assert_eq!(deslug_title("my_project_name"), "My Project Name");
281    }
282
283    #[test]
284    fn deslug_title_single_word() {
285        assert_eq!(deslug_title("hello"), "Hello");
286    }
287
288    #[test]
289    fn deslug_title_empty() {
290        assert_eq!(deslug_title(""), "");
291    }
292
293    // --- strip_non_ascii ---
294
295    #[test]
296    fn strip_non_ascii_accented() {
297        assert_eq!(strip_non_ascii("café résumé", "-"), "caf-rsum");
298    }
299
300    #[test]
301    fn strip_non_ascii_emoji() {
302        assert_eq!(strip_non_ascii("hello 🎉 world", "-"), "hello-world");
303    }
304
305    #[test]
306    fn strip_non_ascii_clean_input() {
307        assert_eq!(strip_non_ascii("hello", "-"), "hello");
308    }
309
310    #[test]
311    fn strip_non_ascii_preserves_case() {
312        assert_eq!(strip_non_ascii("Café", "-"), "Caf");
313    }
314
315    // --- normalize_unicode ---
316
317    #[test]
318    fn normalize_composed_vs_decomposed() {
319        // U+0065 (e) + U+0301 (combining acute) = NFD form of "é"
320        let nfd = "re\u{0301}sume\u{0301}";
321        let nfc = normalize_unicode(nfd);
322        assert_eq!(nfc, "r\u{00e9}sum\u{00e9}");
323    }
324
325    #[test]
326    fn normalize_already_nfc() {
327        let nfc = "résumé";
328        assert_eq!(normalize_unicode(nfc), nfc);
329    }
330
331    #[test]
332    fn normalize_empty() {
333        assert_eq!(normalize_unicode(""), "");
334    }
335}