1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6pub struct ValidationError {
7 pub field: String,
8 pub code: String,
9 pub message: String,
10}
11
12impl ValidationError {
13 pub fn new(
14 field: impl Into<String>,
15 code: impl Into<String>,
16 message: impl Into<String>,
17 ) -> Self {
18 Self {
19 field: field.into(),
20 code: code.into(),
21 message: message.into(),
22 }
23 }
24}
25
26#[derive(Debug, Clone)]
27pub struct Changeset {
28 input: Map<String, Value>,
29 errors: Vec<ValidationError>,
30}
31
32impl Changeset {
33 pub fn from_map(input: Map<String, Value>) -> Self {
34 Self {
35 input,
36 errors: Vec::new(),
37 }
38 }
39
40 pub fn from_value(value: Value) -> Self {
41 let input = value.as_object().cloned().unwrap_or_default();
42 Self::from_map(input)
43 }
44
45 pub fn required(&mut self, fields: &[&str]) -> &mut Self {
46 for field in fields {
47 if self.string(field).is_none() {
48 self.errors.push(ValidationError::new(
49 *field,
50 "required",
51 format!("{field} is required"),
52 ));
53 }
54 }
55 self
56 }
57
58 pub fn string_length(
59 &mut self,
60 field: &str,
61 min: Option<usize>,
62 max: Option<usize>,
63 ) -> &mut Self {
64 let Some(value) = self.string(field) else {
65 return self;
66 };
67 let len = value.chars().count();
68 if let Some(min) = min {
69 if len < min {
70 self.errors.push(ValidationError::new(
71 field,
72 "length_min",
73 format!("{field} must be at least {min} characters."),
74 ));
75 }
76 }
77 if let Some(max) = max {
78 if len > max {
79 self.errors.push(ValidationError::new(
80 field,
81 "length_max",
82 format!("{field} must be at most {max} characters."),
83 ));
84 }
85 }
86 self
87 }
88
89 pub fn string_contains(&mut self, field: &str, needle: &str, message: &str) -> &mut Self {
90 let Some(value) = self.string(field) else {
91 return self;
92 };
93 if !value.contains(needle) {
94 self.errors
95 .push(ValidationError::new(field, "format", message.to_string()));
96 }
97 self
98 }
99
100 pub fn inclusion(&mut self, field: &str, allowed: &[&str], message: &str) -> &mut Self {
101 let Some(value) = self.string(field) else {
102 return self;
103 };
104 if !allowed.contains(&value) {
105 self.errors.push(ValidationError::new(
106 field,
107 "inclusion",
108 message.to_string(),
109 ));
110 }
111 self
112 }
113
114 pub fn number_range(&mut self, field: &str, min: Option<f64>, max: Option<f64>) -> &mut Self {
115 let Some(value) = self.number(field) else {
116 return self;
117 };
118 if let Some(min) = min {
119 if value < min {
120 self.errors.push(ValidationError::new(
121 field,
122 "number_min",
123 format!("{field} must be >= {min}."),
124 ));
125 }
126 }
127 if let Some(max) = max {
128 if value > max {
129 self.errors.push(ValidationError::new(
130 field,
131 "number_max",
132 format!("{field} must be <= {max}."),
133 ));
134 }
135 }
136 self
137 }
138
139 pub fn add_error(
140 &mut self,
141 field: impl Into<String>,
142 code: impl Into<String>,
143 message: impl Into<String>,
144 ) -> &mut Self {
145 self.errors.push(ValidationError::new(field, code, message));
146 self
147 }
148
149 pub fn is_valid(&self) -> bool {
150 self.errors.is_empty()
151 }
152
153 pub fn errors(&self) -> &[ValidationError] {
154 &self.errors
155 }
156
157 pub fn errors_by_field(&self) -> BTreeMap<String, Vec<String>> {
158 let mut out = BTreeMap::<String, Vec<String>>::new();
159 for error in &self.errors {
160 out.entry(error.field.clone())
161 .or_default()
162 .push(error.message.clone());
163 }
164 out
165 }
166
167 pub fn value(&self, field: &str) -> Option<&Value> {
168 self.input.get(field)
169 }
170
171 pub fn string(&self, field: &str) -> Option<&str> {
172 self.value(field).and_then(Value::as_str).and_then(|value| {
173 if value.trim().is_empty() {
174 None
175 } else {
176 Some(value)
177 }
178 })
179 }
180
181 pub fn number(&self, field: &str) -> Option<f64> {
182 self.value(field).and_then(Value::as_f64)
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::Changeset;
189 use serde_json::json;
190
191 #[test]
192 fn changeset_collects_validation_errors() {
193 let mut changeset = Changeset::from_value(json!({
194 "name": "A",
195 "email": "missing-at"
196 }));
197 changeset
198 .required(&["name", "email", "plan"])
199 .string_length("name", Some(2), None)
200 .string_contains("email", "@", "email must include @");
201
202 assert!(!changeset.is_valid());
203 let by_field = changeset.errors_by_field();
204 assert!(by_field.contains_key("plan"));
205 assert!(by_field.contains_key("name"));
206 assert!(by_field.contains_key("email"));
207 }
208
209 #[test]
210 fn changeset_number_range_covers_min_and_max_errors() {
211 let mut low = Changeset::from_value(json!({ "score": 2.0 }));
212 low.number_range("score", Some(3.0), Some(10.0));
213 assert!(!low.is_valid());
214 assert!(low
215 .errors()
216 .iter()
217 .any(|error| error.code == "number_min" && error.field == "score"));
218
219 let mut high = Changeset::from_value(json!({ "score": 11.0 }));
220 high.number_range("score", Some(3.0), Some(10.0));
221 assert!(!high.is_valid());
222 assert!(high
223 .errors()
224 .iter()
225 .any(|error| error.code == "number_max" && error.field == "score"));
226
227 let mut ok = Changeset::from_value(json!({ "score": 7.0 }));
228 ok.number_range("score", Some(3.0), Some(10.0));
229 assert!(ok.is_valid());
230 }
231
232 #[test]
233 fn changeset_helper_methods_cover_optional_and_manual_error_paths() {
234 let mut changeset = Changeset::from_value(json!({
235 "name": " ",
236 "plan": "gold",
237 "score": "n/a",
238 "amount": 12.5
239 }));
240
241 changeset
242 .string_length("name", Some(2), Some(5))
243 .string_contains("name", "x", "name must include x")
244 .inclusion("plan", &["free", "pro"], "plan must be one of free/pro")
245 .number_range("score", Some(1.0), Some(2.0))
246 .add_error("manual", "custom", "manual validation");
247
248 assert!(!changeset.is_valid());
249 assert!(changeset
250 .errors()
251 .iter()
252 .any(|error| error.code == "inclusion" && error.field == "plan"));
253 assert!(changeset
254 .errors()
255 .iter()
256 .any(|error| error.code == "custom" && error.field == "manual"));
257 assert_eq!(changeset.string("name"), None);
258 assert_eq!(changeset.number("score"), None);
259 assert_eq!(changeset.number("amount"), Some(12.5));
260 assert!(changeset.value("missing").is_none());
261 }
262}