1use std::fmt::Display;
19
20pub const REDACTED: &str = "<redacted>";
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
28pub struct SemVer {
29 pub major: u64,
31 pub minor: u64,
33 pub patch: u64,
35}
36
37impl SemVer {
38 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#[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 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#[must_use]
233pub fn mask_api_key(key: &str) -> String {
234 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")] 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}