1use serde::de::{self, Deserializer, Visitor};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8const REPLACEMENT_CHAR: char = '_';
9
10#[derive(Serialize, Clone, Debug, Eq, PartialEq, Default)]
13pub struct Slug(String);
14
15impl Slug {
16 fn new(s: String) -> Slug {
17 let s = s.trim_start_matches(REPLACEMENT_CHAR).to_string();
19 Slug(s)
20 }
21
22 pub fn from_string(s: impl AsRef<str>) -> Slug {
24 Slug::slugify(s.as_ref())
25 }
26
27 pub fn slugify(s: &str) -> Slug {
29 let out = s
30 .to_lowercase()
31 .chars()
32 .map(|c| {
33 let is_valid = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_';
34 if is_valid { c } else { REPLACEMENT_CHAR }
35 })
36 .collect::<String>();
37 Slug::new(out)
38 }
39
40 pub fn slugify_unique(s: &str) -> Slug {
43 let out = s
44 .to_lowercase()
45 .chars()
46 .map(|c| {
47 let is_valid = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_';
48 if is_valid { c } else { REPLACEMENT_CHAR }
49 })
50 .collect::<String>();
51 let hash = blake3::hash(s.as_bytes()).to_string();
52 let out = format!("{out}_{}", &hash[(hash.len() - 8)..]);
53 Slug::new(out)
54 }
55}
56
57impl fmt::Display for Slug {
58 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
59 write!(f, "{}", self.0)
60 }
61}
62
63#[derive(Debug)]
64pub struct InvalidSlugError(char);
65
66impl fmt::Display for InvalidSlugError {
67 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
68 write!(
69 f,
70 "Invalid char '{}'. String can only contain a-z, 0-9, - and _.",
71 self.0
72 )
73 }
74}
75
76impl std::error::Error for InvalidSlugError {}
77
78impl TryFrom<&str> for Slug {
79 type Error = InvalidSlugError;
80
81 fn try_from(s: &str) -> Result<Self, Self::Error> {
82 s.to_string().try_into()
83 }
84}
85
86impl TryFrom<String> for Slug {
87 type Error = InvalidSlugError;
88
89 fn try_from(s: String) -> Result<Self, Self::Error> {
90 let is_invalid =
91 |c: &char| !c.is_ascii_lowercase() && !c.is_ascii_digit() && *c != '-' && *c != '_';
92 match s.chars().find(is_invalid) {
93 None => Ok(Slug(s)),
94 Some(c) => Err(InvalidSlugError(c)),
95 }
96 }
97}
98
99impl<'de> Deserialize<'de> for Slug {
100 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
101 where
102 D: Deserializer<'de>,
103 {
104 struct SlugVisitor;
105
106 impl Visitor<'_> for SlugVisitor {
107 type Value = Slug;
108
109 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
110 formatter
111 .write_str("a valid slug string containing only characters a-z, 0-9, - and _.")
112 }
113
114 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
115 where
116 E: de::Error,
117 {
118 Slug::try_from(v).map_err(de::Error::custom)
119 }
120
121 fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
122 where
123 E: de::Error,
124 {
125 Slug::try_from(v.as_ref()).map_err(de::Error::custom)
126 }
127 }
128
129 deserializer.deserialize_string(SlugVisitor)
130 }
131}
132
133impl AsRef<str> for Slug {
134 fn as_ref(&self) -> &str {
135 &self.0
136 }
137}
138
139impl PartialEq<str> for Slug {
140 fn eq(&self, other: &str) -> bool {
141 self.0 == other
142 }
143}