#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Http,
Graphql,
Ws,
Queue,
Schedule,
Mcp,
}
impl Transport {
pub fn folder(self) -> &'static str {
match self {
Self::Http => "http",
Self::Graphql => "graphql",
Self::Ws => "ws",
Self::Queue => "queue",
Self::Schedule => "schedule",
Self::Mcp => "mcp",
}
}
fn module_infix(self) -> &'static str {
match self {
Self::Http => "Http",
Self::Graphql => "Graphql",
Self::Ws => "Ws",
Self::Queue => "Queue",
Self::Schedule => "Schedule",
Self::Mcp => "Mcp",
}
}
pub fn handler_file(self) -> &'static str {
match self {
Self::Http => "controller.rs",
Self::Graphql => "resolver.rs",
Self::Ws => "gateway.rs",
Self::Queue => "processor.rs",
Self::Schedule => "tasks.rs",
Self::Mcp => "tool.rs",
}
}
pub fn handler_mod(self) -> &'static str {
self.handler_file().trim_end_matches(".rs")
}
}
#[derive(Debug, Clone)]
pub struct Names {
pub kebab: String,
pub snake: String,
pub pascal: String,
pub singular: String,
}
pub fn validate_feature_name(raw: &str) -> Result<(), String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err("feature name must not be empty".into());
}
if trimmed.contains("..") || trimmed.contains('/') || trimmed.contains('\\') {
return Err("feature name must not contain path separators".into());
}
if trimmed.starts_with('.') {
return Err("feature name must not start with '.'".into());
}
Ok(())
}
impl Names {
pub fn parse(raw: &str) -> Self {
let kebab = to_kebab(raw);
let snake = kebab.replace('-', "_");
let pascal = to_pascal(&kebab);
let singular = singularize(&pascal);
Self {
kebab,
snake,
pascal,
singular,
}
}
pub fn module(&self) -> String {
format!("{}Module", self.pascal)
}
pub fn service(&self) -> String {
format!("{}Service", self.pascal)
}
pub fn controller(&self) -> String {
format!("{}Controller", self.pascal)
}
pub fn resolver(&self) -> String {
format!("{}Resolver", self.pascal)
}
pub fn gateway(&self) -> String {
format!("{}Gateway", self.pascal)
}
pub fn processor(&self) -> String {
format!("{}Processor", self.pascal)
}
pub fn tasks(&self) -> String {
format!("{}Tasks", self.pascal)
}
pub fn tool(&self) -> String {
format!("{}Tool", self.singular)
}
pub fn entity(&self) -> String {
self.singular.clone()
}
pub fn table(&self) -> String {
to_kebab(&self.singular).replace('-', "_")
}
pub fn create_op(&self) -> String {
format!("Create{}", self.singular)
}
pub fn update_op(&self) -> String {
format!("Update{}", self.singular)
}
pub fn command(&self) -> String {
format!("Process{}Command", self.singular)
}
pub fn module_for(&self, transport: Transport) -> String {
format!("{}{}Module", self.pascal, transport.module_infix())
}
pub fn handler_for(&self, transport: Transport) -> String {
match transport {
Transport::Http => self.controller(),
Transport::Graphql => self.resolver(),
Transport::Ws => self.gateway(),
Transport::Queue => self.processor(),
Transport::Schedule => self.tasks(),
Transport::Mcp => self.tool(),
}
}
pub fn http_module(&self) -> String {
self.module_for(Transport::Http)
}
}
fn port_role_file(role: &str, stem: &str, total: usize) -> String {
if total <= 1 {
format!("{role}.rs")
} else {
format!("{role}s/{stem}_{role}.rs")
}
}
#[allow(dead_code)]
pub fn dto_file(stem: &str, total: usize) -> String {
port_role_file("dto", stem, total)
}
#[allow(dead_code)]
pub fn command_file(stem: &str, total: usize) -> String {
port_role_file("command", stem, total)
}
#[allow(dead_code)]
pub fn event_file(stem: &str, total: usize) -> String {
port_role_file("event", stem, total)
}
#[allow(dead_code)]
pub fn input_file(stem: &str, total: usize) -> String {
format!("graphql/{}", port_role_file("input", stem, total))
}
fn to_kebab(raw: &str) -> String {
let mut out = String::new();
for (i, ch) in raw.chars().enumerate() {
if ch.is_whitespace() || ch == '_' {
if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
continue;
}
if ch.is_uppercase() {
if i > 0 && !out.ends_with('-') {
out.push('-');
}
out.extend(ch.to_lowercase());
} else if ch == '-' {
if !out.ends_with('-') {
out.push('-');
}
} else {
out.push(ch);
}
}
out.trim_matches('-').to_string()
}
fn to_pascal(kebab: &str) -> String {
kebab
.split('-')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => {
let mut head = first.to_uppercase().to_string();
head.push_str(chars.as_str());
head
}
}
})
.collect()
}
fn singularize(pascal: &str) -> String {
if pascal.is_empty() {
return pascal.to_string();
}
let lower = pascal.to_lowercase();
if lower.ends_with("ies") {
return format!("{}y", &pascal[..pascal.len() - 3]);
}
for suffix in ["ses", "xes", "zes", "ches", "shes"] {
if lower.ends_with(suffix) {
let keep = pascal.len() - 2;
return pascal[..keep].to_string();
}
}
if lower.ends_with("ss") {
return pascal.to_string();
}
if let Some(stripped) = pascal.strip_suffix('s') {
return stripped.to_string();
}
pascal.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_path_traversal_feature_names() {
assert!(validate_feature_name("/tmp/pwn").is_err());
assert!(validate_feature_name("../escape").is_err());
assert!(validate_feature_name("valid_name").is_ok());
}
#[test]
fn parses_kebab_names() {
let names = Names::parse("my-api");
assert_eq!(names.kebab, "my-api");
assert_eq!(names.snake, "my_api");
assert_eq!(names.pascal, "MyApi");
assert_eq!(names.module(), "MyApiModule");
}
#[test]
fn parses_snake_names() {
let names = Names::parse("blog_posts");
assert_eq!(names.kebab, "blog-posts");
assert_eq!(names.snake, "blog_posts");
assert_eq!(names.pascal, "BlogPosts");
assert_eq!(names.singular, "BlogPost");
}
#[test]
fn singularizes_entity_names() {
assert_eq!(Names::parse("users").entity(), "User");
assert_eq!(Names::parse("categories").entity(), "Category");
assert_eq!(Names::parse("statuses").entity(), "Status");
assert_eq!(Names::parse("post").entity(), "Post");
assert_eq!(Names::parse("address").entity(), "Address");
}
#[test]
fn dto_and_transport_module_names() {
let names = Names::parse("posts");
assert_eq!(names.create_op(), "CreatePost");
assert_eq!(names.update_op(), "UpdatePost");
assert_eq!(names.command(), "ProcessPostCommand");
assert_eq!(names.processor(), "PostsProcessor");
assert_eq!(names.module_for(Transport::Http), "PostsHttpModule");
assert_eq!(names.module_for(Transport::Graphql), "PostsGraphqlModule");
assert_eq!(names.handler_for(Transport::Ws), "PostsGateway");
assert_eq!(names.http_module(), "PostsHttpModule");
}
#[test]
fn dto_file_layout_mirrors_the_entity_rule() {
assert_eq!(dto_file("transcode", 1), "dto.rs");
assert_eq!(dto_file("login", 2), "dtos/login_dto.rs");
assert_eq!(dto_file("token_request", 2), "dtos/token_request_dto.rs");
}
#[test]
fn command_file_layout_mirrors_the_dto_rule() {
assert_eq!(command_file("transcode", 1), "command.rs");
assert_eq!(
command_file("transcode", 2),
"commands/transcode_command.rs"
);
assert_eq!(
command_file("generate_media_variant", 2),
"commands/generate_media_variant_command.rs"
);
}
#[test]
fn event_file_layout_mirrors_the_dto_rule() {
assert_eq!(event_file("order_placed", 1), "event.rs");
assert_eq!(
event_file("order_placed", 2),
"events/order_placed_event.rs"
);
}
#[test]
fn input_file_layout_lives_under_the_graphql_adapter() {
assert_eq!(input_file("create_post", 1), "graphql/input.rs");
assert_eq!(
input_file("create_post", 2),
"graphql/inputs/create_post_input.rs"
);
}
}