1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
7
8pub mod prelude;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum SlugError {
12 Empty,
13 InvalidCharacter { character: char, index: usize },
14 LeadingSeparator,
15 TrailingSeparator,
16 RepeatedSeparator { index: usize },
17}
18
19impl fmt::Display for SlugError {
20 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
21 match self {
22 Self::Empty => formatter.write_str("slug cannot be empty"),
23 Self::InvalidCharacter { character, index } => {
24 write!(
25 formatter,
26 "invalid slug character `{character}` at byte {index}"
27 )
28 },
29 Self::LeadingSeparator => formatter.write_str("slug cannot start with `-`"),
30 Self::TrailingSeparator => formatter.write_str("slug cannot end with `-`"),
31 Self::RepeatedSeparator { index } => {
32 write!(formatter, "slug cannot repeat `-` at byte {index}")
33 },
34 }
35 }
36}
37
38impl std::error::Error for SlugError {}
39
40#[must_use]
41pub fn normalize_slug(input: &str) -> String {
42 let mut normalized = String::new();
43 let mut previous_was_separator = false;
44
45 for character in input.trim().chars() {
46 let lowered = character.to_ascii_lowercase();
47
48 if lowered.is_ascii_alphanumeric() {
49 normalized.push(lowered);
50 previous_was_separator = false;
51 } else if !normalized.is_empty() && !previous_was_separator {
52 normalized.push('-');
53 previous_was_separator = true;
54 }
55 }
56
57 while normalized.ends_with('-') {
58 normalized.pop();
59 }
60
61 normalized
62}
63
64pub fn validate_slug(input: &str) -> Result<(), SlugError> {
70 if input.is_empty() {
71 return Err(SlugError::Empty);
72 }
73
74 if input.starts_with('-') {
75 return Err(SlugError::LeadingSeparator);
76 }
77
78 if input.ends_with('-') {
79 return Err(SlugError::TrailingSeparator);
80 }
81
82 let mut previous_was_separator = false;
83
84 for (index, character) in input.char_indices() {
85 if character == '-' {
86 if previous_was_separator {
87 return Err(SlugError::RepeatedSeparator { index });
88 }
89
90 previous_was_separator = true;
91 continue;
92 }
93
94 if !character.is_ascii_lowercase() && !character.is_ascii_digit() {
95 return Err(SlugError::InvalidCharacter { character, index });
96 }
97
98 previous_was_separator = false;
99 }
100
101 Ok(())
102}
103
104#[must_use]
105pub fn is_valid_slug(input: &str) -> bool {
106 validate_slug(input).is_ok()
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
110pub struct Slug(String);
111
112impl Slug {
113 pub fn new(input: &str) -> Result<Self, SlugError> {
120 let normalized = normalize_slug(input);
121 validate_slug(&normalized)?;
122 Ok(Self(normalized))
123 }
124
125 #[must_use]
126 pub fn as_str(&self) -> &str {
127 &self.0
128 }
129
130 #[must_use]
131 pub fn into_inner(self) -> String {
132 self.0
133 }
134
135 pub fn segments(&self) -> impl Iterator<Item = &str> {
136 self.0.split('-')
137 }
138}
139
140impl fmt::Display for Slug {
141 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142 formatter.write_str(self.as_str())
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::{Slug, SlugError, validate_slug};
149
150 #[test]
151 fn normalizes_spaces_and_case() -> Result<(), SlugError> {
152 let slug = Slug::new("RustUse Docs")?;
153 assert_eq!(slug.as_str(), "rustuse-docs");
154 Ok(())
155 }
156
157 #[test]
158 fn constructor_collapses_repeated_separators() -> Result<(), SlugError> {
159 let slug = Slug::new("rustuse--docs")?;
160 assert_eq!(slug.as_str(), "rustuse-docs");
161 Ok(())
162 }
163
164 #[test]
165 fn validator_rejects_repeated_separators() {
166 assert_eq!(
167 validate_slug("rustuse--docs"),
168 Err(SlugError::RepeatedSeparator { index: 8 })
169 );
170 }
171}