use sha2::{Digest, Sha256};
use thiserror::Error;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProjectNames {
pub directory_name: String,
pub crate_name: String,
pub display_name: String,
pub application_identifier: String,
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum NamingError {
#[error("invalid project name: {reason}")]
InvalidProjectName {
reason: String,
},
#[error("invalid application identifier `{identifier}`: {reason}")]
InvalidApplicationIdentifier {
identifier: String,
reason: String,
},
}
pub fn derive_project_names(
requested_name: &str,
identifier: Option<&str>,
) -> Result<ProjectNames, NamingError> {
validate_project_name(requested_name)?;
let crate_name = crate_name(requested_name);
let display_name = display_name(requested_name);
let application_identifier = identifier.map_or_else(
|| {
let suffix = crate_name
.chars()
.filter(char::is_ascii_alphanumeric)
.collect::<String>();
format!("org.rustferry.{suffix}")
},
ToOwned::to_owned,
);
validate_application_identifier(&application_identifier)?;
Ok(ProjectNames {
directory_name: requested_name.to_owned(),
crate_name,
display_name,
application_identifier,
})
}
fn validate_project_name(name: &str) -> Result<(), NamingError> {
if name.is_empty() {
return Err(invalid_name("the name is empty"));
}
if name.trim() != name {
return Err(invalid_name(
"leading or trailing whitespace is not allowed",
));
}
if matches!(name, "." | "..") {
return Err(invalid_name("`.` and `..` are not project names"));
}
if name.contains(['/', '\\']) {
return Err(invalid_name("path separators are not allowed"));
}
if name.chars().any(char::is_control) {
return Err(invalid_name("control characters are not allowed"));
}
if name.contains(['<', '>', ':', '"', '|', '?', '*']) || name.ends_with('.') {
return Err(invalid_name(
"the name contains characters that are unsafe on supported filesystems",
));
}
let windows_stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
if matches!(windows_stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| windows_stem
.strip_prefix("COM")
.or_else(|| windows_stem.strip_prefix("LPT"))
.is_some_and(|number| {
matches!(number, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
})
{
return Err(invalid_name("the name is reserved by Windows"));
}
Ok(())
}
fn invalid_name(reason: &str) -> NamingError {
NamingError::InvalidProjectName {
reason: reason.to_owned(),
}
}
fn crate_name(name: &str) -> String {
let mut output = String::with_capacity(name.len());
let mut separator = false;
for character in name.chars() {
if character.is_ascii_alphanumeric() {
if separator && !output.is_empty() {
output.push('-');
}
separator = false;
output.push(character.to_ascii_lowercase());
} else {
separator = true;
}
}
while output.ends_with('-') {
output.pop();
}
if output.is_empty() {
let digest = Sha256::digest(name.as_bytes());
output = format!("app-{}", hex::encode(&digest[..4]));
}
if output.as_bytes()[0].is_ascii_digit() {
output.insert_str(0, "app-");
}
if rust_keyword(&output) {
output.insert_str(0, "app-");
}
output
}
fn rust_keyword(value: &str) -> bool {
matches!(
value,
"as" | "break"
| "const"
| "continue"
| "crate"
| "else"
| "enum"
| "extern"
| "false"
| "fn"
| "for"
| "if"
| "impl"
| "in"
| "let"
| "loop"
| "match"
| "mod"
| "move"
| "mut"
| "pub"
| "ref"
| "return"
| "self"
| "Self"
| "static"
| "struct"
| "super"
| "trait"
| "true"
| "type"
| "unsafe"
| "use"
| "where"
| "while"
| "async"
| "await"
| "dyn"
| "abstract"
| "become"
| "box"
| "do"
| "final"
| "macro"
| "override"
| "priv"
| "typeof"
| "unsized"
| "virtual"
| "yield"
| "try"
| "gen"
)
}
fn display_name(name: &str) -> String {
let mut words = name
.split(['-', '_'])
.filter(|word| !word.is_empty())
.map(|word| {
let mut characters = word.chars();
characters.next().map_or_else(String::new, |first| {
first.to_uppercase().collect::<String>() + characters.as_str()
})
})
.collect::<Vec<_>>();
if words.is_empty() {
name.to_owned()
} else {
words.retain(|word| !word.is_empty());
words.join(" ")
}
}
pub fn validate_application_identifier(identifier: &str) -> Result<(), NamingError> {
if identifier.len() > 255 {
return Err(invalid_identifier(identifier, "it exceeds 255 bytes"));
}
let segments = identifier.split('.').collect::<Vec<_>>();
if segments.len() < 3 {
return Err(invalid_identifier(
identifier,
"use at least three dot-separated segments, for example `com.example.app`",
));
}
for segment in segments {
let mut characters = segment.chars();
let Some(first) = characters.next() else {
return Err(invalid_identifier(
identifier,
"empty segments are not allowed",
));
};
if !first.is_ascii_lowercase() {
return Err(invalid_identifier(
identifier,
"each segment must start with a lowercase ASCII letter",
));
}
if !characters.all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
{
return Err(invalid_identifier(
identifier,
"segments may contain only lowercase ASCII letters and digits",
));
}
}
Ok(())
}
fn invalid_identifier(identifier: &str, reason: &str) -> NamingError {
NamingError::InvalidApplicationIdentifier {
identifier: identifier.to_owned(),
reason: reason.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derives_safe_names() {
let names = derive_project_names("Weather App", None).unwrap();
assert_eq!(names.crate_name, "weather-app");
assert_eq!(names.display_name, "Weather App");
assert_eq!(names.application_identifier, "org.rustferry.weatherapp");
}
#[test]
fn unicode_names_are_deterministic() {
let first = derive_project_names("Погода", None).unwrap();
let second = derive_project_names("Погода", None).unwrap();
assert_eq!(first.crate_name, second.crate_name);
assert!(first.crate_name.starts_with("app-"));
}
#[test]
fn rejects_path_traversal() {
assert!(derive_project_names("../weather", None).is_err());
assert!(derive_project_names("..", None).is_err());
}
#[test]
fn rejects_cross_platform_reserved_names() {
assert!(derive_project_names("NUL", None).is_err());
assert!(derive_project_names("weather?", None).is_err());
assert_eq!(
derive_project_names("fn", None).unwrap().crate_name,
"app-fn"
);
}
#[test]
fn rejects_unsafe_identifier() {
assert!(validate_application_identifier("com.Example.weather").is_err());
assert!(validate_application_identifier("com.example.weather_app").is_err());
assert!(validate_application_identifier("com.example.weather-app").is_err());
assert!(validate_application_identifier("example").is_err());
}
}