#[cfg(all(not(feature = "std"), feature = "alloc"))]
use alloc::{borrow::ToOwned, string::String};
use core::{fmt, ops::Deref};
#[cfg(feature = "serde")]
use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum SlugError {
#[error("slug must not be empty")]
Empty,
#[error("slug must not exceed 128 characters")]
TooLong,
#[error("slug may only contain lowercase ASCII letters, digits, and hyphens")]
InvalidChars,
#[error("slug must not start with a hyphen")]
LeadingHyphen,
#[error("slug must not end with a hyphen")]
TrailingHyphen,
#[error("slug must not contain consecutive hyphens")]
ConsecutiveHyphens,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize))]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Slug(String);
impl Slug {
pub fn new(s: impl AsRef<str>) -> Result<Self, SlugError> {
let s = s.as_ref();
if s.is_empty() {
return Err(SlugError::Empty);
}
if s.len() > 128 {
return Err(SlugError::TooLong);
}
if !s
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(SlugError::InvalidChars);
}
if s.starts_with('-') {
return Err(SlugError::LeadingHyphen);
}
if s.ends_with('-') {
return Err(SlugError::TrailingHyphen);
}
if s.contains("--") {
return Err(SlugError::ConsecutiveHyphens);
}
Ok(Self(s.to_owned()))
}
#[must_use]
pub fn from_title(s: impl AsRef<str>) -> Self {
let lowered = s.as_ref().to_lowercase();
let replaced: String = lowered
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
let mut collapsed = String::with_capacity(replaced.len());
let mut prev_hyphen = false;
for c in replaced.chars() {
if c == '-' {
if !prev_hyphen {
collapsed.push(c);
}
prev_hyphen = true;
} else {
collapsed.push(c);
prev_hyphen = false;
}
}
let trimmed = collapsed.trim_matches('-');
let truncated: String = trimmed.chars().take(128).collect();
let final_str = truncated.trim_matches('-');
if final_str.is_empty() {
Self("untitled".to_owned())
} else {
Self(final_str.to_owned())
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn into_string(self) -> String {
self.0
}
}
impl Deref for Slug {
type Target = str;
fn deref(&self) -> &str {
&self.0
}
}
impl AsRef<str> for Slug {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for Slug {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for Slug {
type Error = SlugError;
fn try_from(s: String) -> Result<Self, Self::Error> {
Self::new(s)
}
}
impl TryFrom<&str> for Slug {
type Error = SlugError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::new(s)
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for Slug {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::new(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(feature = "arbitrary")]
impl<'a> arbitrary::Arbitrary<'a> for Slug {
fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let len = u.int_in_range(1usize..=20)?;
let inner: String = (0..len)
.map(|_| -> arbitrary::Result<char> {
let idx = u.int_in_range(0..=(CHARSET.len() - 1))?;
Ok(CHARSET[idx] as char)
})
.collect::<arbitrary::Result<_>>()?;
let segments = u.int_in_range(1usize..=3)?;
if segments == 1 || inner.len() < 2 {
Ok(Self(inner))
} else {
let step = inner.len() / segments;
let joined: String = inner
.as_bytes()
.chunks(step.max(1))
.map(|c| std::str::from_utf8(c).unwrap_or("a"))
.collect::<Vec<_>>()
.join("-");
Ok(Self(joined.trim_matches('-').to_owned()))
}
}
}
#[cfg(feature = "proptest")]
pub mod proptest_strategies {
use super::Slug;
use proptest::prelude::*;
pub fn slug_strategy() -> impl Strategy<Value = Slug> {
prop::collection::vec("[a-z0-9]{1,20}", 1..=4).prop_map(|segs| {
let s = segs.join("-");
Slug::new(s).expect("generated slug must be valid")
})
}
}
#[cfg(feature = "proptest")]
impl proptest::arbitrary::Arbitrary for Slug {
type Parameters = ();
type Strategy = proptest::strategy::BoxedStrategy<Self>;
fn arbitrary_with((): Self::Parameters) -> Self::Strategy {
use proptest::prelude::*;
proptest_strategies::slug_strategy().boxed()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_slugs_are_accepted() {
for s in ["a", "hello", "hello-world", "abc-123", "a1b2c3", "x"] {
assert!(Slug::new(s).is_ok(), "expected {s:?} to be valid");
}
}
#[test]
fn empty_string_is_rejected() {
assert_eq!(Slug::new(""), Err(SlugError::Empty));
}
#[test]
fn too_long_string_is_rejected() {
let s: String = "a".repeat(129);
assert_eq!(Slug::new(&s), Err(SlugError::TooLong));
}
#[test]
fn exactly_128_chars_is_accepted() {
let s: String = "a".repeat(128);
assert!(Slug::new(&s).is_ok());
}
#[test]
fn uppercase_chars_are_rejected() {
assert_eq!(Slug::new("Hello"), Err(SlugError::InvalidChars));
}
#[test]
fn special_chars_are_rejected() {
for s in ["hello_world", "hello world", "héllo", "hello.world"] {
assert_eq!(
Slug::new(s),
Err(SlugError::InvalidChars),
"expected {s:?} to be invalid"
);
}
}
#[test]
fn leading_hyphen_is_rejected() {
assert_eq!(Slug::new("-hello"), Err(SlugError::LeadingHyphen));
}
#[test]
fn trailing_hyphen_is_rejected() {
assert_eq!(Slug::new("hello-"), Err(SlugError::TrailingHyphen));
}
#[test]
fn consecutive_hyphens_are_rejected() {
assert_eq!(
Slug::new("hello--world"),
Err(SlugError::ConsecutiveHyphens)
);
}
#[test]
fn from_title_basic() {
let slug = Slug::from_title("Hello World");
assert_eq!(slug.as_str(), "hello-world");
}
#[test]
fn from_title_strips_special_chars() {
let slug = Slug::from_title("Hello, World! 2024");
assert_eq!(slug.as_str(), "hello-world-2024");
}
#[test]
fn from_title_collapses_multiple_spaces() {
let slug = Slug::from_title("Hello World");
assert_eq!(slug.as_str(), "hello-world");
}
#[test]
fn from_title_strips_leading_trailing_separators() {
let slug = Slug::from_title(" Hello World ");
assert_eq!(slug.as_str(), "hello-world");
}
#[test]
fn from_title_all_non_alnum_returns_untitled() {
let slug = Slug::from_title("!!! ???");
assert_eq!(slug.as_str(), "untitled");
}
#[test]
fn from_title_empty_returns_untitled() {
let slug = Slug::from_title("");
assert_eq!(slug.as_str(), "untitled");
}
#[test]
fn from_title_truncates_to_128() {
let long: String = "a ".repeat(200);
let slug = Slug::from_title(&long);
assert!(slug.as_str().len() <= 128);
assert!(Slug::new(slug.as_str()).is_ok());
}
#[test]
fn deref_to_str() {
let slug = Slug::new("hello").unwrap();
let s: &str = &slug;
assert_eq!(s, "hello");
}
#[test]
fn display() {
let slug = Slug::new("hello-world").unwrap();
assert_eq!(format!("{slug}"), "hello-world");
}
#[test]
fn as_ref_str() {
let slug = Slug::new("hello").unwrap();
let s: &str = slug.as_ref();
assert_eq!(s, "hello");
}
#[test]
fn try_from_string() {
let slug = Slug::try_from("hello".to_owned()).unwrap();
assert_eq!(slug.as_str(), "hello");
}
#[test]
fn try_from_str_ref() {
let slug = Slug::try_from("world").unwrap();
assert_eq!(slug.as_str(), "world");
}
#[test]
fn into_string() {
let slug = Slug::new("hello").unwrap();
assert_eq!(slug.into_string(), "hello".to_owned());
}
#[cfg(feature = "serde")]
#[test]
fn serde_roundtrip() {
let slug = Slug::new("hello-world").unwrap();
let json = serde_json::to_string(&slug).unwrap();
assert_eq!(json, r#""hello-world""#);
let back: Slug = serde_json::from_str(&json).unwrap();
assert_eq!(back, slug);
}
#[cfg(feature = "serde")]
#[test]
fn serde_deserialize_invalid_rejects() {
let result: Result<Slug, _> = serde_json::from_str(r#""Hello-World""#);
assert!(result.is_err());
}
#[cfg(feature = "arbitrary")]
mod arbitrary_tests {
use super::super::Slug;
use arbitrary::{Arbitrary, Unstructured};
#[test]
fn arbitrary_generates_valid_slugs() {
let raw: Vec<u8> = (0u8..=255).cycle().take(1024).collect();
let mut u = Unstructured::new(&raw);
for _ in 0..50 {
if let Ok(slug) = Slug::arbitrary(&mut u) {
assert!(
Slug::new(slug.as_str()).is_ok(),
"arbitrary produced invalid slug: {slug:?}"
);
}
}
}
}
#[cfg(feature = "proptest")]
mod proptest_tests {
use super::super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn arbitrary_with_generates_valid_slugs(slug in <Slug as proptest::arbitrary::Arbitrary>::arbitrary_with(())) {
prop_assert!(Slug::new(slug.as_str()).is_ok());
}
#[test]
fn generated_slugs_are_always_valid(slug in proptest_strategies::slug_strategy()) {
prop_assert!(Slug::new(slug.as_str()).is_ok());
prop_assert!(!slug.as_str().is_empty());
prop_assert!(slug.as_str().len() <= 128);
prop_assert!(!slug.as_str().starts_with('-'));
prop_assert!(!slug.as_str().ends_with('-'));
prop_assert!(!slug.as_str().contains("--"));
}
#[test]
fn from_title_always_produces_valid_slug(title in ".*") {
let slug = Slug::from_title(&title);
prop_assert!(Slug::new(slug.as_str()).is_ok());
}
}
}
#[cfg(feature = "schemars")]
#[test]
fn slug_schema_is_valid() {
let schema = schemars::schema_for!(Slug);
let json = serde_json::to_value(&schema).expect("schema serializable");
assert!(json.is_object());
}
}