1#[cfg(all(not(feature = "std"), feature = "alloc"))]
27use alloc::{borrow::ToOwned, string::String};
28use core::{fmt, ops::Deref};
29#[cfg(feature = "serde")]
30use serde::{Deserialize, Deserializer, Serialize};
31use thiserror::Error;
32
33#[derive(Debug, Clone, PartialEq, Eq, Error)]
39pub enum SlugError {
40 #[error("slug must not be empty")]
42 Empty,
43 #[error("slug must not exceed 128 characters")]
45 TooLong,
46 #[error("slug may only contain lowercase ASCII letters, digits, and hyphens")]
48 InvalidChars,
49 #[error("slug must not start with a hyphen")]
51 LeadingHyphen,
52 #[error("slug must not end with a hyphen")]
54 TrailingHyphen,
55 #[error("slug must not contain consecutive hyphens")]
57 ConsecutiveHyphens,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
68#[cfg_attr(feature = "serde", derive(Serialize))]
69#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
70#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
71pub struct Slug(String);
72
73impl Slug {
74 pub fn new(s: impl AsRef<str>) -> Result<Self, SlugError> {
92 let s = s.as_ref();
93 if s.is_empty() {
94 return Err(SlugError::Empty);
95 }
96 if s.len() > 128 {
97 return Err(SlugError::TooLong);
98 }
99 if !s
100 .chars()
101 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
102 {
103 return Err(SlugError::InvalidChars);
104 }
105 if s.starts_with('-') {
106 return Err(SlugError::LeadingHyphen);
107 }
108 if s.ends_with('-') {
109 return Err(SlugError::TrailingHyphen);
110 }
111 if s.contains("--") {
112 return Err(SlugError::ConsecutiveHyphens);
113 }
114 Ok(Self(s.to_owned()))
115 }
116
117 #[must_use]
137 pub fn from_title(s: impl AsRef<str>) -> Self {
138 let lowered = s.as_ref().to_lowercase();
139 let replaced: String = lowered
141 .chars()
142 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
143 .collect();
144 let mut collapsed = String::with_capacity(replaced.len());
146 let mut prev_hyphen = false;
147 for c in replaced.chars() {
148 if c == '-' {
149 if !prev_hyphen {
150 collapsed.push(c);
151 }
152 prev_hyphen = true;
153 } else {
154 collapsed.push(c);
155 prev_hyphen = false;
156 }
157 }
158 let trimmed = collapsed.trim_matches('-');
160 let truncated: String = trimmed.chars().take(128).collect();
161 let final_str = truncated.trim_matches('-');
163 if final_str.is_empty() {
164 Self("untitled".to_owned())
165 } else {
166 Self(final_str.to_owned())
167 }
168 }
169
170 #[must_use]
179 pub fn as_str(&self) -> &str {
180 &self.0
181 }
182
183 #[must_use]
193 pub fn into_string(self) -> String {
194 self.0
195 }
196}
197
198impl Deref for Slug {
203 type Target = str;
204
205 fn deref(&self) -> &str {
206 &self.0
207 }
208}
209
210impl AsRef<str> for Slug {
211 fn as_ref(&self) -> &str {
212 &self.0
213 }
214}
215
216impl fmt::Display for Slug {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 f.write_str(&self.0)
219 }
220}
221
222impl TryFrom<String> for Slug {
223 type Error = SlugError;
224
225 fn try_from(s: String) -> Result<Self, Self::Error> {
226 Self::new(s)
227 }
228}
229
230impl TryFrom<&str> for Slug {
231 type Error = SlugError;
232
233 fn try_from(s: &str) -> Result<Self, Self::Error> {
234 Self::new(s)
235 }
236}
237
238#[cfg(feature = "serde")]
243impl<'de> Deserialize<'de> for Slug {
244 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
245 let s = String::deserialize(deserializer)?;
246 Self::new(&s).map_err(serde::de::Error::custom)
247 }
248}
249
250#[cfg(feature = "arbitrary")]
255impl<'a> arbitrary::Arbitrary<'a> for Slug {
256 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
257 const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
258 let len = u.int_in_range(1usize..=20)?;
259 let inner: String = (0..len)
261 .map(|_| -> arbitrary::Result<char> {
262 let idx = u.int_in_range(0..=(CHARSET.len() - 1))?;
263 Ok(CHARSET[idx] as char)
264 })
265 .collect::<arbitrary::Result<_>>()?;
266 let segments = u.int_in_range(1usize..=3)?;
268 if segments == 1 || inner.len() < 2 {
269 Ok(Self(inner))
270 } else {
271 let step = inner.len() / segments;
272 let joined: String = inner
273 .as_bytes()
274 .chunks(step.max(1))
275 .map(|c| std::str::from_utf8(c).unwrap_or("a"))
276 .collect::<Vec<_>>()
277 .join("-");
278 Ok(Self(joined.trim_matches('-').to_owned()))
280 }
281 }
282}
283
284#[cfg(feature = "proptest")]
289pub mod proptest_strategies {
290 use super::Slug;
291 use proptest::prelude::*;
292
293 pub fn slug_strategy() -> impl Strategy<Value = Slug> {
295 prop::collection::vec("[a-z0-9]{1,20}", 1..=4).prop_map(|segs| {
297 let s = segs.join("-");
298 Slug::new(s).expect("generated slug must be valid")
300 })
301 }
302}
303
304#[cfg(feature = "proptest")]
305impl proptest::arbitrary::Arbitrary for Slug {
306 type Parameters = ();
307 type Strategy = proptest::strategy::BoxedStrategy<Self>;
308
309 fn arbitrary_with((): Self::Parameters) -> Self::Strategy {
310 use proptest::prelude::*;
311 proptest_strategies::slug_strategy().boxed()
312 }
313}
314
315#[cfg(test)]
320mod tests {
321 use super::*;
322
323 #[test]
326 fn valid_slugs_are_accepted() {
327 for s in ["a", "hello", "hello-world", "abc-123", "a1b2c3", "x"] {
328 assert!(Slug::new(s).is_ok(), "expected {s:?} to be valid");
329 }
330 }
331
332 #[test]
333 fn empty_string_is_rejected() {
334 assert_eq!(Slug::new(""), Err(SlugError::Empty));
335 }
336
337 #[test]
338 fn too_long_string_is_rejected() {
339 let s: String = "a".repeat(129);
340 assert_eq!(Slug::new(&s), Err(SlugError::TooLong));
341 }
342
343 #[test]
344 fn exactly_128_chars_is_accepted() {
345 let s: String = "a".repeat(128);
346 assert!(Slug::new(&s).is_ok());
347 }
348
349 #[test]
350 fn uppercase_chars_are_rejected() {
351 assert_eq!(Slug::new("Hello"), Err(SlugError::InvalidChars));
352 }
353
354 #[test]
355 fn special_chars_are_rejected() {
356 for s in ["hello_world", "hello world", "héllo", "hello.world"] {
357 assert_eq!(
358 Slug::new(s),
359 Err(SlugError::InvalidChars),
360 "expected {s:?} to be invalid"
361 );
362 }
363 }
364
365 #[test]
366 fn leading_hyphen_is_rejected() {
367 assert_eq!(Slug::new("-hello"), Err(SlugError::LeadingHyphen));
368 }
369
370 #[test]
371 fn trailing_hyphen_is_rejected() {
372 assert_eq!(Slug::new("hello-"), Err(SlugError::TrailingHyphen));
373 }
374
375 #[test]
376 fn consecutive_hyphens_are_rejected() {
377 assert_eq!(
378 Slug::new("hello--world"),
379 Err(SlugError::ConsecutiveHyphens)
380 );
381 }
382
383 #[test]
386 fn from_title_basic() {
387 let slug = Slug::from_title("Hello World");
388 assert_eq!(slug.as_str(), "hello-world");
389 }
390
391 #[test]
392 fn from_title_strips_special_chars() {
393 let slug = Slug::from_title("Hello, World! 2024");
394 assert_eq!(slug.as_str(), "hello-world-2024");
395 }
396
397 #[test]
398 fn from_title_collapses_multiple_spaces() {
399 let slug = Slug::from_title("Hello World");
400 assert_eq!(slug.as_str(), "hello-world");
401 }
402
403 #[test]
404 fn from_title_strips_leading_trailing_separators() {
405 let slug = Slug::from_title(" Hello World ");
406 assert_eq!(slug.as_str(), "hello-world");
407 }
408
409 #[test]
410 fn from_title_all_non_alnum_returns_untitled() {
411 let slug = Slug::from_title("!!! ???");
412 assert_eq!(slug.as_str(), "untitled");
413 }
414
415 #[test]
416 fn from_title_empty_returns_untitled() {
417 let slug = Slug::from_title("");
418 assert_eq!(slug.as_str(), "untitled");
419 }
420
421 #[test]
422 fn from_title_truncates_to_128() {
423 let long: String = "a ".repeat(200);
424 let slug = Slug::from_title(&long);
425 assert!(slug.as_str().len() <= 128);
426 assert!(Slug::new(slug.as_str()).is_ok());
427 }
428
429 #[test]
432 fn deref_to_str() {
433 let slug = Slug::new("hello").unwrap();
434 let s: &str = &slug;
435 assert_eq!(s, "hello");
436 }
437
438 #[test]
439 fn display() {
440 let slug = Slug::new("hello-world").unwrap();
441 assert_eq!(format!("{slug}"), "hello-world");
442 }
443
444 #[test]
445 fn as_ref_str() {
446 let slug = Slug::new("hello").unwrap();
447 let s: &str = slug.as_ref();
448 assert_eq!(s, "hello");
449 }
450
451 #[test]
452 fn try_from_string() {
453 let slug = Slug::try_from("hello".to_owned()).unwrap();
454 assert_eq!(slug.as_str(), "hello");
455 }
456
457 #[test]
458 fn try_from_str_ref() {
459 let slug = Slug::try_from("world").unwrap();
460 assert_eq!(slug.as_str(), "world");
461 }
462
463 #[test]
464 fn into_string() {
465 let slug = Slug::new("hello").unwrap();
466 assert_eq!(slug.into_string(), "hello".to_owned());
467 }
468
469 #[cfg(feature = "serde")]
472 #[test]
473 fn serde_roundtrip() {
474 let slug = Slug::new("hello-world").unwrap();
475 let json = serde_json::to_string(&slug).unwrap();
476 assert_eq!(json, r#""hello-world""#);
477 let back: Slug = serde_json::from_str(&json).unwrap();
478 assert_eq!(back, slug);
479 }
480
481 #[cfg(feature = "serde")]
482 #[test]
483 fn serde_deserialize_invalid_rejects() {
484 let result: Result<Slug, _> = serde_json::from_str(r#""Hello-World""#);
485 assert!(result.is_err());
486 }
487
488 #[cfg(feature = "arbitrary")]
491 mod arbitrary_tests {
492 use super::super::Slug;
493 use arbitrary::{Arbitrary, Unstructured};
494
495 #[test]
496 fn arbitrary_generates_valid_slugs() {
497 let raw: Vec<u8> = (0u8..=255).cycle().take(1024).collect();
498 let mut u = Unstructured::new(&raw);
499 for _ in 0..50 {
500 if let Ok(slug) = Slug::arbitrary(&mut u) {
501 assert!(
502 Slug::new(slug.as_str()).is_ok(),
503 "arbitrary produced invalid slug: {slug:?}"
504 );
505 }
506 }
507 }
508 }
509
510 #[cfg(feature = "proptest")]
513 mod proptest_tests {
514 use super::super::*;
515 use proptest::prelude::*;
516
517 proptest! {
518 #[test]
519 fn arbitrary_with_generates_valid_slugs(slug in <Slug as proptest::arbitrary::Arbitrary>::arbitrary_with(())) {
520 prop_assert!(Slug::new(slug.as_str()).is_ok());
521 }
522
523 #[test]
524 fn generated_slugs_are_always_valid(slug in proptest_strategies::slug_strategy()) {
525 prop_assert!(Slug::new(slug.as_str()).is_ok());
526 prop_assert!(!slug.as_str().is_empty());
527 prop_assert!(slug.as_str().len() <= 128);
528 prop_assert!(!slug.as_str().starts_with('-'));
529 prop_assert!(!slug.as_str().ends_with('-'));
530 prop_assert!(!slug.as_str().contains("--"));
531 }
532
533 #[test]
534 fn from_title_always_produces_valid_slug(title in ".*") {
535 let slug = Slug::from_title(&title);
536 prop_assert!(Slug::new(slug.as_str()).is_ok());
537 }
538 }
539 }
540
541 #[cfg(feature = "schemars")]
542 #[test]
543 fn slug_schema_is_valid() {
544 let schema = schemars::schema_for!(Slug);
545 let json = serde_json::to_value(&schema).expect("schema serializable");
546 assert!(json.is_object());
547 }
548}