pub mod timestamp;
use std::borrow::Borrow;
use std::ffi::OsStr;
use std::fmt::{self, Display};
use std::mem;
use std::ops::Deref;
use std::path::Path;
use paste::paste;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(target_family = "windows")]
pub use os::ForbiddenOnWindows;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Eq, PartialEq, Ord, PartialOrd, Hash)] #[derive(derive_more::Display)]
#[serde(try_from = "String", into = "String")]
pub struct Slug(Box<str>);
#[derive(Debug, Serialize)] #[derive(Eq, PartialEq, Ord, PartialOrd, Hash)] #[derive(derive_more::Display)]
#[serde(transparent)]
#[repr(transparent)] pub struct SlugRef(str);
pub const SLUG_SEPARATOR_CHARS: &str = "/+.";
#[derive(Error, Debug, Clone, Eq, PartialEq, Hash)]
#[non_exhaustive]
pub enum BadSlug {
BadCharacter(char),
BadFirstCharacter(char),
EmptySlugNotAllowed,
#[cfg(target_family = "windows")]
ForbiddenOnWindows(ForbiddenOnWindows),
}
pub trait TryIntoSlug {
fn try_into_slug(&self) -> Result<Slug, BadSlug>;
}
impl<T: ToString + ?Sized> TryIntoSlug for T {
fn try_into_slug(&self) -> Result<Slug, BadSlug> {
self.to_string().try_into()
}
}
impl Slug {
pub fn new(s: String) -> Result<Slug, BadSlug> {
Ok(unsafe {
check_syntax(&s)?;
Slug::new_unchecked(s)
})
}
pub unsafe fn new_unchecked(s: String) -> Slug {
Slug(s.into())
}
}
impl SlugRef {
pub fn new(s: &str) -> Result<&SlugRef, BadSlug> {
Ok(unsafe {
check_syntax(s)?;
SlugRef::new_unchecked(s)
})
}
pub unsafe fn new_unchecked<'s>(s: &'s str) -> &'s SlugRef {
unsafe {
mem::transmute::<&'s str, &'s SlugRef>(s)
}
}
fn to_slug(&self) -> Slug {
unsafe {
Slug::new_unchecked(self.0.into())
}
}
}
impl TryFrom<String> for Slug {
type Error = BadSlug;
fn try_from(s: String) -> Result<Slug, BadSlug> {
Slug::new(s)
}
}
impl From<Slug> for String {
fn from(s: Slug) -> String {
s.0.into()
}
}
impl<'s> TryFrom<&'s str> for &'s SlugRef {
type Error = BadSlug;
fn try_from(s: &'s str) -> Result<&'s SlugRef, BadSlug> {
SlugRef::new(s)
}
}
impl Deref for Slug {
type Target = SlugRef;
fn deref(&self) -> &SlugRef {
unsafe {
SlugRef::new_unchecked(&self.0)
}
}
}
impl Borrow<SlugRef> for Slug {
fn borrow(&self) -> &SlugRef {
self
}
}
impl Borrow<str> for Slug {
fn borrow(&self) -> &str {
self.as_ref()
}
}
impl ToOwned for SlugRef {
type Owned = Slug;
fn to_owned(&self) -> Slug {
self.to_slug()
}
}
macro_rules! impl_as_with_inherent { { $ty:ident } => { paste!{
impl SlugRef {
#[doc = concat!("Obtain this slug as a `", stringify!($ty), "`")]
pub fn [<as_ $ty:snake>](&self) -> &$ty {
self.as_ref()
}
}
impl_as_ref!($ty);
} } }
macro_rules! impl_as_ref { { $ty:ty } => { paste!{
impl AsRef<$ty> for SlugRef {
fn as_ref(&self) -> &$ty {
self.0.as_ref()
}
}
impl AsRef<$ty> for Slug {
fn as_ref(&self) -> &$ty {
self.deref().as_ref()
}
}
} } }
impl_as_with_inherent!(str);
impl_as_with_inherent!(Path);
impl_as_ref!(OsStr);
impl_as_ref!([u8]);
#[allow(clippy::if_same_then_else)] pub fn check_syntax(s: &str) -> Result<(), BadSlug> {
if s.is_empty() {
return Err(BadSlug::EmptySlugNotAllowed);
}
if s.starts_with('-') {
return Err(BadSlug::BadFirstCharacter('-'));
}
for c in s.chars() {
if c.is_ascii_lowercase() {
Ok(())
} else if c.is_ascii_digit() {
Ok(())
} else if c == '_' || c == '-' {
Ok(())
} else {
Err(BadSlug::BadCharacter(c))
}?;
}
os::check_forbidden(s)?;
Ok(())
}
impl Display for BadSlug {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
BadSlug::BadCharacter(c) => {
let num = u32::from(*c);
write!(f, "character {c:?} (U+{num:04X}) is not allowed")
}
BadSlug::BadFirstCharacter(c) => {
let num = u32::from(*c);
write!(
f,
"character {c:?} (U+{num:04X}) is not allowed as the first character"
)
}
BadSlug::EmptySlugNotAllowed => {
write!(f, "empty identifier (empty slug) not allowed")
}
#[cfg(target_family = "windows")]
BadSlug::ForbiddenOnWindows(e) => os::fmt_error(e, f),
}
}
}
#[cfg(target_family = "windows")]
mod os {
use super::*;
pub type ForbiddenOnWindows = &'static &'static str;
const FORBIDDEN: &[&str] = &[
"con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "com0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", "lpt0",
];
pub(super) fn check_forbidden(s: &str) -> Result<(), BadSlug> {
for bad in FORBIDDEN {
if s == *bad {
return Err(BadSlug::ForbiddenOnWindows(bad));
}
}
Ok(())
}
pub(super) fn fmt_error(s: &ForbiddenOnWindows, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "slug (name) {s:?} is not allowed on Windows")
}
}
#[cfg(not(target_family = "windows"))]
mod os {
use super::*;
#[allow(clippy::unnecessary_wraps)]
pub(super) fn check_forbidden(_s: &str) -> Result<(), BadSlug> {
Ok(())
}
}
#[cfg(test)]
mod test {
#![allow(clippy::bool_assert_comparison)]
#![allow(clippy::clone_on_copy)]
#![allow(clippy::dbg_macro)]
#![allow(clippy::mixed_attributes_style)]
#![allow(clippy::print_stderr)]
#![allow(clippy::print_stdout)]
#![allow(clippy::single_char_pattern)]
#![allow(clippy::unwrap_used)]
#![allow(clippy::unchecked_time_subtraction)]
#![allow(clippy::useless_vec)]
#![allow(clippy::needless_pass_by_value)]
use super::*;
use itertools::chain;
#[test]
fn bad() {
for c in chain!(
SLUG_SEPARATOR_CHARS.chars(), ['\\', ' ', '\n', '\0']
) {
let s = format!("x{c}y");
let e_ref = SlugRef::new(&s).unwrap_err();
assert_eq!(e_ref, BadSlug::BadCharacter(c));
let e_own = Slug::new(s).unwrap_err();
assert_eq!(e_ref, e_own);
}
}
#[test]
fn good() {
let all = chain!(
b'a'..=b'z', b'0'..=b'9',
[b'_'],
)
.map(char::from);
let chk = |s: String| {
let sref = SlugRef::new(&s).unwrap();
let slug = Slug::new(s.clone()).unwrap();
assert_eq!(sref.to_string(), s);
assert_eq!(slug.to_string(), s);
};
chk(all.clone().collect());
for c in all {
chk(format!("{c}"));
}
chk("a-".into());
chk("a-b".into());
}
#[test]
fn badchar_msg() {
let chk = |s: &str, m: &str| {
assert_eq!(
SlugRef::new(s).unwrap_err().to_string(),
m, );
};
chk(".", "character '.' (U+002E) is not allowed");
chk("\0", "character '\\0' (U+0000) is not allowed");
chk(
"\u{12345}",
"character '\u{12345}' (U+12345) is not allowed",
);
chk(
"-",
"character '-' (U+002D) is not allowed as the first character",
);
chk("A", "character 'A' (U+0041) is not allowed");
}
#[test]
fn windows_forbidden() {
for s in ["con", "prn", "lpt0"] {
let r = SlugRef::new(s);
if cfg!(target_family = "windows") {
assert_eq!(
r.unwrap_err().to_string(),
format!("slug (name) \"{s}\" is not allowed on Windows"),
);
} else {
assert_eq!(r.unwrap().as_str(), s);
}
}
}
#[test]
fn empty_slug() {
assert_eq!(
SlugRef::new("").unwrap_err().to_string(),
"empty identifier (empty slug) not allowed"
);
}
}