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 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
56macro_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}