reinhardt_forms/
validators.rs1use crate::field::{FieldError, FieldResult};
7use regex::Regex;
8use std::sync::LazyLock;
9
10static URL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
18 Regex::new(
19 r"^https?://[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)*(:[0-9]{1,5})?(/[^\s?#]*)?(\?[^\s#]*)?(#[^\s]*)?$",
20 )
21 .expect("URL_REGEX: invalid regex pattern")
22});
23
24static SLUG_REGEX: LazyLock<Regex> = LazyLock::new(|| {
28 Regex::new(r"^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$")
29 .expect("SLUG_REGEX: invalid regex pattern")
30});
31
32#[derive(Debug, Clone)]
51pub struct UrlValidator {
52 message: Option<String>,
54}
55
56impl UrlValidator {
57 pub fn new() -> Self {
68 Self { message: None }
69 }
70
71 pub fn with_message(mut self, message: impl Into<String>) -> Self {
82 self.message = Some(message.into());
83 self
84 }
85
86 pub fn validate(&self, value: &str) -> FieldResult<()> {
101 if URL_REGEX.is_match(value) {
102 Ok(())
103 } else {
104 let msg = self.message.as_deref().unwrap_or("Enter a valid URL");
105 Err(FieldError::Validation(msg.to_string()))
106 }
107 }
108}
109
110impl Default for UrlValidator {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116#[derive(Debug, Clone)]
137pub struct SlugValidator {
138 message: Option<String>,
140}
141
142impl SlugValidator {
143 pub fn new() -> Self {
154 Self { message: None }
155 }
156
157 pub fn with_message(mut self, message: impl Into<String>) -> Self {
168 self.message = Some(message.into());
169 self
170 }
171
172 pub fn validate(&self, value: &str) -> FieldResult<()> {
188 if value.is_empty() {
189 let msg = self
190 .message
191 .as_deref()
192 .unwrap_or("Enter a valid slug (non-empty)");
193 return Err(FieldError::Validation(msg.to_string()));
194 }
195
196 if SLUG_REGEX.is_match(value) {
197 Ok(())
198 } else {
199 let msg = self.message.as_deref().unwrap_or(
200 "Enter a valid slug consisting of lowercase letters, numbers, hyphens, or underscores. \
201 The slug must not start or end with a hyphen.",
202 );
203 Err(FieldError::Validation(msg.to_string()))
204 }
205 }
206}
207
208impl Default for SlugValidator {
209 fn default() -> Self {
210 Self::new()
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use rstest::rstest;
218
219 #[rstest]
224 #[case("http://example.com")]
225 #[case("https://example.com")]
226 #[case("http://www.example.com")]
227 #[case("https://www.example.com/")]
228 #[case("http://localhost")]
229 #[case("http://localhost:8080")]
230 #[case("http://localhost:8080/path")]
231 #[case("https://example.com/path/to/resource")]
232 #[case("https://example.com/path?query=value")]
233 #[case("https://example.com/path?query=value#section")]
234 #[case("http://sub.example.com/")]
235 #[case("http://example.com:3000")]
236 #[case("http://valid-domain.com/")]
237 #[case("https://example.com?q=1&page=2")]
238 fn test_url_validator_valid(#[case] url: &str) {
239 let validator = UrlValidator::new();
241
242 let result = validator.validate(url);
244
245 assert!(result.is_ok(), "Expected '{url}' to be a valid URL");
247 }
248
249 #[rstest]
250 #[case("")]
251 #[case("not-a-url")]
252 #[case("ftp://example.com")]
253 #[case("http://")]
254 #[case("http://.com")]
255 #[case("//example.com")]
256 #[case("http://-invalid.com")]
257 #[case("http://invalid-.com")]
258 #[case("just text")]
259 #[case("example.com")]
260 fn test_url_validator_invalid(#[case] url: &str) {
261 let validator = UrlValidator::new();
263
264 let result = validator.validate(url);
266
267 assert!(result.is_err(), "Expected '{url}' to be an invalid URL");
269 }
270
271 #[rstest]
272 fn test_url_validator_error_type() {
273 let validator = UrlValidator::new();
275
276 let result = validator.validate("not-a-url");
278
279 assert!(matches!(result, Err(FieldError::Validation(_))));
281 }
282
283 #[rstest]
284 fn test_url_validator_custom_message() {
285 let validator = UrlValidator::new().with_message("Custom URL error");
287
288 let result = validator.validate("bad-url");
290
291 match result {
293 Err(FieldError::Validation(msg)) => {
294 assert_eq!(msg, "Custom URL error");
295 }
296 _ => panic!("Expected Validation error with custom message"),
297 }
298 }
299
300 #[rstest]
301 fn test_url_validator_default() {
302 let validator = UrlValidator::default();
304
305 assert!(validator.validate("https://example.com").is_ok());
307 }
308
309 #[rstest]
314 #[case("a")]
315 #[case("slug")]
316 #[case("my-slug")]
317 #[case("my_slug")]
318 #[case("slug-123")]
319 #[case("my-article-title")]
320 #[case("page1")]
321 #[case("a1b2c3")]
322 #[case("under_score")]
323 #[case("mix-ed_slug-1")]
324 fn test_slug_validator_valid(#[case] slug: &str) {
325 let validator = SlugValidator::new();
327
328 let result = validator.validate(slug);
330
331 assert!(result.is_ok(), "Expected '{slug}' to be a valid slug");
333 }
334
335 #[rstest]
336 #[case("")]
337 #[case("-starts-with-hyphen")]
338 #[case("ends-with-hyphen-")]
339 #[case("has space")]
340 #[case("UPPERCASE")]
341 #[case("Has-Upper")]
342 #[case("special!char")]
343 #[case("dot.in.slug")]
344 #[case("unicode-日本語")]
345 fn test_slug_validator_invalid(#[case] slug: &str) {
346 let validator = SlugValidator::new();
348
349 let result = validator.validate(slug);
351
352 assert!(result.is_err(), "Expected '{slug}' to be an invalid slug");
354 }
355
356 #[rstest]
357 fn test_slug_validator_empty_specific_error() {
358 let validator = SlugValidator::new();
360
361 let result = validator.validate("");
363
364 assert!(matches!(result, Err(FieldError::Validation(_))));
366 }
367
368 #[rstest]
369 fn test_slug_validator_invalid_error_type() {
370 let validator = SlugValidator::new();
372
373 let result = validator.validate("-bad-slug");
375
376 assert!(matches!(result, Err(FieldError::Validation(_))));
378 }
379
380 #[rstest]
381 fn test_slug_validator_custom_message() {
382 let validator = SlugValidator::new().with_message("Custom slug error");
384
385 let result = validator.validate("Bad Slug!");
387
388 match result {
390 Err(FieldError::Validation(msg)) => {
391 assert_eq!(msg, "Custom slug error");
392 }
393 _ => panic!("Expected Validation error with custom message"),
394 }
395 }
396
397 #[rstest]
398 fn test_slug_validator_custom_message_on_empty() {
399 let validator = SlugValidator::new().with_message("Slug cannot be empty");
401
402 let result = validator.validate("");
404
405 match result {
407 Err(FieldError::Validation(msg)) => {
408 assert_eq!(msg, "Slug cannot be empty");
409 }
410 _ => panic!("Expected Validation error with custom message"),
411 }
412 }
413
414 #[rstest]
415 fn test_slug_validator_default() {
416 let validator = SlugValidator::default();
418
419 assert!(validator.validate("valid-slug").is_ok());
421 }
422}