Skip to main content

nautilus_core/
string.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! String manipulation functionality.
17
18use std::fmt::Display;
19
20/// Placeholder used in `Debug` impls to redact secret fields.
21pub const REDACTED: &str = "<redacted>";
22
23/// Parsed semantic version with major, minor, and patch components.
24///
25/// Supports parsing `"X.Y.Z"` strings and lexicographic comparison
26/// (major, then minor, then patch).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
28pub struct SemVer {
29    /// Major version number.
30    pub major: u64,
31    /// Minor version number.
32    pub minor: u64,
33    /// Patch version number.
34    pub patch: u64,
35}
36
37impl SemVer {
38    /// Parses a `"major.minor.patch"` string into a [`SemVer`].
39    ///
40    /// Missing components default to zero.
41    pub fn parse(s: &str) -> anyhow::Result<Self> {
42        let mut parts = s.split('.').map(str::parse::<u64>);
43        let major = parts.next().unwrap_or(Ok(0))?;
44        let minor = parts.next().unwrap_or(Ok(0))?;
45        let patch = parts.next().unwrap_or(Ok(0))?;
46        Ok(Self {
47            major,
48            minor,
49            patch,
50        })
51    }
52}
53
54impl Display for SemVer {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
57    }
58}
59
60/// Converts a string from any common case to `snake_case`.
61///
62/// Word boundaries are detected at:
63/// - Non-alphanumeric characters (spaces, hyphens, underscores, colons, etc.)
64/// - Transitions from lowercase or digit to uppercase (`camelCase` -> `camel_case`)
65/// - Within consecutive uppercase letters, before the last if followed by lowercase
66///   (`XMLParser` -> `xml_parser`)
67#[must_use]
68pub fn to_snake_case(s: &str) -> String {
69    if s.is_ascii() {
70        to_snake_case_ascii(s.as_bytes())
71    } else {
72        to_snake_case_unicode(s)
73    }
74}
75
76fn to_snake_case_ascii(bytes: &[u8]) -> String {
77    // Single pass over bytes. Mode tracks the case of the last cased character
78    // within the current alphanumeric run, matching heck's word-boundary rules.
79    const BOUNDARY: u8 = 0;
80    const LOWER: u8 = 1;
81    const UPPER: u8 = 2;
82
83    let len = bytes.len();
84    let mut result = String::with_capacity(len + len / 4);
85    let mut first_word = true;
86    let mut mode: u8 = BOUNDARY;
87    let mut word_start = 0;
88    let mut i = 0;
89
90    while i < len {
91        let b = bytes[i];
92
93        if !b.is_ascii_alphanumeric() {
94            if word_start < i {
95                push_lower_ascii(&mut result, &bytes[word_start..i], &mut first_word);
96            }
97            word_start = i + 1;
98            mode = BOUNDARY;
99            i += 1;
100            continue;
101        }
102
103        let next_mode = if b.is_ascii_lowercase() {
104            LOWER
105        } else if b.is_ascii_uppercase() {
106            UPPER
107        } else {
108            mode
109        };
110
111        if i + 1 < len && bytes[i + 1].is_ascii_alphanumeric() {
112            let next = bytes[i + 1];
113
114            if next_mode == LOWER && next.is_ascii_uppercase() {
115                push_lower_ascii(&mut result, &bytes[word_start..=i], &mut first_word);
116                word_start = i + 1;
117                mode = BOUNDARY;
118            } else if mode == UPPER && b.is_ascii_uppercase() && next.is_ascii_lowercase() {
119                if word_start < i {
120                    push_lower_ascii(&mut result, &bytes[word_start..i], &mut first_word);
121                }
122                word_start = i;
123                mode = BOUNDARY;
124            } else {
125                mode = next_mode;
126            }
127        }
128
129        i += 1;
130    }
131
132    if word_start < len && bytes[word_start].is_ascii_alphanumeric() {
133        push_lower_ascii(&mut result, &bytes[word_start..], &mut first_word);
134    }
135
136    result
137}
138
139fn push_lower_ascii(result: &mut String, word: &[u8], first_word: &mut bool) {
140    if word.is_empty() {
141        *first_word = false;
142        return;
143    }
144
145    if !*first_word {
146        result.push('_');
147    }
148    *first_word = false;
149
150    for &b in word {
151        result.push(char::from(b.to_ascii_lowercase()));
152    }
153}
154
155fn to_snake_case_unicode(s: &str) -> String {
156    #[derive(Clone, Copy, PartialEq)]
157    enum Mode {
158        Boundary,
159        Lowercase,
160        Uppercase,
161    }
162
163    let mut result = String::with_capacity(s.len() + s.len() / 4);
164    let mut first_word = true;
165
166    for word in s.split(|c: char| !c.is_alphanumeric()) {
167        let mut char_indices = word.char_indices().peekable();
168        let mut init = 0;
169        let mut mode = Mode::Boundary;
170
171        while let Some((i, c)) = char_indices.next() {
172            if let Some(&(next_i, next)) = char_indices.peek() {
173                let next_mode = if c.is_lowercase() {
174                    Mode::Lowercase
175                } else if c.is_uppercase() {
176                    Mode::Uppercase
177                } else {
178                    mode
179                };
180
181                if next_mode == Mode::Lowercase && next.is_uppercase() {
182                    push_lower_unicode(&mut result, &word[init..next_i], &mut first_word);
183                    init = next_i;
184                    mode = Mode::Boundary;
185                } else if mode == Mode::Uppercase && c.is_uppercase() && next.is_lowercase() {
186                    push_lower_unicode(&mut result, &word[init..i], &mut first_word);
187                    init = i;
188                    mode = Mode::Boundary;
189                } else {
190                    mode = next_mode;
191                }
192            } else {
193                push_lower_unicode(&mut result, &word[init..], &mut first_word);
194                break;
195            }
196        }
197    }
198
199    result
200}
201
202fn push_lower_unicode(result: &mut String, word: &str, first_word: &mut bool) {
203    if word.is_empty() {
204        *first_word = false;
205        return;
206    }
207
208    if !*first_word {
209        result.push('_');
210    }
211    *first_word = false;
212
213    for c in word.chars() {
214        for lc in c.to_lowercase() {
215            result.push(lc);
216        }
217    }
218}
219
220/// Masks an API key by showing only the first and last 4 characters.
221///
222/// For keys 8 characters or shorter, returns asterisks only.
223///
224/// # Examples
225///
226/// ```
227/// use nautilus_core::string::mask_api_key;
228///
229/// assert_eq!(mask_api_key("abcdefghijklmnop"), "abcd...mnop");
230/// assert_eq!(mask_api_key("short"), "*****");
231/// ```
232#[must_use]
233pub fn mask_api_key(key: &str) -> String {
234    // Work with Unicode scalars to avoid panicking on multibyte characters.
235    let chars: Vec<char> = key.chars().collect();
236    let len = chars.len();
237
238    if len <= 8 {
239        return "*".repeat(len);
240    }
241
242    let first: String = chars[..4].iter().collect();
243    let last: String = chars[len - 4..].iter().collect();
244
245    format!("{first}...{last}")
246}
247
248#[cfg(test)]
249mod tests {
250    use rstest::rstest;
251
252    use super::*;
253
254    #[rstest]
255    #[case("", "")]
256    #[case("a", "*")]
257    #[case("abc", "***")]
258    #[case("abcdefgh", "********")]
259    #[case("abcdefghi", "abcd...fghi")]
260    #[case("abcdefghijklmnop", "abcd...mnop")]
261    #[case("VeryLongAPIKey123456789", "Very...6789")]
262    fn test_mask_api_key(#[case] input: &str, #[case] expected: &str) {
263        assert_eq!(mask_api_key(input), expected);
264    }
265
266    #[rstest]
267    #[case("CamelCase", "camel_case")]
268    #[case("This is Human case.", "this_is_human_case")]
269    #[case(
270        "MixedUP CamelCase, with some Spaces",
271        "mixed_up_camel_case_with_some_spaces"
272    )]
273    #[case(
274        "mixed_up_ snake_case with some _spaces",
275        "mixed_up_snake_case_with_some_spaces"
276    )]
277    #[case("kebab-case", "kebab_case")]
278    #[case("SHOUTY_SNAKE_CASE", "shouty_snake_case")]
279    #[case("snake_case", "snake_case")]
280    #[case("XMLHttpRequest", "xml_http_request")]
281    #[case("FIELD_NAME11", "field_name11")]
282    #[case("99BOTTLES", "99bottles")]
283    #[case("abc123def456", "abc123def456")]
284    #[case("abc123DEF456", "abc123_def456")]
285    #[case("abc123Def456", "abc123_def456")]
286    #[case("abc123DEf456", "abc123_d_ef456")]
287    #[case("ABC123def456", "abc123def456")]
288    #[case("ABC123DEF456", "abc123def456")]
289    #[case("ABC123Def456", "abc123_def456")]
290    #[case("ABC123DEf456", "abc123d_ef456")]
291    #[case("ABC123dEEf456FOO", "abc123d_e_ef456_foo")]
292    #[case("abcDEF", "abc_def")]
293    #[case("ABcDE", "a_bc_de")]
294    #[case("", "")]
295    #[case("A", "a")]
296    #[case("AB", "ab")]
297    #[case("PascalCase", "pascal_case")]
298    #[case("camelCase", "camel_case")]
299    #[case("getHTTPResponse", "get_http_response")]
300    #[case("Level1", "level1")]
301    #[case("OrderBookDelta", "order_book_delta")]
302    #[case("IOError", "io_error")]
303    #[case("SimpleHTTPServer", "simple_http_server")]
304    #[case("version2Release", "version2_release")]
305    #[case("ALLCAPS", "allcaps")]
306    #[case("nautilus_model::data::bar::Bar", "nautilus_model_data_bar_bar")] // nautilus-import-ok
307    fn test_to_snake_case(#[case] input: &str, #[case] expected: &str) {
308        assert_eq!(to_snake_case(input), expected);
309    }
310
311    #[rstest]
312    #[case("6.2.0", 6, 2, 0)]
313    #[case("7.0.15", 7, 0, 15)]
314    #[case("0.0.1", 0, 0, 1)]
315    #[case("1", 1, 0, 0)]
316    #[case("2.5", 2, 5, 0)]
317    fn test_semver_parse(
318        #[case] input: &str,
319        #[case] major: u64,
320        #[case] minor: u64,
321        #[case] patch: u64,
322    ) {
323        let v = SemVer::parse(input).unwrap();
324        assert_eq!(v.major, major);
325        assert_eq!(v.minor, minor);
326        assert_eq!(v.patch, patch);
327    }
328
329    #[rstest]
330    fn test_semver_display() {
331        let v = SemVer::parse("7.2.4").unwrap();
332        assert_eq!(v.to_string(), "7.2.4");
333    }
334
335    #[rstest]
336    fn test_semver_ordering() {
337        let v620 = SemVer::parse("6.2.0").unwrap();
338        let v700 = SemVer::parse("7.0.0").unwrap();
339        let v621 = SemVer::parse("6.2.1").unwrap();
340        let v630 = SemVer::parse("6.3.0").unwrap();
341
342        assert!(v700 > v620);
343        assert!(v621 > v620);
344        assert!(v630 > v621);
345        assert!(v700 >= v620);
346        assert!(v620 >= v620);
347    }
348
349    #[rstest]
350    fn test_semver_parse_invalid() {
351        assert!(SemVer::parse("abc").is_err());
352    }
353}