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