Skip to main content

dynamo_runtime/
slug.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use serde::de::{self, Deserializer, Visitor};
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8const REPLACEMENT_CHAR: char = '_';
9
10/// URL and NATS friendly string.
11/// Only a-z, 0-9, - and _.
12#[derive(Serialize, Clone, Debug, Eq, PartialEq, Default)]
13pub struct Slug(String);
14
15impl Slug {
16    fn new(s: String) -> Slug {
17        // remove any leading REPLACEMENT_CHAR
18        let s = s.trim_start_matches(REPLACEMENT_CHAR).to_string();
19        Slug(s)
20    }
21
22    /// Create [`Slug`] from a string.
23    pub fn from_string(s: impl AsRef<str>) -> Slug {
24        Slug::slugify(s.as_ref())
25    }
26
27    /// Turn the string into a valid slug, replacing any not-web-or-nats-safe characters with '-'
28    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    /// Like slugify but also add a four byte hash on the end, in case two different strings slug
41    /// to the same thing (e.g. because of case differences).
42    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}