use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::errors::NameError;
const MAX_PROJECT_NAME_LEN: usize = 64;
const MAX_SERIES_NAME_LEN: usize = 64;
const MAX_TAG_NAME_LEN: usize = 128;
const MAX_TAG_GLOB_LEN: usize = 128;
const MAX_MODULE_NAME_LEN: usize = 256;
const MAX_FUNCTION_NAME_LEN: usize = 256;
const MAX_TYPE_NAME_LEN: usize = 256;
const MAX_RECORD_NAME_LEN: usize = 256;
const MAX_FIELD_NAME_LEN: usize = 256;
const MAX_APPLICATION_NAME_LEN: usize = 128;
const MAX_BEHAVIOUR_NAME_LEN: usize = 256;
const MAX_MACRO_NAME_LEN: usize = 256;
const MAX_DEPENDENCY_NAME_LEN: usize = 128;
const MAX_DEPENDENCY_VERSION_LEN: usize = 128;
const APPLICATION_PATTERN: &str = "[a-z][a-z0-9_]{0,127}";
const PROJECT_PATTERN: &str = "[a-z][a-z0-9_-]{0,63}";
const SERIES_PATTERN: &str = "[a-z][a-z0-9_.-]{0,63}";
fn is_project_name_char(c: char) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-'
}
fn is_series_name_char(c: char) -> bool {
is_project_name_char(c) || c == '.'
}
fn is_application_name_char(c: char) -> bool {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'
}
fn validate_simple_name(
kind: &'static str,
value: &str,
max_len: usize,
first_char_ok: fn(char) -> bool,
rest_char_ok: fn(char) -> bool,
pattern: &'static str,
) -> Result<(), NameError> {
if value.is_empty() {
return Err(NameError::Empty { kind });
}
if value.len() > max_len {
return Err(NameError::TooLong {
kind,
len: value.len(),
max: max_len,
});
}
let mut chars = value.chars();
let first = chars.next().expect("non-empty");
if !first_char_ok(first) {
return Err(NameError::PatternMismatch {
kind,
value: value.to_owned(),
pattern,
});
}
for ch in chars {
if !rest_char_ok(ch) {
return Err(NameError::InvalidCharacter {
kind,
ch,
value: value.to_owned(),
});
}
}
Ok(())
}
fn validate_tag_glob(value: &str) -> Result<(), NameError> {
if value.is_empty() {
return Err(NameError::Empty { kind: "tag glob" });
}
if value.len() > MAX_TAG_GLOB_LEN {
return Err(NameError::TooLong {
kind: "tag glob",
len: value.len(),
max: MAX_TAG_GLOB_LEN,
});
}
for ch in value.chars() {
if ch.is_ascii_control()
|| ch.is_whitespace()
|| ch == '/'
|| ch == '\\'
|| ch == '\0'
|| ch == ':'
|| ch == '~'
|| ch == '^'
|| ch == '['
{
return Err(NameError::InvalidCharacter {
kind: "tag glob",
ch,
value: value.to_owned(),
});
}
}
if value == "." || value == ".." || value.starts_with('.') || value.ends_with('.') {
return Err(NameError::PatternMismatch {
kind: "tag glob",
value: value.to_owned(),
pattern: "non-empty, no path separators or git ref-magic",
});
}
Ok(())
}
fn validate_tag_name(value: &str) -> Result<(), NameError> {
if value.is_empty() {
return Err(NameError::Empty { kind: "tag" });
}
if value.len() > MAX_TAG_NAME_LEN {
return Err(NameError::TooLong {
kind: "tag",
len: value.len(),
max: MAX_TAG_NAME_LEN,
});
}
for ch in value.chars() {
if ch.is_ascii_control()
|| ch.is_whitespace()
|| ch == '/'
|| ch == '\\'
|| ch == '\0'
|| ch == ':'
|| ch == '~'
|| ch == '^'
|| ch == '?'
|| ch == '*'
|| ch == '['
{
return Err(NameError::InvalidCharacter {
kind: "tag",
ch,
value: value.to_owned(),
});
}
}
if value == "." || value == ".." || value.starts_with('.') || value.ends_with('.') {
return Err(NameError::PatternMismatch {
kind: "tag",
value: value.to_owned(),
pattern: "non-empty, no path separators or git ref-magic",
});
}
Ok(())
}
fn validate_erlang_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
if value.is_empty() {
return Err(NameError::Empty { kind });
}
if value.len() > max_len {
return Err(NameError::TooLong {
kind,
len: value.len(),
max: max_len,
});
}
if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
return Ok(());
}
let first = value.chars().next().expect("non-empty");
if !(first.is_ascii_lowercase() || first == '_') {
return Err(NameError::PatternMismatch {
kind,
value: value.to_owned(),
pattern: "lowercase-led atom or single-quoted",
});
}
let last = value.chars().last().expect("non-empty");
let drop_trailing_punct = if matches!(last, '?' | '!') {
value.len() - last.len_utf8()
} else {
value.len()
};
for ch in value[..drop_trailing_punct].chars() {
if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '@') {
return Err(NameError::InvalidCharacter {
kind,
ch,
value: value.to_owned(),
});
}
}
Ok(())
}
fn validate_module_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
if value.is_empty() {
return Err(NameError::Empty { kind });
}
if value.len() > max_len {
return Err(NameError::TooLong {
kind,
len: value.len(),
max: max_len,
});
}
if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
return Ok(());
}
let first = value.chars().next().expect("non-empty");
if !(first.is_ascii_alphabetic() || first == '_') {
return Err(NameError::PatternMismatch {
kind,
value: value.to_owned(),
pattern: "Erlang atom or Elixir module name",
});
}
for ch in value.chars() {
if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '@' || ch == '.') {
return Err(NameError::InvalidCharacter {
kind,
ch,
value: value.to_owned(),
});
}
}
Ok(())
}
macro_rules! string_newtype {
($name:ident, $validate:expr) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(transparent)]
pub struct $name(Arc<str>);
impl $name {
pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
let v = value.into();
$validate(&v)?;
Ok(Self(Arc::from(v)))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_inner(self) -> String {
self.0.to_string()
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for $name {
type Err = NameError;
fn from_str(s: &str) -> Result<Self, NameError> {
Self::new(s.to_owned())
}
}
};
}
string_newtype!(ProjectName, |v: &str| {
validate_simple_name(
"project name",
v,
MAX_PROJECT_NAME_LEN,
|c| c.is_ascii_lowercase(),
is_project_name_char,
PROJECT_PATTERN,
)
});
string_newtype!(SeriesName, |v: &str| {
validate_simple_name(
"series name",
v,
MAX_SERIES_NAME_LEN,
|c| c.is_ascii_lowercase(),
is_series_name_char,
SERIES_PATTERN,
)
});
string_newtype!(TagName, validate_tag_name);
const MAX_GIT_REF_LEN: usize = 256;
string_newtype!(GitRef, validate_git_ref);
fn validate_git_ref(value: &str) -> Result<(), NameError> {
if value.is_empty() {
return Err(NameError::Empty { kind: "git ref" });
}
if value.len() > MAX_GIT_REF_LEN {
return Err(NameError::TooLong {
kind: "git ref",
len: value.len(),
max: MAX_GIT_REF_LEN,
});
}
for ch in value.chars() {
if ch.is_ascii_control() || ch.is_whitespace() || ch == '\0' || ch == ':' {
return Err(NameError::InvalidCharacter {
kind: "git ref",
ch,
value: value.to_owned(),
});
}
}
Ok(())
}
string_newtype!(TagGlob, validate_tag_glob);
impl TagGlob {
pub fn matches(&self, tag: &TagName) -> bool {
glob_match(self.as_str(), tag.as_str())
}
}
fn glob_match(pattern: &str, text: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let text: Vec<char> = text.chars().collect();
let mut pi = 0usize;
let mut ti = 0usize;
let mut star_p: Option<usize> = None;
let mut star_t: Option<usize> = None;
while ti < text.len() {
if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
pi += 1;
ti += 1;
} else if pi < pattern.len() && pattern[pi] == '*' {
star_p = Some(pi);
star_t = Some(ti);
pi += 1;
} else if let (Some(sp), Some(st)) = (star_p, star_t) {
pi = sp + 1;
star_t = Some(st + 1);
ti = st + 1;
} else {
return false;
}
}
while pi < pattern.len() && pattern[pi] == '*' {
pi += 1;
}
pi == pattern.len()
}
string_newtype!(ModuleName, |v: &str| {
validate_module_name("module name", v, MAX_MODULE_NAME_LEN)
});
string_newtype!(FunctionName, |v: &str| {
validate_erlang_name("function name", v, MAX_FUNCTION_NAME_LEN)
});
string_newtype!(TypeName, |v: &str| {
validate_erlang_name("type name", v, MAX_TYPE_NAME_LEN)
});
string_newtype!(RecordName, |v: &str| {
validate_erlang_name("record name", v, MAX_RECORD_NAME_LEN)
});
string_newtype!(FieldName, |v: &str| {
validate_erlang_name("field name", v, MAX_FIELD_NAME_LEN)
});
string_newtype!(ApplicationName, |v: &str| {
validate_simple_name(
"application name",
v,
MAX_APPLICATION_NAME_LEN,
|c| c.is_ascii_lowercase(),
is_application_name_char,
APPLICATION_PATTERN,
)
});
string_newtype!(BehaviourName, |v: &str| {
validate_module_name("behaviour name", v, MAX_BEHAVIOUR_NAME_LEN)
});
string_newtype!(MacroName, |v: &str| {
validate_macro_name("macro name", v, MAX_MACRO_NAME_LEN)
});
string_newtype!(DependencyName, |v: &str| {
validate_dependency_name("dependency name", v, MAX_DEPENDENCY_NAME_LEN)
});
string_newtype!(DependencyVersion, |v: &str| {
validate_dependency_version("dependency version", v, MAX_DEPENDENCY_VERSION_LEN)
});
fn validate_macro_name(kind: &'static str, value: &str, max_len: usize) -> Result<(), NameError> {
validate_simple_name(
kind,
value,
max_len,
|c| c.is_ascii_alphabetic() || c == '_',
|c| c.is_ascii_alphanumeric() || c == '_',
"letter or underscore, then letters digits underscores",
)
}
fn validate_dependency_name(
kind: &'static str,
value: &str,
max_len: usize,
) -> Result<(), NameError> {
validate_simple_name(
kind,
value,
max_len,
|c| c.is_ascii_lowercase(),
is_application_name_char,
"lowercase letter, then lowercase digits underscores",
)
}
fn validate_dependency_version(
kind: &'static str,
value: &str,
max_len: usize,
) -> Result<(), NameError> {
if value.is_empty() {
return Err(NameError::Empty { kind });
}
if value.len() > max_len {
return Err(NameError::TooLong {
kind,
len: value.len(),
max: max_len,
});
}
for ch in value.chars() {
if ch.is_ascii_control() || ch == '\0' || ch.is_whitespace() {
return Err(NameError::InvalidCharacter {
kind,
ch,
value: value.to_owned(),
});
}
}
Ok(())
}
const MAX_RELATIVE_PATH_LEN: usize = 4096;
string_newtype!(RelativePath, validate_relative_path);
fn validate_relative_path(value: &str) -> Result<(), NameError> {
if value.is_empty() {
return Err(NameError::Empty {
kind: "relative path",
});
}
if value.len() > MAX_RELATIVE_PATH_LEN {
return Err(NameError::TooLong {
kind: "relative path",
len: value.len(),
max: MAX_RELATIVE_PATH_LEN,
});
}
if value.starts_with('/') {
return Err(NameError::Invalid {
kind: "relative path",
value: value.to_owned(),
reason: "absolute paths are not allowed",
});
}
if value.split('/').any(|seg| seg == "..") {
return Err(NameError::Invalid {
kind: "relative path",
value: value.to_owned(),
reason: "parent-directory segments are not allowed",
});
}
if value.contains('\0') {
return Err(NameError::Invalid {
kind: "relative path",
value: value.to_owned(),
reason: "NUL bytes are not allowed",
});
}
Ok(())
}
impl RelativePath {
pub fn from_bytes(bytes: &[u8]) -> Result<Self, NameError> {
let s = std::str::from_utf8(bytes).map_err(|_| NameError::Invalid {
kind: "relative path",
value: String::from_utf8_lossy(bytes).into_owned(),
reason: "path is not valid UTF-8",
})?;
Self::new(s.to_owned())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(transparent)]
pub struct Arity(u8);
impl Arity {
pub const fn new(value: u8) -> Self {
Self(value)
}
pub const fn get(self) -> u8 {
self.0
}
}
impl fmt::Display for Arity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for Arity {
type Err = NameError;
fn from_str(s: &str) -> Result<Self, NameError> {
let parsed: i64 = s
.parse()
.map_err(|_| NameError::InvalidArityParse { raw: s.to_owned() })?;
if !(0..=255).contains(&parsed) {
return Err(NameError::InvalidArity { value: parsed });
}
Ok(Self(parsed as u8))
}
}
impl TryFrom<usize> for Arity {
type Error = NameError;
fn try_from(value: usize) -> Result<Self, NameError> {
u8::try_from(value)
.map(Self)
.map_err(|_| NameError::InvalidArity {
value: i64::try_from(value).unwrap_or(i64::MAX),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(transparent)]
pub struct CommitSha(Arc<str>);
impl CommitSha {
pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
let v = value.into();
if v.len() != 40
|| !v
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
{
return Err(NameError::InvalidCommitSha { value: v });
}
Ok(Self(Arc::from(v)))
}
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn abbreviated(&self) -> &str {
&self.0[..12]
}
}
impl fmt::Display for CommitSha {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for CommitSha {
type Err = NameError;
fn from_str(s: &str) -> Result<Self, NameError> {
Self::new(s.to_owned())
}
}
impl AsRef<str> for CommitSha {
fn as_ref(&self) -> &str {
&self.0
}
}
const MIN_COMMIT_SHA_PREFIX_LEN: usize = 7;
const MAX_COMMIT_SHA_PREFIX_LEN: usize = 40;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(try_from = "String", into = "String")]
pub struct CommitShaPrefix(String);
impl TryFrom<String> for CommitShaPrefix {
type Error = NameError;
fn try_from(value: String) -> Result<Self, NameError> {
Self::new(value)
}
}
impl From<CommitShaPrefix> for String {
fn from(p: CommitShaPrefix) -> Self {
p.0
}
}
impl CommitShaPrefix {
pub fn new(value: impl Into<String>) -> Result<Self, NameError> {
let raw = value.into();
if raw.is_empty() {
return Err(NameError::EmptyCommitShaPrefix);
}
let normalised = raw.to_ascii_lowercase();
if let Some((position, ch)) = normalised
.chars()
.enumerate()
.find(|(_, c)| !c.is_ascii_hexdigit())
{
return Err(NameError::CommitShaPrefixNonHex {
value: normalised,
ch,
position,
});
}
let len = normalised.len();
if len < MIN_COMMIT_SHA_PREFIX_LEN {
return Err(NameError::CommitShaPrefixTooShort {
value: normalised,
len,
});
}
if len > MAX_COMMIT_SHA_PREFIX_LEN {
return Err(NameError::CommitShaPrefixTooLong {
value: normalised,
len,
});
}
Ok(Self(normalised))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn is_full(&self) -> bool {
self.0.len() == MAX_COMMIT_SHA_PREFIX_LEN
}
#[must_use]
pub fn as_full_sha(&self) -> Option<CommitSha> {
if self.is_full() {
CommitSha::new(self.0.clone()).ok()
} else {
None
}
}
}
impl fmt::Display for CommitShaPrefix {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for CommitShaPrefix {
type Err = NameError;
fn from_str(s: &str) -> Result<Self, NameError> {
Self::new(s.to_owned())
}
}
impl AsRef<str> for CommitShaPrefix {
fn as_ref(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Mfa {
pub module: ModuleName,
pub function: FunctionName,
pub arity: Arity,
}
impl Mfa {
pub fn new(module: ModuleName, function: FunctionName, arity: Arity) -> Self {
Self {
module,
function,
arity,
}
}
}
impl fmt::Display for Mfa {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}/{}", self.module, self.function, self.arity)
}
}
impl FromStr for Mfa {
type Err = NameError;
fn from_str(s: &str) -> Result<Self, NameError> {
let (head, arity_part) = s.rsplit_once('/').ok_or_else(|| NameError::InvalidMfa {
value: s.to_owned(),
})?;
let (module_part, function_part) =
head.split_once(':').ok_or_else(|| NameError::InvalidMfa {
value: s.to_owned(),
})?;
let arity = arity_part
.parse::<Arity>()
.map_err(|_| NameError::InvalidMfa {
value: s.to_owned(),
})?;
Ok(Self {
module: module_part.parse()?,
function: function_part.parse()?,
arity,
})
}
}