Skip to main content

sofie_values/web/
slug.rs

1use sofie_core::{Error, Result};
2
3const MAXIMUM_LENGTH: usize = 100;
4
5#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
6pub struct Slug {
7    text: String,
8}
9
10impl Slug {
11    pub fn parse(text: &str) -> Result<Self> {
12        Self::from_stored(text.to_owned())
13    }
14
15    fn from_stored(text: String) -> Result<Self> {
16        if text.is_empty() || text.len() > MAXIMUM_LENGTH {
17            return Err(invalid_slug("must have 1 to 100 characters"));
18        }
19        let well_formed = text
20            .split('-')
21            .all(|group| !group.is_empty() && group.chars().all(crate::is_slug_character));
22        if !well_formed {
23            return Err(invalid_slug(
24                "must be lowercase letters and digits joined by single hyphens",
25            ));
26        }
27        Ok(Self { text })
28    }
29
30    #[must_use]
31    pub fn as_str(&self) -> &str {
32        &self.text
33    }
34}
35
36fn invalid_slug(message: &str) -> Error {
37    Error::invalid("slug", message)
38}
39
40sofie_core::text_value!(Slug, text, Slug::from_stored);
41
42crate::present::present_text!(
43    Slug,
44    crate::Input::typed(&[
45        ("type", "text"),
46        (
47            "pattern",
48            r"[\p{Ll}\p{Lm}\p{Lo}\p{M}\p{N}]+(-[\p{Ll}\p{Lm}\p{Lo}\p{M}\p{N}]+)*"
49        )
50    ]),
51    Exact
52);
53
54#[cfg(test)]
55mod tests {
56    use sofie_core::SqlValue;
57
58    use super::*;
59    use crate::testing::{assert_refused, round_trip};
60
61    #[test]
62    fn accepts_hyphenated_lowercase_words_and_digits() {
63        for text in [
64            "scarf",
65            "blue-wool-scarf",
66            "2026-sale",
67            "ogle-yemegi",
68            "关于我们",
69            "привет-мир",
70            "नमस्ते-दुनिया",
71            "ไม่-ใช่",
72            "a",
73            &"a".repeat(100),
74        ] {
75            assert_eq!(Slug::parse(text).unwrap().as_str(), text);
76        }
77    }
78
79    #[test]
80    fn refuses_anything_else() {
81        assert_refused(
82            Slug::parse,
83            &[
84                "",
85                "Blue-Scarf",
86                "blue--scarf",
87                "-scarf",
88                "scarf-",
89                "blue scarf",
90                "blue_scarf",
91                "Şal",
92                "привет мир",
93                "नमस्ते दुनिया",
94                "ไม่!",
95                "Cre\u{300}me",
96                "🎉",
97                "about\u{feff}",
98                "a\u{200b}b",
99                "a\u{e000}",
100                "a\u{378}",
101                "\u{3164}",
102                "a\u{20dd}",
103                &"a".repeat(101),
104            ],
105        );
106    }
107
108    #[test]
109    fn round_trips_through_a_text_column_and_json() {
110        let slug = Slug::parse("blue-wool-scarf").unwrap();
111        assert_eq!(
112            round_trip(&slug),
113            vec![SqlValue::Text("blue-wool-scarf".into())]
114        );
115        let json = serde_json::to_value(&slug).unwrap();
116        assert_eq!(serde_json::from_value::<Slug>(json).unwrap(), slug);
117        assert!(serde_json::from_value::<Slug>(serde_json::json!("Blue")).is_err());
118    }
119}