use std::path::{Component, Path, PathBuf};
pub(crate) fn validate_module_name(name: &str) -> Result<String, String> {
if name.is_empty() {
return Err("module name must not be empty".to_owned());
}
if !is_valid_ident(name) {
return Err(format!(
"module name `{name}` must be a lowercase ASCII identifier (a-z, 0-9, _)"
));
}
Ok(name.to_owned())
}
pub(crate) fn validate_type_name(name: &str) -> Result<String, String> {
if name.is_empty() {
return Err("name must not be empty".to_owned());
}
if !is_valid_type_ident(name) {
return Err(format!(
"name `{name}` must be an ASCII identifier (a-z, A-Z, 0-9, _), starting with a letter or underscore"
));
}
Ok(name.to_owned())
}
pub(crate) fn snake_case(name: &str) -> String {
let mut out = String::new();
for (i, ch) in name.chars().enumerate() {
if ch.is_ascii_uppercase() {
if i > 0 {
out.push('_');
}
out.push(ch.to_ascii_lowercase());
} else {
out.push(ch);
}
}
out
}
pub(crate) fn pascal_case(name: &str) -> String {
let mut out = String::new();
let mut capitalize_next = true;
for ch in name.chars() {
if ch == '_' || ch == '-' {
capitalize_next = true;
} else if capitalize_next {
out.push(ch.to_ascii_uppercase());
capitalize_next = false;
} else {
out.push(ch);
}
}
out
}
pub(crate) fn module_file_path(
src_root: &Path,
module: &str,
file_stem: &str,
) -> Result<PathBuf, String> {
let path = src_root.join(module).join(file_stem);
ensure_within(src_root, &path)?;
Ok(path)
}
pub(crate) fn tests_file_path(root: &Path, file_stem: &str) -> Result<PathBuf, String> {
let path = root.join("tests").join(file_stem);
ensure_within(root, &path)?;
Ok(path)
}
fn ensure_within(root: &Path, path: &Path) -> Result<(), String> {
if path.is_absolute() {
let canonical_root = root
.canonicalize()
.map_err(|e| format!("cannot canonicalize project root {}: {e}", root.display()))?;
let mut current = path.to_path_buf();
while !current.exists() {
match current.parent() {
Some(p) if p != current => current = p.to_path_buf(),
_ => break,
}
}
let canonical = current
.canonicalize()
.map_err(|e| format!("cannot canonicalize {}: {e}", current.display()))?;
if !canonical.starts_with(&canonical_root) {
return Err(format!(
"resolved path {} escapes the project root",
path.display()
));
}
return Ok(());
}
let escapes = path
.components()
.any(|c| !matches!(c, Component::Normal(_)));
if escapes {
return Err(format!(
"resolved path {} contains a non-normal component",
path.display()
));
}
Ok(())
}
fn is_valid_ident(name: &str) -> bool {
name.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_lowercase() || c == '_')
}
fn is_valid_type_ident(name: &str) -> bool {
name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& name
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
}
pub(crate) fn ensure_missing(path: &Path) -> Result<(), String> {
if path.exists() {
Err(format!(
"refusing to overwrite existing file: {}",
path.display()
))
} else {
Ok(())
}
}
pub(crate) fn mod_declaration(file_stem: &str) -> Result<String, String> {
if !is_valid_ident(file_stem) {
return Err(format!(
"cannot derive mod declaration from non-identifier file stem `{file_stem}`"
));
}
Ok(format!("pub mod {file_stem};"))
}
pub(crate) fn append_mod_declaration(mod_rs: &Path, declaration: &str) -> Result<String, String> {
let existing = std::fs::read_to_string(mod_rs).unwrap_or_default();
if existing.contains(declaration) {
return Ok("already declared".to_owned());
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str(declaration);
content.push('\n');
if let Some(parent) = mod_rs.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("cannot create directory {}: {e}", parent.display()))?;
}
std::fs::write(mod_rs, content)
.map_err(|e| format!("cannot write {}: {e}", mod_rs.display()))?;
Ok(format!("declared `{declaration}` in {}", mod_rs.display()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snake_case_converts_pascal() {
assert_eq!(snake_case("Links"), "links");
assert_eq!(snake_case("SessionsController"), "sessions_controller");
assert_eq!(snake_case("SendEmail"), "send_email");
}
#[test]
fn pascal_case_converts_snake() {
assert_eq!(pascal_case("links"), "Links");
assert_eq!(pascal_case("user_accounts"), "UserAccounts");
assert_eq!(pascal_case("send_email"), "SendEmail");
}
#[test]
fn validate_module_name_rejects_uppercase_and_paths() {
assert!(validate_module_name("Links").is_err());
assert!(validate_module_name("").is_err());
assert!(validate_module_name("../etc").is_err());
assert!(validate_module_name("links").is_ok());
assert!(validate_module_name("user_accounts").is_ok());
}
#[test]
fn validate_type_name_rejects_non_identifiers() {
assert!(validate_type_name("").is_err());
assert!(validate_type_name("123abc").is_err());
assert!(validate_type_name("a/b").is_err());
assert!(validate_type_name("Links").is_ok());
assert!(validate_type_name("send_welcome").is_ok());
assert!(validate_type_name("_private").is_ok());
}
#[test]
fn mod_declaration_validates_stem() {
assert_eq!(
mod_declaration("links_controller").unwrap(),
"pub mod links_controller;"
);
assert!(mod_declaration("Links-Controller").is_err());
assert!(mod_declaration("").is_err());
}
}