#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use std::fmt;
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct Slug(String);
impl Slug {
pub fn new(value: &str) -> Option<Self> {
is_slug(value).then(|| Self(value.to_owned()))
}
pub fn from_text(input: &str) -> Self {
Self(slugify(input))
}
pub fn from_text_with_options(input: &str, options: SlugOptions) -> Self {
Self(slugify_with_options(input, options))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl AsRef<str> for Slug {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for Slug {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SlugOptions {
pub separator: SlugSeparator,
pub max_length: Option<usize>,
}
impl Default for SlugOptions {
fn default() -> Self {
Self {
separator: SlugSeparator::Dash,
max_length: None,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SlugSeparator {
Dash,
Underscore,
Custom(char),
}
impl SlugSeparator {
pub const fn as_char(self) -> char {
match self {
Self::Dash => '-',
Self::Underscore => '_',
Self::Custom(character) => character,
}
}
}
pub fn slugify(input: &str) -> String {
slugify_with_options(input, SlugOptions::default())
}
pub fn normalize_slug(input: &str) -> String {
slugify(input)
}
pub fn is_slug(input: &str) -> bool {
!input.is_empty()
&& input == input.trim()
&& !input.starts_with('-')
&& !input.ends_with('-')
&& !input.contains("--")
&& input.chars().all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
})
}
pub fn slug_words(input: &str) -> Vec<String> {
let slug = slugify(input);
if slug.is_empty() {
return Vec::new();
}
slug.split('-').map(ToOwned::to_owned).collect()
}
pub fn truncate_slug(input: &str, max_length: usize) -> String {
let slug = slugify(input);
truncate_normalized_slug(&slug, max_length, '-')
}
fn slugify_with_options(input: &str, options: SlugOptions) -> String {
let separator = normalize_separator(options.separator);
let mut output = String::new();
let mut previous_was_separator = false;
for character in input.trim().chars() {
if character.is_ascii_alphanumeric() {
output.push(character.to_ascii_lowercase());
previous_was_separator = false;
continue;
}
if (character.is_whitespace()
|| (character.is_ascii() && !character.is_ascii_alphanumeric()))
&& !output.is_empty()
&& !previous_was_separator
{
output.push(separator);
previous_was_separator = true;
}
}
while output.ends_with(separator) {
output.pop();
}
if let Some(max_length) = options.max_length {
return truncate_normalized_slug(&output, max_length, separator);
}
output
}
fn normalize_separator(separator: SlugSeparator) -> char {
let candidate = separator.as_char();
if candidate.is_ascii_alphanumeric() || candidate.is_ascii_whitespace() {
'-'
} else {
candidate
}
}
fn truncate_normalized_slug(slug: &str, max_length: usize, separator: char) -> String {
if max_length == 0 || slug.is_empty() {
return String::new();
}
if slug.len() <= max_length {
return slug.to_owned();
}
let prefix = &slug[..max_length];
if let Some(index) = prefix.rfind(separator) {
let candidate = prefix[..index].trim_end_matches(separator);
if !candidate.is_empty() {
return candidate.to_owned();
}
}
prefix.trim_end_matches(separator).to_owned()
}
#[cfg(test)]
mod tests {
use super::{
Slug, SlugOptions, SlugSeparator, is_slug, normalize_slug, slug_words, slugify,
truncate_slug,
};
#[test]
fn handles_empty_and_whitespace_only_input() {
assert_eq!(slugify(""), "");
assert_eq!(slugify(" \n"), "");
assert_eq!(slug_words(" \n"), Vec::<String>::new());
}
#[test]
fn normalizes_ascii_text_and_repeated_separators() {
assert_eq!(slugify("Release Candidate 1"), "release-candidate-1");
assert_eq!(slugify("release---candidate___1"), "release-candidate-1");
assert_eq!(normalize_slug(" release_candidate "), "release-candidate");
}
#[test]
fn validates_and_truncates_slugs() {
assert!(is_slug("release-candidate-1"));
assert!(!is_slug("Release-Candidate-1"));
assert_eq!(truncate_slug("release candidate patch", 14), "release");
assert_eq!(
truncate_slug("release candidate patch", 20),
"release-candidate"
);
}
#[test]
fn exposes_slug_words_and_custom_options() {
assert_eq!(
slug_words("Release Candidate"),
vec![String::from("release"), String::from("candidate")]
);
let slug = Slug::from_text_with_options(
"Release Candidate",
SlugOptions {
separator: SlugSeparator::Underscore,
max_length: None,
},
);
assert_eq!(slug.as_str(), "release_candidate");
}
#[test]
fn treats_unicode_conservatively() {
assert_eq!(slugify("Élan vital"), "lan-vital");
assert_eq!(slugify("naïve façade"), "nave-faade");
}
#[test]
fn slug_type_validates_normalized_values() {
assert!(Slug::new("release-candidate").is_some());
assert!(Slug::new("Release Candidate").is_none());
}
}