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}