Skip to main content

reinhardt_forms/fields/
url_field.rs

1use crate::field::{FieldError, FieldResult, FormField, Widget};
2use regex::Regex;
3use std::sync::LazyLock;
4
5/// URL validation regex pattern.
6const URL_PATTERN: &str = r"^https?://(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|localhost|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})(?::\d+)?(?:/[^\s]*)?$";
7
8/// Cached URL validation regex to avoid repeated compilation.
9static URL_REGEX: LazyLock<Regex> =
10	LazyLock::new(|| Regex::new(URL_PATTERN).expect("URL regex pattern is valid"));
11
12/// URLField for URL input
13#[derive(Debug, Clone)]
14pub struct URLField {
15	/// The field name used as the form data key.
16	pub name: String,
17	/// Optional human-readable label for display.
18	pub label: Option<String>,
19	/// Whether this field must be filled in.
20	pub required: bool,
21	/// Optional help text displayed alongside the field.
22	pub help_text: Option<String>,
23	/// The widget type used for rendering this field.
24	pub widget: Widget,
25	/// Optional initial (default) value for the field.
26	pub initial: Option<serde_json::Value>,
27	/// Maximum allowed character count for the URL.
28	pub max_length: Option<usize>,
29}
30
31impl URLField {
32	/// Create a new URLField
33	///
34	/// # Examples
35	///
36	/// ```
37	/// use reinhardt_forms::fields::URLField;
38	///
39	/// let field = URLField::new("website".to_string());
40	/// assert_eq!(field.name, "website");
41	/// ```
42	pub fn new(name: String) -> Self {
43		Self {
44			name,
45			label: None,
46			required: true,
47			help_text: None,
48			widget: Widget::TextInput,
49			initial: None,
50			max_length: Some(200),
51		}
52	}
53
54	fn validate_url(url: &str) -> bool {
55		URL_REGEX.is_match(url)
56	}
57}
58
59impl FormField for URLField {
60	fn name(&self) -> &str {
61		&self.name
62	}
63
64	fn label(&self) -> Option<&str> {
65		self.label.as_deref()
66	}
67
68	fn required(&self) -> bool {
69		self.required
70	}
71
72	fn help_text(&self) -> Option<&str> {
73		self.help_text.as_deref()
74	}
75
76	fn widget(&self) -> &Widget {
77		&self.widget
78	}
79
80	fn initial(&self) -> Option<&serde_json::Value> {
81		self.initial.as_ref()
82	}
83
84	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
85		match value {
86			None if self.required => Err(FieldError::required(None)),
87			None => Ok(serde_json::Value::String(String::new())),
88			Some(v) => {
89				let s = v
90					.as_str()
91					.ok_or_else(|| FieldError::Invalid("Expected string".to_string()))?;
92
93				let s = s.trim();
94
95				if s.is_empty() {
96					if self.required {
97						return Err(FieldError::required(None));
98					}
99					return Ok(serde_json::Value::String(String::new()));
100				}
101
102				// Check length using character count (not byte count)
103				// for correct multi-byte character handling
104				let char_count = s.chars().count();
105				if let Some(max) = self.max_length
106					&& char_count > max
107				{
108					return Err(FieldError::Validation(format!(
109						"Ensure this value has at most {} characters (it has {})",
110						max, char_count
111					)));
112				}
113
114				// Validate URL format
115				if !Self::validate_url(s) {
116					return Err(FieldError::Validation("Enter a valid URL".to_string()));
117				}
118
119				Ok(serde_json::Value::String(s.to_string()))
120			}
121		}
122	}
123}
124
125#[cfg(test)]
126mod tests {
127	use super::*;
128
129	#[test]
130	fn test_urlfield_valid() {
131		let field = URLField::new("website".to_string());
132
133		assert_eq!(
134			field
135				.clean(Some(&serde_json::json!("https://example.com")))
136				.unwrap(),
137			serde_json::json!("https://example.com")
138		);
139		assert_eq!(
140			field
141				.clean(Some(&serde_json::json!("http://test.org/path")))
142				.unwrap(),
143			serde_json::json!("http://test.org/path")
144		);
145	}
146
147	#[test]
148	fn test_urlfield_invalid() {
149		let field = URLField::new("website".to_string());
150
151		assert!(matches!(
152			field.clean(Some(&serde_json::json!("not a url"))),
153			Err(FieldError::Validation(_))
154		));
155		assert!(matches!(
156			field.clean(Some(&serde_json::json!("ftp://example.com"))),
157			Err(FieldError::Validation(_))
158		));
159	}
160
161	#[test]
162	fn test_urlfield_optional() {
163		let mut field = URLField::new("website".to_string());
164		field.required = false;
165
166		assert_eq!(field.clean(None).unwrap(), serde_json::json!(""));
167		assert_eq!(
168			field.clean(Some(&serde_json::json!(""))).unwrap(),
169			serde_json::json!("")
170		);
171	}
172}