1use crate::error::{DkpError, DkpResult};
2
3pub const MIN_SLUG_LEN: usize = 2;
4pub const MAX_SLUG_LEN: usize = 40;
5
6const RESERVED_DOMAIN_SLUGS: &[&str] = &[
15 "all",
16 "none",
17 "null",
18 "undefined",
19 "admin",
20 "root",
21 "system",
22 "test",
23 "example",
24 "unknown",
25 "n-a",
26 "na",
27];
28
29pub fn slugify_domain(display: &str) -> String {
40 let lowered = display.trim().to_lowercase();
41 let mut slug = String::with_capacity(lowered.len());
42 let mut prev_was_hyphen = true; for ch in lowered.chars() {
44 if ch.is_ascii_alphanumeric() {
45 slug.push(ch);
46 prev_was_hyphen = false;
47 } else if !prev_was_hyphen {
48 slug.push('-');
49 prev_was_hyphen = true;
50 }
51 }
52 while slug.ends_with('-') {
53 slug.pop();
54 }
55 slug
56}
57
58pub fn validate_domain_slug(slug: &str) -> DkpResult<()> {
65 if slug.len() < MIN_SLUG_LEN || slug.len() > MAX_SLUG_LEN {
66 return Err(DkpError::ManifestInvalid {
67 reason: format!(
68 "domain slug '{slug}' must be {MIN_SLUG_LEN}-{MAX_SLUG_LEN} characters (got {})",
69 slug.len()
70 ),
71 });
72 }
73 if !slug
74 .chars()
75 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
76 {
77 return Err(DkpError::ManifestInvalid {
78 reason: format!(
79 "domain slug '{slug}' must contain only lowercase letters, digits, and hyphens"
80 ),
81 });
82 }
83 if slug.starts_with('-') || slug.ends_with('-') || slug.contains("--") {
84 return Err(DkpError::ManifestInvalid {
85 reason: format!("domain slug '{slug}' has malformed hyphenation"),
86 });
87 }
88 Ok(())
89}
90
91pub fn validate_domain_slug_for_registry(slug: &str) -> DkpResult<()> {
95 validate_domain_slug(slug)?;
96 if RESERVED_DOMAIN_SLUGS.contains(&slug) {
97 return Err(DkpError::ManifestInvalid {
98 reason: format!("domain '{slug}' is a reserved word and cannot be used"),
99 });
100 }
101 Ok(())
102}
103
104pub fn derive_and_validate_domain_slug(display: &str) -> DkpResult<String> {
107 let slug = slugify_domain(display);
108 validate_domain_slug(&slug)?;
109 Ok(slug)
110}
111
112pub fn derive_and_validate_domain_slug_for_registry(display: &str) -> DkpResult<String> {
115 let slug = slugify_domain(display);
116 validate_domain_slug_for_registry(&slug)?;
117 Ok(slug)
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn basic_cases() {
126 assert_eq!(slugify_domain("Startups"), "startups");
127 assert_eq!(slugify_domain(" Startups "), "startups");
128 assert_eq!(slugify_domain("Health & Law"), "health-law");
129 assert_eq!(slugify_domain("AI/ML Tools"), "ai-ml-tools");
130 assert_eq!(slugify_domain("---weird---"), "weird");
131 assert_eq!(slugify_domain("support"), "support");
132 }
133
134 #[test]
135 fn length_bounds() {
136 assert!(validate_domain_slug("a").is_err());
137 assert!(validate_domain_slug(&"a".repeat(41)).is_err());
138 assert!(validate_domain_slug(&"a".repeat(40)).is_ok());
139 assert!(validate_domain_slug("ab").is_ok());
140 }
141
142 #[test]
143 fn reserved_words_rejected_for_registry() {
144 for w in ["all", "none", "admin", "undefined", "null"] {
145 assert!(
146 validate_domain_slug_for_registry(w).is_err(),
147 "{w} should be reserved"
148 );
149 }
150 }
151
152 #[test]
153 fn reserved_words_allowed_locally() {
154 for w in ["all", "none", "admin", "undefined", "null", "test"] {
157 assert!(
158 validate_domain_slug(w).is_ok(),
159 "{w} should be allowed for local validation"
160 );
161 assert!(derive_and_validate_domain_slug(w).is_ok());
162 }
163 }
164
165 #[test]
166 fn support_is_not_reserved() {
167 assert!(validate_domain_slug("support").is_ok());
171 assert!(validate_domain_slug_for_registry("support").is_ok());
172 }
173
174 #[test]
175 fn charset_rejected_if_hand_constructed_badly() {
176 assert!(validate_domain_slug("Has_Underscore").is_err());
177 assert!(validate_domain_slug("-leading").is_err());
178 assert!(validate_domain_slug("trailing-").is_err());
179 assert!(validate_domain_slug("double--hyphen").is_err());
180 }
181
182 #[test]
183 fn derive_round_trip() {
184 assert_eq!(
185 derive_and_validate_domain_slug("Startups").unwrap(),
186 "startups"
187 );
188 assert_eq!(derive_and_validate_domain_slug("Law").unwrap(), "law");
189 assert_eq!(
190 derive_and_validate_domain_slug("support").unwrap(),
191 "support"
192 );
193 assert!(derive_and_validate_domain_slug_for_registry("Admin").is_err());
194 }
195
196 #[test]
197 fn slugify_empty_and_punctuation_only() {
198 assert_eq!(slugify_domain(""), "");
199 assert_eq!(slugify_domain(" "), "");
200 assert_eq!(slugify_domain("!!!"), "");
201 }
202
203 #[test]
204 fn slugify_unicode_dropped_as_separator() {
205 assert_eq!(slugify_domain("Café Law"), "caf-law");
207 }
208
209 #[test]
210 fn validate_rejects_all_reserved_words_for_registry() {
211 for w in [
212 "all",
213 "none",
214 "null",
215 "undefined",
216 "admin",
217 "root",
218 "system",
219 "test",
220 "example",
221 "unknown",
222 "n-a",
223 "na",
224 ] {
225 assert!(
226 validate_domain_slug_for_registry(w).is_err(),
227 "{w} should be reserved"
228 );
229 }
230 }
231
232 #[test]
233 fn validate_rejects_uppercase() {
234 assert!(validate_domain_slug("Startups").is_err());
235 }
236
237 #[test]
238 fn validate_rejects_non_ascii() {
239 assert!(validate_domain_slug("café").is_err());
240 }
241
242 #[test]
243 fn derive_and_validate_reserved_word_collision_for_registry() {
244 assert!(derive_and_validate_domain_slug_for_registry("Root").is_err());
247 assert!(derive_and_validate_domain_slug_for_registry(" Test ").is_err());
248 assert!(derive_and_validate_domain_slug("Root").is_ok());
249 assert!(derive_and_validate_domain_slug(" Test ").is_ok());
250 }
251
252 #[test]
253 fn derive_and_validate_too_short_after_slugify() {
254 assert!(derive_and_validate_domain_slug("!!!").is_err());
256 assert!(derive_and_validate_domain_slug("a").is_err());
257 }
258}