Skip to main content

sofie_values/web/
slug.rs

1use sofie_core::Result;
2
3use crate::refusal::refusal;
4use crate::slug_character::{draws_nothing, without_marks_that_draw_nothing};
5
6const MAXIMUM_LENGTH: usize = 100;
7
8#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
9pub struct Slug {
10    text: String,
11}
12
13impl Slug {
14    pub fn parse(text: &str) -> Result<Self> {
15        Self::checked(text.to_owned())
16    }
17
18    /// Also reads a slug stored before marks that draw nothing were refused.
19    fn from_stored(text: String) -> Result<Self> {
20        if text.chars().any(draws_nothing) {
21            Self::checked(without_marks_that_draw_nothing(&text))?;
22            return Ok(Self { text });
23        }
24        Self::checked(text)
25    }
26
27    fn checked(text: String) -> Result<Self> {
28        if text.is_empty() || text.len() > MAXIMUM_LENGTH {
29            return Err(refusal(
30                "slug",
31                "validation.slug.length",
32                "must have 1 to 100 characters",
33            ));
34        }
35        let well_formed = text
36            .split('-')
37            .all(|group| !group.is_empty() && group.chars().all(crate::is_slug_character));
38        if !well_formed {
39            return Err(refusal(
40                "slug",
41                "validation.slug.characters",
42                "must be lowercase letters and digits joined by single hyphens",
43            ));
44        }
45        Ok(Self { text })
46    }
47
48    #[must_use]
49    pub fn as_str(&self) -> &str {
50        &self.text
51    }
52}
53
54sofie_core::text_value!(Slug, text, Slug::from_stored);
55
56/// `is_slug_character` for the browser, which reads a pattern with the `v` flag: letters that
57/// lowercasing keeps, digits and marks, less the marks and fillers that draw nothing, enclosing
58/// marks and decomposed Latin accents.
59macro_rules! slug_character_class {
60    () => {
61        concat!(
62            r"[[\p{Ll}\p{Lm}\p{Lo}\p{Mn}\p{Mc}\p{N}]--[\u{300}-\u{36f}\u{34f}\u{17b4}\u{17b5}",
63            r"\u{180b}-\u{180d}\u{180f}\u{fe00}-\u{fe0f}\u{e0100}-\u{e01ef}\u{115f}\u{1160}",
64            r"\u{3164}\u{ffa0}\u{2160}-\u{216f}]]",
65        )
66    };
67}
68
69crate::present::present_text!(
70    Slug,
71    crate::Input::typed(&[
72        ("type", "text"),
73        (
74            "pattern",
75            concat!(
76                slug_character_class!(),
77                "+(-",
78                slug_character_class!(),
79                "+)*"
80            )
81        )
82    ]),
83    Exact
84);
85
86#[cfg(test)]
87mod tests {
88    use sofie_core::{Cast, RowReader, SqlValue};
89
90    use super::*;
91    use crate::testing::{assert_refused, round_trip};
92
93    #[test]
94    fn accepts_hyphenated_lowercase_words_and_digits() {
95        for text in [
96            "scarf",
97            "blue-wool-scarf",
98            "2026-sale",
99            "ogle-yemegi",
100            "关于我们",
101            "привет-мир",
102            "नमस्ते-दुनिया",
103            "ไม่-ใช่",
104            "a",
105            &"a".repeat(100),
106        ] {
107            assert_eq!(Slug::parse(text).unwrap().as_str(), text);
108        }
109    }
110
111    #[test]
112    fn refuses_anything_else() {
113        assert_refused(
114            Slug::parse,
115            &[
116                "",
117                "Blue-Scarf",
118                "blue--scarf",
119                "-scarf",
120                "scarf-",
121                "blue scarf",
122                "blue_scarf",
123                "Şal",
124                "привет мир",
125                "नमस्ते दुनिया",
126                "ไม่!",
127                "Cre\u{300}me",
128                "🎉",
129                "about\u{feff}",
130                "a\u{200b}b",
131                "a\u{e000}",
132                "a\u{378}",
133                "\u{3164}",
134                "a\u{20dd}",
135                &"a".repeat(101),
136            ],
137        );
138    }
139
140    #[test]
141    fn round_trips_through_a_text_column_and_json() {
142        let slug = Slug::parse("blue-wool-scarf").unwrap();
143        assert_eq!(
144            round_trip(&slug),
145            vec![SqlValue::Text("blue-wool-scarf".into())]
146        );
147        let json = serde_json::to_value(&slug).unwrap();
148        assert_eq!(serde_json::from_value::<Slug>(json).unwrap(), slug);
149        assert!(serde_json::from_value::<Slug>(serde_json::json!("Blue")).is_err());
150    }
151
152    #[test]
153    fn reads_a_slug_stored_before_marks_that_draw_nothing_were_refused() {
154        let values = [SqlValue::Text("blue\u{FE0F}-scarf".into())];
155        let slug = Slug::decode(&mut RowReader::new(&values)).unwrap();
156
157        assert_eq!(slug.as_str(), "blue\u{FE0F}-scarf");
158        assert!(
159            serde_json::to_value(&slug)
160                .and_then(serde_json::from_value::<Slug>)
161                .is_err()
162        );
163        let refused = [SqlValue::Text("Blue\u{FE0F}".into())];
164        assert!(Slug::decode(&mut RowReader::new(&refused)).is_err());
165    }
166}