Skip to main content

reinhardt_forms/
validators.rs

1//! Page/URL validators for form fields
2//!
3//! This module provides validators for URL and URL slug validation
4//! that integrate with the form field validation pipeline.
5
6use crate::field::{FieldError, FieldResult};
7use regex::Regex;
8use std::sync::LazyLock;
9
10// HTTP/HTTPS URL pattern.
11//
12// Validates URLs with:
13// - http or https scheme only
14// - Valid domain labels (no leading/trailing hyphens)
15// - Optional port number (1-5 digits)
16// - Optional path, query string, and fragment
17static 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
24// ASCII slug pattern: lowercase letters, digits, hyphens, underscores.
25//
26// Does not allow hyphens at the start or end of the slug.
27static 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/// Validates that a string value is a well-formed HTTP or HTTPS URL.
33///
34/// The validator checks:
35/// - Scheme must be `http` or `https`
36/// - Host must be non-empty and must not start or end with a hyphen
37/// - Optional port, path, query string, and fragment are allowed
38///
39/// # Examples
40///
41/// ```
42/// use reinhardt_forms::validators::UrlValidator;
43///
44/// let validator = UrlValidator::new();
45/// assert!(validator.validate("https://example.com").is_ok());
46/// assert!(validator.validate("http://localhost:8080/path").is_ok());
47/// assert!(validator.validate("ftp://example.com").is_err());
48/// assert!(validator.validate("not-a-url").is_err());
49/// ```
50#[derive(Debug, Clone)]
51pub struct UrlValidator {
52	/// Optional custom error message shown on validation failure
53	message: Option<String>,
54}
55
56impl UrlValidator {
57	/// Creates a new `UrlValidator` with default settings.
58	///
59	/// # Examples
60	///
61	/// ```
62	/// use reinhardt_forms::validators::UrlValidator;
63	///
64	/// let validator = UrlValidator::new();
65	/// assert!(validator.validate("https://example.com").is_ok());
66	/// ```
67	pub fn new() -> Self {
68		Self { message: None }
69	}
70
71	/// Sets a custom error message returned on validation failure.
72	///
73	/// # Examples
74	///
75	/// ```
76	/// use reinhardt_forms::validators::UrlValidator;
77	///
78	/// let validator = UrlValidator::new().with_message("Please enter a valid website URL");
79	/// assert!(validator.validate("bad").is_err());
80	/// ```
81	pub fn with_message(mut self, message: impl Into<String>) -> Self {
82		self.message = Some(message.into());
83		self
84	}
85
86	/// Validates the given string slice as a URL.
87	///
88	/// Returns `Ok(())` when the URL is valid, or a [`FieldError::Validation`]
89	/// containing an error message when it is not.
90	///
91	/// # Examples
92	///
93	/// ```
94	/// use reinhardt_forms::validators::UrlValidator;
95	///
96	/// let validator = UrlValidator::new();
97	/// assert!(validator.validate("https://www.example.com/path?q=1").is_ok());
98	/// assert!(validator.validate("ftp://example.com").is_err());
99	/// ```
100	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/// Validates that a string value is a valid URL slug.
117///
118/// A valid slug:
119/// - Contains only lowercase ASCII letters (`a`-`z`), digits (`0`-`9`),
120///   hyphens (`-`), and underscores (`_`)
121/// - Is non-empty
122/// - Does not start or end with a hyphen
123///
124/// # Examples
125///
126/// ```
127/// use reinhardt_forms::validators::SlugValidator;
128///
129/// let validator = SlugValidator::new();
130/// assert!(validator.validate("my-article").is_ok());
131/// assert!(validator.validate("page_1").is_ok());
132/// assert!(validator.validate("-invalid").is_err());
133/// assert!(validator.validate("has space").is_err());
134/// assert!(validator.validate("").is_err());
135/// ```
136#[derive(Debug, Clone)]
137pub struct SlugValidator {
138	/// Optional custom error message shown on validation failure
139	message: Option<String>,
140}
141
142impl SlugValidator {
143	/// Creates a new `SlugValidator` with default settings.
144	///
145	/// # Examples
146	///
147	/// ```
148	/// use reinhardt_forms::validators::SlugValidator;
149	///
150	/// let validator = SlugValidator::new();
151	/// assert!(validator.validate("valid-slug").is_ok());
152	/// ```
153	pub fn new() -> Self {
154		Self { message: None }
155	}
156
157	/// Sets a custom error message returned on validation failure.
158	///
159	/// # Examples
160	///
161	/// ```
162	/// use reinhardt_forms::validators::SlugValidator;
163	///
164	/// let validator = SlugValidator::new().with_message("Only lowercase letters, numbers, hyphens, and underscores are allowed");
165	/// assert!(validator.validate("Bad Slug!").is_err());
166	/// ```
167	pub fn with_message(mut self, message: impl Into<String>) -> Self {
168		self.message = Some(message.into());
169		self
170	}
171
172	/// Validates the given string slice as a URL slug.
173	///
174	/// Returns `Ok(())` for a valid slug, or a [`FieldError::Validation`]
175	/// containing an error message for an invalid one.
176	///
177	/// # Examples
178	///
179	/// ```
180	/// use reinhardt_forms::validators::SlugValidator;
181	///
182	/// let validator = SlugValidator::new();
183	/// assert!(validator.validate("my-slug-123").is_ok());
184	/// assert!(validator.validate("trailing-").is_err());
185	/// assert!(validator.validate("-leading").is_err());
186	/// ```
187	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	// =========================================================================
220	// UrlValidator tests
221	// =========================================================================
222
223	#[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		// Arrange
240		let validator = UrlValidator::new();
241
242		// Act
243		let result = validator.validate(url);
244
245		// Assert
246		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		// Arrange
262		let validator = UrlValidator::new();
263
264		// Act
265		let result = validator.validate(url);
266
267		// Assert
268		assert!(result.is_err(), "Expected '{url}' to be an invalid URL");
269	}
270
271	#[rstest]
272	fn test_url_validator_error_type() {
273		// Arrange
274		let validator = UrlValidator::new();
275
276		// Act
277		let result = validator.validate("not-a-url");
278
279		// Assert
280		assert!(matches!(result, Err(FieldError::Validation(_))));
281	}
282
283	#[rstest]
284	fn test_url_validator_custom_message() {
285		// Arrange
286		let validator = UrlValidator::new().with_message("Custom URL error");
287
288		// Act
289		let result = validator.validate("bad-url");
290
291		// Assert
292		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		// Arrange
303		let validator = UrlValidator::default();
304
305		// Act + Assert
306		assert!(validator.validate("https://example.com").is_ok());
307	}
308
309	// =========================================================================
310	// SlugValidator tests
311	// =========================================================================
312
313	#[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		// Arrange
326		let validator = SlugValidator::new();
327
328		// Act
329		let result = validator.validate(slug);
330
331		// Assert
332		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		// Arrange
347		let validator = SlugValidator::new();
348
349		// Act
350		let result = validator.validate(slug);
351
352		// Assert
353		assert!(result.is_err(), "Expected '{slug}' to be an invalid slug");
354	}
355
356	#[rstest]
357	fn test_slug_validator_empty_specific_error() {
358		// Arrange
359		let validator = SlugValidator::new();
360
361		// Act
362		let result = validator.validate("");
363
364		// Assert
365		assert!(matches!(result, Err(FieldError::Validation(_))));
366	}
367
368	#[rstest]
369	fn test_slug_validator_invalid_error_type() {
370		// Arrange
371		let validator = SlugValidator::new();
372
373		// Act
374		let result = validator.validate("-bad-slug");
375
376		// Assert
377		assert!(matches!(result, Err(FieldError::Validation(_))));
378	}
379
380	#[rstest]
381	fn test_slug_validator_custom_message() {
382		// Arrange
383		let validator = SlugValidator::new().with_message("Custom slug error");
384
385		// Act
386		let result = validator.validate("Bad Slug!");
387
388		// Assert
389		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		// Arrange
400		let validator = SlugValidator::new().with_message("Slug cannot be empty");
401
402		// Act
403		let result = validator.validate("");
404
405		// Assert
406		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		// Arrange
417		let validator = SlugValidator::default();
418
419		// Act + Assert
420		assert!(validator.validate("valid-slug").is_ok());
421	}
422}