use once_cell::sync::Lazy;
use regex::Regex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransformType {
Clean,
Snake,
Kebab,
Title,
Camel,
Pascal,
Lower,
Upper,
}
impl TransformType {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"clean" => Some(TransformType::Clean),
"snake" => Some(TransformType::Snake),
"kebab" => Some(TransformType::Kebab),
"title" => Some(TransformType::Title),
"camel" => Some(TransformType::Camel),
"pascal" => Some(TransformType::Pascal),
"lower" => Some(TransformType::Lower),
"upper" => Some(TransformType::Upper),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
TransformType::Clean => "clean",
TransformType::Snake => "snake",
TransformType::Kebab => "kebab",
TransformType::Title => "title",
TransformType::Camel => "camel",
TransformType::Pascal => "pascal",
TransformType::Lower => "lower",
TransformType::Upper => "upper",
}
}
}
static SPECIAL_CHARS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[^\w\s.-]").unwrap());
static MULTIPLE_SPACES_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\s+").unwrap());
static LEADING_TRAILING_SPECIALS_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[-\s.]+|[-\s.]+$").unwrap());
static WORD_SEPARATORS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\s_-]+").unwrap());
static WORD_SEPARATORS_WITH_DOTS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\s_.-]+").unwrap());
pub fn transform(name: &str, transform_type: TransformType) -> String {
match transform_type {
TransformType::Clean => clean(name),
TransformType::Snake => snake_case(name),
TransformType::Kebab => kebab_case(name),
TransformType::Title => title_case(name),
TransformType::Camel => camel_case(name),
TransformType::Pascal => pascal_case(name),
TransformType::Lower => name.to_lowercase(),
TransformType::Upper => name.to_uppercase(),
}
}
fn clean(name: &str) -> String {
let trimmed = name.trim();
let normalized_spaces = MULTIPLE_SPACES_RE.replace_all(trimmed, " ");
let no_special_chars = SPECIAL_CHARS_RE.replace_all(&normalized_spaces, "");
LEADING_TRAILING_SPECIALS_RE
.replace_all(&no_special_chars, "")
.to_string()
}
fn snake_case(name: &str) -> String {
let tokens = tokenize(name, false);
format_snake(&tokens)
}
fn kebab_case(name: &str) -> String {
let tokens = tokenize(name, false);
format_kebab(&tokens)
}
fn title_case(name: &str) -> String {
let tokens = tokenize(name, true);
format_title(&tokens)
}
fn camel_case(name: &str) -> String {
let tokens = tokenize(name, true);
format_camel(&tokens)
}
fn pascal_case(name: &str) -> String {
let tokens = tokenize(name, true);
format_pascal(&tokens)
}
fn tokenize(name: &str, include_dots: bool) -> Vec<String> {
let separator_regex = if include_dots {
&WORD_SEPARATORS_WITH_DOTS_RE
} else {
&WORD_SEPARATORS_RE
};
separator_regex
.split(name)
.filter(|s| !s.is_empty())
.flat_map(|word| split_camel_case_word(word))
.collect()
}
fn split_camel_case_word(word: &str) -> Vec<String> {
let mut result = Vec::new();
let mut current_word = String::new();
let chars: Vec<char> = word.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
if ch.is_uppercase() && i > 0 && (
chars[i-1].is_lowercase() || chars[i-1].is_ascii_digit() ||
(i + 1 < chars.len() && chars[i+1].is_lowercase())
) {
if !current_word.is_empty() {
result.push(current_word.clone());
current_word.clear();
}
}
current_word.push(ch);
}
if !current_word.is_empty() {
result.push(current_word);
}
result
}
fn format_snake(tokens: &[String]) -> String {
tokens.join("_").replace('-', "_").to_lowercase()
}
fn format_kebab(tokens: &[String]) -> String {
tokens.join("-").replace('_', "-").to_lowercase()
}
fn format_camel(tokens: &[String]) -> String {
if tokens.is_empty() {
return String::new();
}
let mut result = tokens[0].to_lowercase();
for token in tokens.iter().skip(1) {
result.push_str(&capitalize_first(token));
}
result
}
fn format_pascal(tokens: &[String]) -> String {
tokens.iter()
.map(|token| capitalize_first(token))
.collect::<Vec<String>>()
.join("")
}
fn format_title(tokens: &[String]) -> String {
tokens.iter()
.map(|token| capitalize_first(token))
.collect::<Vec<String>>()
.join(" ")
}
fn capitalize_first(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean() {
assert_eq!(clean(" My File (1) !! "), "My File 1");
assert_eq!(clean("..leading-dots"), "leading-dots");
assert_eq!(clean("trailing-dots.."), "trailing-dots");
}
#[test]
fn test_snake_case() {
assert_eq!(snake_case("HelloWorld"), "hello_world");
assert_eq!(snake_case("My-File.txt"), "my_file.txt");
assert_eq!(snake_case("already_snake"), "already_snake");
assert_eq!(snake_case("Words With Spaces"), "words_with_spaces");
assert_eq!(
snake_case("Mix-of spaces_and-hyphens"),
"mix_of_spaces_and_hyphens"
);
}
#[test]
fn test_kebab_case() {
assert_eq!(kebab_case("HelloWorld"), "hello-world");
assert_eq!(kebab_case("My_File.txt"), "my-file.txt");
assert_eq!(kebab_case("already-kebab"), "already-kebab");
assert_eq!(kebab_case("Words With Spaces"), "words-with-spaces");
assert_eq!(
kebab_case("Mix-of spaces_and_underscores"),
"mix-of-spaces-and-underscores"
);
assert_eq!(kebab_case("Dir Template.txt"), "dir-template.txt");
}
#[test]
fn test_title_case() {
assert_eq!(title_case("hello_world"), "Hello World");
assert_eq!(title_case("my-file.txt"), "My File Txt");
assert_eq!(title_case("already Title Case"), "Already Title Case");
}
#[test]
fn test_camel_case() {
assert_eq!(camel_case("hello_world"), "helloWorld");
assert_eq!(camel_case("my-file.txt"), "myFileTxt");
assert_eq!(camel_case("Words With Spaces"), "wordsWithSpaces");
assert_eq!(camel_case("multiple spaces"), "multipleSpaces");
}
#[test]
fn test_pascal_case() {
assert_eq!(pascal_case("hello_world"), "HelloWorld");
assert_eq!(pascal_case("my-file.txt"), "MyFileTxt");
assert_eq!(pascal_case("Words With Spaces"), "WordsWithSpaces");
assert_eq!(pascal_case("multiple spaces"), "MultipleSpaces");
}
}