1pub mod timestamp;
31
32use std::borrow::Borrow;
33use std::ffi::OsStr;
34use std::fmt::{self, Display};
35use std::mem;
36use std::ops::Deref;
37use std::path::Path;
38
39use paste::paste;
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42
43#[cfg(target_family = "windows")]
44pub use os::ForbiddenOnWindows;
45
46#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Eq, PartialEq, Ord, PartialOrd, Hash)] #[derive(derive_more::Display)]
53#[serde(try_from = "String", into = "String")]
54pub struct Slug(Box<str>);
57
58#[derive(Debug, Serialize)] #[derive(Eq, PartialEq, Ord, PartialOrd, Hash)] #[derive(derive_more::Display)]
64#[serde(transparent)]
65#[repr(transparent)] pub struct SlugRef(str);
67
68pub const SLUG_SEPARATOR_CHARS: &str = "/+.";
74
75#[derive(Error, Debug, Clone, Eq, PartialEq, Hash)]
77#[non_exhaustive]
78pub enum BadSlug {
79 BadCharacter(char),
81 BadFirstCharacter(char),
83 EmptySlugNotAllowed,
85 #[cfg(target_family = "windows")]
89 ForbiddenOnWindows(ForbiddenOnWindows),
90}
91
92pub trait TryIntoSlug {
101 fn try_into_slug(&self) -> Result<Slug, BadSlug>;
103}
104
105impl<T: ToString + ?Sized> TryIntoSlug for T {
106 fn try_into_slug(&self) -> Result<Slug, BadSlug> {
107 self.to_string().try_into()
108 }
109}
110
111impl Slug {
112 pub fn new(s: String) -> Result<Slug, BadSlug> {
114 Ok(unsafe {
115 check_syntax(&s)?;
117 Slug::new_unchecked(s)
118 })
119 }
120
121 pub unsafe fn new_unchecked(s: String) -> Slug {
127 Slug(s.into())
128 }
129}
130
131impl SlugRef {
132 pub fn new(s: &str) -> Result<&SlugRef, BadSlug> {
134 Ok(unsafe {
135 check_syntax(s)?;
137 SlugRef::new_unchecked(s)
138 })
139 }
140
141 pub unsafe fn new_unchecked<'s>(s: &'s str) -> &'s SlugRef {
147 unsafe {
148 mem::transmute::<&'s str, &'s SlugRef>(s)
156 }
157 }
158
159 fn to_slug(&self) -> Slug {
161 unsafe {
162 Slug::new_unchecked(self.0.into())
164 }
165 }
166}
167
168impl TryFrom<String> for Slug {
169 type Error = BadSlug;
170 fn try_from(s: String) -> Result<Slug, BadSlug> {
171 Slug::new(s)
172 }
173}
174
175impl From<Slug> for String {
176 fn from(s: Slug) -> String {
177 s.0.into()
178 }
179}
180
181impl<'s> TryFrom<&'s str> for &'s SlugRef {
182 type Error = BadSlug;
183 fn try_from(s: &'s str) -> Result<&'s SlugRef, BadSlug> {
184 SlugRef::new(s)
185 }
186}
187
188impl Deref for Slug {
189 type Target = SlugRef;
190 fn deref(&self) -> &SlugRef {
191 unsafe {
192 SlugRef::new_unchecked(&self.0)
194 }
195 }
196}
197
198impl Borrow<SlugRef> for Slug {
199 fn borrow(&self) -> &SlugRef {
200 self
201 }
202}
203impl Borrow<str> for Slug {
204 fn borrow(&self) -> &str {
205 self.as_ref()
206 }
207}
208
209impl ToOwned for SlugRef {
210 type Owned = Slug;
211 fn to_owned(&self) -> Slug {
212 self.to_slug()
213 }
214}
215
216macro_rules! impl_as_with_inherent { { $ty:ident } => { paste!{
218 impl SlugRef {
219 #[doc = concat!("Obtain this slug as a `", stringify!($ty), "`")]
220 pub fn [<as_ $ty:snake>](&self) -> &$ty {
221 self.as_ref()
222 }
223 }
224 impl_as_ref!($ty);
225} } }
226macro_rules! impl_as_ref { { $ty:ty } => { paste!{
228 impl AsRef<$ty> for SlugRef {
229 fn as_ref(&self) -> &$ty {
230 self.0.as_ref()
231 }
232 }
233 impl AsRef<$ty> for Slug {
234 fn as_ref(&self) -> &$ty {
235 self.deref().as_ref()
236 }
237 }
238} } }
239
240impl_as_with_inherent!(str);
241impl_as_with_inherent!(Path);
242impl_as_ref!(OsStr);
243impl_as_ref!([u8]);
244
245#[allow(clippy::if_same_then_else)] pub fn check_syntax(s: &str) -> Result<(), BadSlug> {
254 if s.is_empty() {
255 return Err(BadSlug::EmptySlugNotAllowed);
256 }
257
258 if s.starts_with('-') {
260 return Err(BadSlug::BadFirstCharacter('-'));
261 }
262
263 for c in s.chars() {
265 if c.is_ascii_lowercase() {
266 Ok(())
267 } else if c.is_ascii_digit() {
268 Ok(())
269 } else if c == '_' || c == '-' {
270 Ok(())
271 } else {
272 Err(BadSlug::BadCharacter(c))
273 }?;
274 }
275
276 os::check_forbidden(s)?;
277
278 Ok(())
279}
280
281impl Display for BadSlug {
282 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
283 match self {
284 BadSlug::BadCharacter(c) => {
285 let num = u32::from(*c);
286 write!(f, "character {c:?} (U+{num:04X}) is not allowed")
287 }
288 BadSlug::BadFirstCharacter(c) => {
289 let num = u32::from(*c);
290 write!(
291 f,
292 "character {c:?} (U+{num:04X}) is not allowed as the first character"
293 )
294 }
295 BadSlug::EmptySlugNotAllowed => {
296 write!(f, "empty identifier (empty slug) not allowed")
297 }
298 #[cfg(target_family = "windows")]
299 BadSlug::ForbiddenOnWindows(e) => os::fmt_error(e, f),
300 }
301 }
302}
303
304#[cfg(target_family = "windows")]
306mod os {
307 use super::*;
308
309 pub type ForbiddenOnWindows = &'static &'static str;
315
316 const FORBIDDEN: &[&str] = &[
318 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9", "com0", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9", "lpt0",
321 ];
322
323 pub(super) fn check_forbidden(s: &str) -> Result<(), BadSlug> {
325 for bad in FORBIDDEN {
326 if s == *bad {
327 return Err(BadSlug::ForbiddenOnWindows(bad));
328 }
329 }
330 Ok(())
331 }
332
333 pub(super) fn fmt_error(s: &ForbiddenOnWindows, f: &mut fmt::Formatter) -> fmt::Result {
335 write!(f, "slug (name) {s:?} is not allowed on Windows")
336 }
337}
338#[cfg(not(target_family = "windows"))]
340mod os {
341 use super::*;
342
343 #[allow(clippy::unnecessary_wraps)]
345 pub(super) fn check_forbidden(_s: &str) -> Result<(), BadSlug> {
346 Ok(())
347 }
348}
349
350#[cfg(test)]
351mod test {
352 #![allow(clippy::bool_assert_comparison)]
354 #![allow(clippy::clone_on_copy)]
355 #![allow(clippy::dbg_macro)]
356 #![allow(clippy::mixed_attributes_style)]
357 #![allow(clippy::print_stderr)]
358 #![allow(clippy::print_stdout)]
359 #![allow(clippy::single_char_pattern)]
360 #![allow(clippy::unwrap_used)]
361 #![allow(clippy::unchecked_time_subtraction)]
362 #![allow(clippy::useless_vec)]
363 #![allow(clippy::needless_pass_by_value)]
364 use super::*;
367 use itertools::chain;
368
369 #[test]
370 fn bad() {
371 for c in chain!(
372 SLUG_SEPARATOR_CHARS.chars(), ['\\', ' ', '\n', '\0']
374 ) {
375 let s = format!("x{c}y");
376 let e_ref = SlugRef::new(&s).unwrap_err();
377 assert_eq!(e_ref, BadSlug::BadCharacter(c));
378 let e_own = Slug::new(s).unwrap_err();
379 assert_eq!(e_ref, e_own);
380 }
381 }
382
383 #[test]
384 fn good() {
385 let all = chain!(
386 b'a'..=b'z', b'0'..=b'9',
388 [b'_'],
389 )
390 .map(char::from);
391
392 let chk = |s: String| {
393 let sref = SlugRef::new(&s).unwrap();
394 let slug = Slug::new(s.clone()).unwrap();
395 assert_eq!(sref.to_string(), s);
396 assert_eq!(slug.to_string(), s);
397 };
398
399 chk(all.clone().collect());
400
401 for c in all {
402 chk(format!("{c}"));
403 }
404
405 chk("a-".into());
407 chk("a-b".into());
408 }
409
410 #[test]
411 fn badchar_msg() {
412 let chk = |s: &str, m: &str| {
413 assert_eq!(
414 SlugRef::new(s).unwrap_err().to_string(),
415 m, );
417 };
418
419 chk(".", "character '.' (U+002E) is not allowed");
420 chk("\0", "character '\\0' (U+0000) is not allowed");
421 chk(
422 "\u{12345}",
423 "character '\u{12345}' (U+12345) is not allowed",
424 );
425 chk(
426 "-",
427 "character '-' (U+002D) is not allowed as the first character",
428 );
429 chk("A", "character 'A' (U+0041) is not allowed");
430 }
431
432 #[test]
433 fn windows_forbidden() {
434 for s in ["con", "prn", "lpt0"] {
435 let r = SlugRef::new(s);
436 if cfg!(target_family = "windows") {
437 assert_eq!(
438 r.unwrap_err().to_string(),
439 format!("slug (name) \"{s}\" is not allowed on Windows"),
440 );
441 } else {
442 assert_eq!(r.unwrap().as_str(), s);
443 }
444 }
445 }
446
447 #[test]
448 fn empty_slug() {
449 assert_eq!(
450 SlugRef::new("").unwrap_err().to_string(),
451 "empty identifier (empty slug) not allowed"
452 );
453 }
454}