use deunicode::deunicode;
use once_cell::sync::Lazy;
use regex::Regex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransformType {
Clean,
Snake,
Kebab,
Title,
Camel,
Pascal,
Lower,
Upper,
Replace(String, String),
ReplaceRegex(String, String),
RemovePrefix(String),
}
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 replace(find: &str, replace: &str) -> Self {
TransformType::Replace(find.to_string(), replace.to_string())
}
pub fn replace_regex(pattern: &str, replacement: &str) -> Self {
TransformType::ReplaceRegex(pattern.to_string(), replacement.to_string())
}
pub fn remove_prefix(prefix: &str) -> Self {
TransformType::RemovePrefix(prefix.to_string())
}
pub fn as_str(&self) -> String {
match self {
TransformType::Clean => "clean".to_string(),
TransformType::Snake => "snake".to_string(),
TransformType::Kebab => "kebab".to_string(),
TransformType::Title => "title".to_string(),
TransformType::Camel => "camel".to_string(),
TransformType::Pascal => "pascal".to_string(),
TransformType::Lower => "lower".to_string(),
TransformType::Upper => "upper".to_string(),
TransformType::Replace(find, replace) => format!("replace({find} → {replace})"),
TransformType::ReplaceRegex(pattern, replacement) => {
format!("replace-regex({pattern} → {replacement})")
}
TransformType::RemovePrefix(prefix) => format!("remove-prefix({prefix})"),
}
}
}
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_preserve_extension(name),
TransformType::Kebab => kebab_case_preserve_extension(name),
TransformType::Title => title_case_preserve_extension(name),
TransformType::Camel => camel_case_preserve_extension(name),
TransformType::Pascal => pascal_case_preserve_extension(name),
TransformType::Lower => name.to_lowercase(),
TransformType::Upper => name.to_uppercase(),
TransformType::Replace(find, replace) => replace_substring(name, find, replace),
TransformType::ReplaceRegex(pattern, replacement) => {
replace_regex(name, pattern, replacement)
}
TransformType::RemovePrefix(prefix) => remove_prefix(name, prefix),
}
}
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 snake_case_preserve_extension(name: &str) -> String {
if let Some(dot_pos) = name.rfind('.') {
if dot_pos > 0 && dot_pos < name.len() - 1 {
let (basename, extension) = name.split_at(dot_pos);
let transformed_basename = snake_case(basename);
let transformed_extension = extension[1..].to_lowercase(); format!("{transformed_basename}.{transformed_extension}")
} else {
snake_case(name)
}
} else {
snake_case(name)
}
}
fn kebab_case(name: &str) -> String {
let tokens = tokenize(name, false);
format_kebab(&tokens)
}
fn kebab_case_preserve_extension(name: &str) -> String {
preserve_extension_transform(name, kebab_case)
}
fn preserve_extension_transform<F>(name: &str, transform_fn: F) -> String
where
F: Fn(&str) -> String,
{
if let Some(dot_pos) = name.rfind('.') {
if dot_pos > 0 && dot_pos < name.len() - 1 {
let (basename, extension) = name.split_at(dot_pos);
let transformed_basename = transform_fn(basename);
let transformed_extension = extension[1..].to_lowercase(); format!("{transformed_basename}.{transformed_extension}")
} else {
transform_fn(name)
}
} else {
transform_fn(name)
}
}
fn title_case(name: &str) -> String {
let tokens = tokenize(name, true);
format_title(&tokens)
}
fn title_case_preserve_extension(name: &str) -> String {
preserve_extension_transform(name, title_case)
}
fn camel_case(name: &str) -> String {
let tokens = tokenize(name, true);
format_camel(&tokens)
}
fn camel_case_preserve_extension(name: &str) -> String {
preserve_extension_transform(name, camel_case)
}
fn pascal_case(name: &str) -> String {
let tokens = tokenize(name, true);
format_pascal(&tokens)
}
fn pascal_case_preserve_extension(name: &str) -> String {
preserve_extension_transform(name, pascal_case)
}
fn tokenize(name: &str, include_dots: bool) -> Vec<String> {
let normalized = deunicode(name);
let separator_regex = if include_dots {
&WORD_SEPARATORS_WITH_DOTS_RE
} else {
&WORD_SEPARATORS_RE
};
separator_regex
.split(&normalized)
.filter(|s| !s.is_empty())
.flat_map(split_camel_case_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())
)
&& !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(),
}
}
fn replace_substring(name: &str, find: &str, replace: &str) -> String {
name.replace(find, replace)
}
fn replace_regex(name: &str, pattern: &str, replacement: &str) -> String {
match Regex::new(pattern) {
Ok(re) => re.replace_all(name, replacement).to_string(),
Err(_) => {
eprintln!("Warning: Invalid regex pattern '{pattern}', returning original string");
name.to_string()
}
}
}
fn remove_prefix(name: &str, prefix: &str) -> String {
if let Some(stripped) = name.strip_prefix(prefix) {
stripped.to_string()
} else {
name.to_string()
}
}
#[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"
);
assert_eq!(snake_case_preserve_extension("My-File.txt"), "my_file.txt");
assert_eq!(
snake_case_preserve_extension("HelloWorld.pdf"),
"hello_world.pdf"
);
}
#[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");
assert_eq!(kebab_case_preserve_extension("My_File.txt"), "my-file.txt");
assert_eq!(
kebab_case_preserve_extension("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");
}
#[test]
fn test_replace_substring() {
assert_eq!(
replace_substring("hello_world.txt", "hello", "hi"),
"hi_world.txt"
);
assert_eq!(
replace_substring("AFN_project.rs", "AFN", "CNP"),
"CNP_project.rs"
);
assert_eq!(
replace_substring("test_AFN_file.txt", "AFN", "CNP"),
"test_CNP_file.txt"
);
assert_eq!(
replace_substring("no_match.txt", "xyz", "abc"),
"no_match.txt"
);
assert_eq!(
replace_substring("multiple_AFN_AFN.txt", "AFN", "CNP"),
"multiple_CNP_CNP.txt"
);
}
#[test]
fn test_replace_regex() {
assert_eq!(replace_regex("file123.txt", r"\d+", "456"), "file456.txt");
assert_eq!(
replace_regex("AFN_project_v1.rs", r"AFN", "CNP"),
"CNP_project_v1.rs"
);
assert_eq!(
replace_regex("test_file_2023.txt", r"\d{4}", "2024"),
"test_file_2024.txt"
);
assert_eq!(
replace_regex("CamelCase.txt", r"([A-Z])", "_$1"),
"_Camel_Case.txt"
);
assert_eq!(
replace_regex("invalid[regex.txt", r"[", "replacement"),
"invalid[regex.txt"
);
}
#[test]
fn test_transform_replace() {
let replace_transform = TransformType::Replace("AFN".to_string(), "CNP".to_string());
assert_eq!(
transform("AFN_project.rs", &replace_transform),
"CNP_project.rs"
);
let regex_transform = TransformType::ReplaceRegex(r"\d+".to_string(), "XXX".to_string());
assert_eq!(transform("file123.txt", ®ex_transform), "fileXXX.txt");
}
#[test]
fn test_remove_prefix() {
assert_eq!(remove_prefix("prefix_file.txt", "prefix_"), "file.txt");
assert_eq!(remove_prefix("IMG_1234.jpg", "IMG_"), "1234.jpg");
assert_eq!(remove_prefix("DSC_9876.png", "DSC_"), "9876.png");
assert_eq!(remove_prefix("no_match.txt", "prefix_"), "no_match.txt");
assert_eq!(remove_prefix("prefix_", "prefix_"), "");
assert_eq!(remove_prefix("", "prefix_"), "");
assert_eq!(remove_prefix("file.txt", ""), "file.txt");
}
#[test]
fn test_transform_remove_prefix() {
let remove_prefix_transform = TransformType::RemovePrefix("IMG_".to_string());
assert_eq!(
transform("IMG_1234.jpg", &remove_prefix_transform),
"1234.jpg"
);
assert_eq!(
transform("no_prefix.jpg", &remove_prefix_transform),
"no_prefix.jpg"
);
}
}