Skip to main content

reinhardt_forms/fields/
boolean_field.rs

1//! Boolean field with Django-compatible required semantics
2
3use crate::field::{FieldError, FieldResult, FormField, Widget};
4
5/// Boolean field for checkbox input
6#[derive(Debug, Clone)]
7pub struct BooleanField {
8	/// The field name used as the form data key.
9	pub name: String,
10	/// Optional human-readable label for display.
11	pub label: Option<String>,
12	/// Whether the checkbox must be checked (true) to pass validation.
13	pub required: bool,
14	/// Optional help text displayed alongside the field.
15	pub help_text: Option<String>,
16	/// Optional initial (default) value for the field.
17	pub initial: Option<serde_json::Value>,
18}
19
20impl BooleanField {
21	/// Create a new BooleanField with the given name
22	///
23	/// # Examples
24	///
25	/// ```
26	/// use reinhardt_forms::fields::BooleanField;
27	///
28	/// let field = BooleanField::new("accept_terms".to_string());
29	/// assert_eq!(field.name, "accept_terms");
30	/// assert!(!field.required);
31	/// ```
32	pub fn new(name: String) -> Self {
33		Self {
34			name,
35			label: None,
36			required: false,
37			help_text: None,
38			initial: None,
39		}
40	}
41
42	/// Set the field as required
43	///
44	/// # Examples
45	///
46	/// ```
47	/// use reinhardt_forms::fields::BooleanField;
48	///
49	/// let field = BooleanField::new("terms".to_string()).required();
50	/// assert!(field.required);
51	/// ```
52	pub fn required(mut self) -> Self {
53		self.required = true;
54		self
55	}
56
57	/// Set the label for the field
58	///
59	/// # Examples
60	///
61	/// ```
62	/// use reinhardt_forms::fields::BooleanField;
63	///
64	/// let field = BooleanField::new("agree".to_string()).with_label("I agree");
65	/// assert_eq!(field.label, Some("I agree".to_string()));
66	/// ```
67	pub fn with_label(mut self, label: impl Into<String>) -> Self {
68		self.label = Some(label.into());
69		self
70	}
71
72	/// Set the help text for the field
73	///
74	/// # Examples
75	///
76	/// ```
77	/// use reinhardt_forms::fields::BooleanField;
78	///
79	/// let field = BooleanField::new("newsletter".to_string()).with_help_text("Subscribe to newsletter");
80	/// assert_eq!(field.help_text, Some("Subscribe to newsletter".to_string()));
81	/// ```
82	pub fn with_help_text(mut self, help_text: impl Into<String>) -> Self {
83		self.help_text = Some(help_text.into());
84		self
85	}
86
87	/// Set the initial value for the field
88	///
89	/// # Examples
90	///
91	/// ```
92	/// use reinhardt_forms::fields::BooleanField;
93	///
94	/// let field = BooleanField::new("enabled".to_string()).with_initial(true);
95	/// assert_eq!(field.initial, Some(serde_json::json!(true)));
96	/// ```
97	pub fn with_initial(mut self, initial: bool) -> Self {
98		self.initial = Some(serde_json::json!(initial));
99		self
100	}
101}
102
103// Note: Default trait is not implemented because BooleanField requires a name
104
105impl FormField for BooleanField {
106	fn name(&self) -> &str {
107		&self.name
108	}
109
110	fn label(&self) -> Option<&str> {
111		self.label.as_deref()
112	}
113
114	fn required(&self) -> bool {
115		self.required
116	}
117
118	fn help_text(&self) -> Option<&str> {
119		self.help_text.as_deref()
120	}
121
122	fn widget(&self) -> &Widget {
123		&Widget::CheckboxInput
124	}
125
126	fn initial(&self) -> Option<&serde_json::Value> {
127		self.initial.as_ref()
128	}
129
130	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
131		match value {
132			None => {
133				if self.required {
134					Err(FieldError::Required(self.name.clone()))
135				} else {
136					Ok(serde_json::Value::Bool(false))
137				}
138			}
139			Some(v) => {
140				// Convert various types to boolean (Django-like behavior)
141				let b = match v {
142					serde_json::Value::Bool(b) => *b,
143					serde_json::Value::String(s) => {
144						// String conversion: "false", "False", "0", "" -> false, others -> true
145						let s_lower = s.to_lowercase();
146						!(s.is_empty() || s_lower == "false" || s == "0")
147					}
148					serde_json::Value::Number(n) => {
149						// Numbers: 0 -> false, non-zero -> true
150						if let Some(i) = n.as_i64() {
151							i != 0
152						} else if let Some(f) = n.as_f64() {
153							f != 0.0
154						} else {
155							true
156						}
157					}
158					serde_json::Value::Null => false,
159					_ => {
160						return Err(FieldError::Validation(
161							"Cannot convert to boolean".to_string(),
162						));
163					}
164				};
165
166				// Django behavior: a required BooleanField must be True.
167				// This ensures consent checkboxes (e.g., "I agree to Terms")
168				// cannot be submitted unchecked.
169				if self.required && !b {
170					return Err(FieldError::Required(self.name.clone()));
171				}
172
173				Ok(serde_json::Value::Bool(b))
174			}
175		}
176	}
177}
178
179#[cfg(test)]
180mod tests {
181	use super::*;
182	use rstest::rstest;
183	use serde_json::json;
184
185	#[rstest]
186	fn test_required_boolean_rejects_false() {
187		// Arrange: required BooleanField should require true (Django behavior)
188		let field = BooleanField::new("terms".to_string()).required();
189
190		// Act & Assert: false should be rejected for required BooleanField
191		assert!(
192			field.clean(Some(&json!(false))).is_err(),
193			"required BooleanField should reject false"
194		);
195	}
196
197	#[rstest]
198	fn test_required_boolean_accepts_true() {
199		// Arrange
200		let field = BooleanField::new("terms".to_string()).required();
201
202		// Act
203		let result = field.clean(Some(&json!(true)));
204
205		// Assert
206		assert_eq!(result.unwrap(), json!(true));
207	}
208
209	#[rstest]
210	fn test_required_boolean_rejects_none() {
211		// Arrange
212		let field = BooleanField::new("terms".to_string()).required();
213
214		// Act & Assert
215		assert!(field.clean(None).is_err());
216	}
217
218	#[rstest]
219	fn test_required_boolean_rejects_false_string() {
220		// Arrange
221		let field = BooleanField::new("terms".to_string()).required();
222
223		// Act & Assert: "false" string converts to false, which should be rejected
224		assert!(field.clean(Some(&json!("false"))).is_err());
225		assert!(field.clean(Some(&json!("0"))).is_err());
226		assert!(field.clean(Some(&json!(""))).is_err());
227	}
228
229	#[rstest]
230	fn test_required_boolean_rejects_zero() {
231		// Arrange
232		let field = BooleanField::new("terms".to_string()).required();
233
234		// Act & Assert: numeric 0 converts to false, which should be rejected
235		assert!(field.clean(Some(&json!(0))).is_err());
236	}
237
238	#[rstest]
239	fn test_optional_boolean_accepts_false() {
240		// Arrange: optional BooleanField should accept false
241		let field = BooleanField::new("newsletter".to_string());
242
243		// Act
244		let result = field.clean(Some(&json!(false)));
245
246		// Assert
247		assert_eq!(result.unwrap(), json!(false));
248	}
249
250	#[rstest]
251	fn test_optional_boolean_accepts_none() {
252		// Arrange
253		let field = BooleanField::new("newsletter".to_string());
254
255		// Act
256		let result = field.clean(None);
257
258		// Assert
259		assert_eq!(result.unwrap(), json!(false));
260	}
261
262	#[rstest]
263	fn test_required_boolean_accepts_truthy_string() {
264		// Arrange
265		let field = BooleanField::new("terms".to_string()).required();
266
267		// Act & Assert: truthy strings should be accepted
268		assert_eq!(field.clean(Some(&json!("true"))).unwrap(), json!(true));
269		assert_eq!(field.clean(Some(&json!("yes"))).unwrap(), json!(true));
270		assert_eq!(field.clean(Some(&json!("1"))).unwrap(), json!(true));
271	}
272
273	#[rstest]
274	fn test_required_boolean_rejects_null() {
275		// Arrange
276		let field = BooleanField::new("terms".to_string()).required();
277
278		// Act & Assert: null converts to false, which should be rejected
279		assert!(field.clean(Some(&json!(null))).is_err());
280	}
281}