use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactName {
segments: Vec<String>,
stem: String,
}
impl ArtifactName {
pub fn parse(input: &str) -> Result<Self, NameError> {
let normalized = input.replace("::", "/").replace('\\', "/");
let raw: Vec<&str> = normalized
.split('/')
.filter(|part| !part.is_empty())
.collect();
let Some((last, parents)) = raw.split_last() else {
return Err(NameError::Empty);
};
let mut segments = Vec::with_capacity(parents.len());
for parent in parents {
segments.push(to_snake_case(validate(parent)?));
}
Ok(Self {
segments,
stem: to_snake_case(validate(last)?),
})
}
#[must_use]
pub fn segments(&self) -> &[String] {
&self.segments
}
#[must_use]
pub fn file_stem(&self, suffix: &str) -> String {
let base = self.base(suffix);
if suffix.is_empty() {
base.to_string()
} else {
format!("{base}_{}", to_snake_case(suffix))
}
}
#[must_use]
pub fn type_name(&self, suffix: &str) -> String {
format!("{}{}", to_pascal_case(self.base(suffix)), pascal(suffix))
}
fn base<'a>(&'a self, suffix: &str) -> &'a str {
if suffix.is_empty() {
return &self.stem;
}
let snake_suffix = format!("_{}", to_snake_case(suffix));
match self.stem.strip_suffix(&snake_suffix) {
Some(rest) if !rest.is_empty() => rest,
_ => &self.stem,
}
}
#[must_use]
pub fn slash_path(&self) -> String {
let mut parts = self.segments.clone();
parts.push(self.stem.clone());
parts.join("/")
}
}
impl fmt::Display for ArtifactName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.slash_path())
}
}
fn validate(segment: &str) -> Result<&str, NameError> {
let first_is_letter = segment
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic());
let rest_is_clean = segment
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if first_is_letter && rest_is_clean {
Ok(segment)
} else {
Err(NameError::BadSegment {
segment: segment.to_owned(),
})
}
}
pub fn to_snake_case(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let mut out = String::with_capacity(input.len() + 4);
for (index, ¤t) in chars.iter().enumerate() {
if current == '-' || current == '_' || current == ' ' {
if !out.ends_with('_') && !out.is_empty() {
out.push('_');
}
continue;
}
if current.is_ascii_uppercase() && index > 0 {
let previous = chars[index - 1];
let starts_word = previous.is_ascii_lowercase()
|| previous.is_ascii_digit()
|| (previous.is_ascii_uppercase()
&& chars.get(index + 1).is_some_and(char::is_ascii_lowercase));
if starts_word && !out.ends_with('_') {
out.push('_');
}
}
out.push(current.to_ascii_lowercase());
}
out.trim_matches('_').to_string()
}
pub fn to_pascal_case(input: &str) -> String {
pascal(&to_snake_case(input))
}
fn pascal(snake: &str) -> String {
snake
.split('_')
.filter(|word| !word.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
})
.collect()
}
pub fn pluralize(word: &str) -> String {
const SIBILANT_ENDINGS: [&str; 5] = ["s", "x", "z", "ch", "sh"];
if let Some(stem) = word.strip_suffix('y')
&& !stem.ends_with(['a', 'e', 'i', 'o', 'u'])
{
return format!("{stem}ies");
}
if SIBILANT_ENDINGS.iter().any(|ending| word.ends_with(ending)) {
return format!("{word}es");
}
format!("{word}s")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NameError {
Empty,
BadSegment { segment: String },
}
impl fmt::Display for NameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str(
"the name is empty; pass something like `user`, `User`, or `users/show`",
),
Self::BadSegment { segment } => write!(
formatter,
"{segment:?} is not a valid name segment; use letters, digits, `-`, and `_`, \
starting with a letter, and separate nested names with `/`"
),
}
}
}
impl std::error::Error for NameError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_lowercase_name_and_its_pascal_spelling_normalize_alike() {
let lower = ArtifactName::parse("user").expect("valid");
let pascal = ArtifactName::parse("User").expect("valid");
assert_eq!(lower, pascal);
assert_eq!(lower.file_stem("Controller"), "user_controller");
assert_eq!(lower.type_name("Controller"), "UserController");
}
#[test]
fn a_nested_name_splits_into_directories_and_a_stem() {
let name = ArtifactName::parse("users/show").expect("valid");
assert_eq!(name.segments(), ["users"]);
assert_eq!(name.file_stem("Controller"), "show_controller");
assert_eq!(name.type_name("Controller"), "ShowController");
assert_eq!(name.slash_path(), "users/show");
}
#[test]
fn backslashes_and_rust_paths_are_the_same_separator() {
let expected = ArtifactName::parse("admin/users/show").expect("valid");
assert_eq!(
ArtifactName::parse("admin\\users\\show").expect("valid"),
expected
);
assert_eq!(
ArtifactName::parse("admin::users::show").expect("valid"),
expected
);
}
#[test]
fn a_name_that_already_carries_its_suffix_is_not_doubled() {
let name = ArtifactName::parse("UserController").expect("valid");
assert_eq!(name.file_stem("Controller"), "user_controller");
assert_eq!(name.type_name("Controller"), "UserController");
}
#[test]
fn a_name_that_is_only_the_suffix_keeps_itself() {
let name = ArtifactName::parse("controller").expect("valid");
assert_eq!(name.type_name("Controller"), "ControllerController");
}
#[test]
fn an_empty_suffix_leaves_the_name_alone() {
let name = ArtifactName::parse("SendWelcomeEmail").expect("valid");
assert_eq!(name.file_stem(""), "send_welcome_email");
assert_eq!(name.type_name(""), "SendWelcomeEmail");
}
#[test]
fn a_name_with_path_traversal_is_refused() {
assert!(matches!(
ArtifactName::parse("../../etc/passwd"),
Err(NameError::BadSegment { .. })
));
assert!(matches!(
ArtifactName::parse("users/../secrets"),
Err(NameError::BadSegment { .. })
));
}
#[test]
fn an_empty_name_is_refused() {
assert_eq!(ArtifactName::parse(""), Err(NameError::Empty));
assert_eq!(ArtifactName::parse("///"), Err(NameError::Empty));
}
#[test]
fn a_segment_that_starts_with_a_digit_is_refused() {
assert!(ArtifactName::parse("2fa").is_err());
}
#[test]
fn an_acronym_run_breaks_before_the_word_it_starts() {
assert_eq!(to_snake_case("HTTPServer"), "http_server");
assert_eq!(to_snake_case("OAuthToken"), "o_auth_token");
assert_eq!(to_snake_case("send-welcome-email"), "send_welcome_email");
assert_eq!(to_pascal_case("send_welcome_email"), "SendWelcomeEmail");
}
#[test]
fn the_pluralizer_covers_the_common_english_endings() {
assert_eq!(pluralize("user"), "users");
assert_eq!(pluralize("category"), "categories");
assert_eq!(pluralize("day"), "days");
assert_eq!(pluralize("address"), "addresses");
assert_eq!(pluralize("box"), "boxes");
assert_eq!(pluralize("match"), "matches");
}
}