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
32#[derive(Debug, Clone, PartialEq, Eq)]
33enum PathSegment {
34 Key(String),
35 Index(usize),
36}
37
38impl Changeset {
39 pub fn from_map(input: Map<String, Value>) -> Self {
40 Self {
41 input,
42 errors: Vec::new(),
43 }
44 }
45
46 pub fn from_value(value: Value) -> Self {
47 let input = value.as_object().cloned().unwrap_or_default();
48 Self::from_map(input)
49 }
50
51 pub fn required(&mut self, fields: &[&str]) -> &mut Self {
52 for field in fields {
53 if self.string(field).is_none() {
54 self.errors.push(ValidationError::new(
55 *field,
56 "required",
57 format!("{field} is required"),
58 ));
59 }
60 }
61 self
62 }
63
64 pub fn string_length(
65 &mut self,
66 field: &str,
67 min: Option<usize>,
68 max: Option<usize>,
69 ) -> &mut Self {
70 let Some(value) = self.string(field) else {
71 return self;
72 };
73 let len = value.chars().count();
74 if let Some(min) = min {
75 if len < min {
76 self.errors.push(ValidationError::new(
77 field,
78 "length_min",
79 format!("{field} must be at least {min} characters."),
80 ));
81 }
82 }
83 if let Some(max) = max {
84 if len > max {
85 self.errors.push(ValidationError::new(
86 field,
87 "length_max",
88 format!("{field} must be at most {max} characters."),
89 ));
90 }
91 }
92 self
93 }
94
95 pub fn string_contains(&mut self, field: &str, needle: &str, message: &str) -> &mut Self {
96 let Some(value) = self.string(field) else {
97 return self;
98 };
99 if !value.contains(needle) {
100 self.errors
101 .push(ValidationError::new(field, "format", message.to_string()));
102 }
103 self
104 }
105
106 pub fn inclusion(&mut self, field: &str, allowed: &[&str], message: &str) -> &mut Self {
107 let Some(value) = self.string(field) else {
108 return self;
109 };
110 if !allowed.contains(&value) {
111 self.errors.push(ValidationError::new(
112 field,
113 "inclusion",
114 message.to_string(),
115 ));
116 }
117 self
118 }
119
120 pub fn number_range(&mut self, field: &str, min: Option<f64>, max: Option<f64>) -> &mut Self {
121 let Some(value) = self.number(field) else {
122 return self;
123 };
124 if let Some(min) = min {
125 if value < min {
126 self.errors.push(ValidationError::new(
127 field,
128 "number_min",
129 format!("{field} must be >= {min}."),
130 ));
131 }
132 }
133 if let Some(max) = max {
134 if value > max {
135 self.errors.push(ValidationError::new(
136 field,
137 "number_max",
138 format!("{field} must be <= {max}."),
139 ));
140 }
141 }
142 self
143 }
144
145 pub fn add_error(
146 &mut self,
147 field: impl Into<String>,
148 code: impl Into<String>,
149 message: impl Into<String>,
150 ) -> &mut Self {
151 self.errors.push(ValidationError::new(field, code, message));
152 self
153 }
154
155 pub fn is_valid(&self) -> bool {
156 self.errors.is_empty()
157 }
158
159 pub fn errors(&self) -> &[ValidationError] {
160 &self.errors
161 }
162
163 pub fn errors_by_field(&self) -> BTreeMap<String, Vec<String>> {
164 let mut out = BTreeMap::<String, Vec<String>>::new();
165 for error in &self.errors {
166 out.entry(error.field.clone())
167 .or_default()
168 .push(error.message.clone());
169 }
170 out
171 }
172
173 pub fn errors_for_prefix(&self, prefix: &str) -> Vec<ValidationError> {
174 let prefix = prefix.trim();
175 if prefix.is_empty() {
176 return self.errors.clone();
177 }
178 self.errors
179 .iter()
180 .filter(|error| {
181 error.field == prefix
182 || error.field.starts_with(&format!("{prefix}."))
183 || error.field.starts_with(&format!("{prefix}["))
184 })
185 .cloned()
186 .collect()
187 }
188
189 pub fn value(&self, field: &str) -> Option<&Value> {
190 self.input.get(field)
191 }
192
193 pub fn value_path(&self, path: &str) -> Option<&Value> {
194 let segments = parse_path(path)?;
195 value_by_path(&self.input, &segments)
196 }
197
198 pub fn string(&self, field: &str) -> Option<&str> {
199 self.value(field).and_then(Value::as_str).and_then(|value| {
200 if value.trim().is_empty() {
201 None
202 } else {
203 Some(value)
204 }
205 })
206 }
207
208 pub fn number(&self, field: &str) -> Option<f64> {
209 self.value(field).and_then(Value::as_f64)
210 }
211
212 pub fn string_path(&self, path: &str) -> Option<&str> {
213 self.value_path(path)
214 .and_then(Value::as_str)
215 .and_then(|value| {
216 if value.trim().is_empty() {
217 None
218 } else {
219 Some(value)
220 }
221 })
222 }
223
224 pub fn number_path(&self, path: &str) -> Option<f64> {
225 self.value_path(path).and_then(Value::as_f64)
226 }
227
228 pub fn required_paths(&mut self, paths: &[&str]) -> &mut Self {
229 for path in paths {
230 if self.string_path(path).is_none() {
231 self.errors.push(ValidationError::new(
232 *path,
233 "required",
234 format!("{path} is required"),
235 ));
236 }
237 }
238 self
239 }
240
241 pub fn string_length_path(
242 &mut self,
243 path: &str,
244 min: Option<usize>,
245 max: Option<usize>,
246 ) -> &mut Self {
247 let Some(value) = self.string_path(path) else {
248 return self;
249 };
250 let len = value.chars().count();
251 if let Some(min) = min {
252 if len < min {
253 self.errors.push(ValidationError::new(
254 path,
255 "length_min",
256 format!("{path} must be at least {min} characters."),
257 ));
258 }
259 }
260 if let Some(max) = max {
261 if len > max {
262 self.errors.push(ValidationError::new(
263 path,
264 "length_max",
265 format!("{path} must be at most {max} characters."),
266 ));
267 }
268 }
269 self
270 }
271
272 pub fn string_contains_path(&mut self, path: &str, needle: &str, message: &str) -> &mut Self {
273 let Some(value) = self.string_path(path) else {
274 return self;
275 };
276 if !value.contains(needle) {
277 self.errors
278 .push(ValidationError::new(path, "format", message.to_string()));
279 }
280 self
281 }
282
283 pub fn inclusion_path(&mut self, path: &str, allowed: &[&str], message: &str) -> &mut Self {
284 let Some(value) = self.string_path(path) else {
285 return self;
286 };
287 if !allowed.contains(&value) {
288 self.errors
289 .push(ValidationError::new(path, "inclusion", message.to_string()));
290 }
291 self
292 }
293
294 pub fn number_range_path(
295 &mut self,
296 path: &str,
297 min: Option<f64>,
298 max: Option<f64>,
299 ) -> &mut Self {
300 let Some(value) = self.number_path(path) else {
301 return self;
302 };
303 if let Some(min) = min {
304 if value < min {
305 self.errors.push(ValidationError::new(
306 path,
307 "number_min",
308 format!("{path} must be >= {min}."),
309 ));
310 }
311 }
312 if let Some(max) = max {
313 if value > max {
314 self.errors.push(ValidationError::new(
315 path,
316 "number_max",
317 format!("{path} must be <= {max}."),
318 ));
319 }
320 }
321 self
322 }
323
324 pub fn nested_changeset(&self, path: &str) -> Option<Self> {
325 self.value_path(path)
326 .and_then(Value::as_object)
327 .cloned()
328 .map(Self::from_map)
329 }
330
331 pub fn nested_changesets(&self, path: &str) -> Vec<Self> {
332 self.value_path(path)
333 .and_then(Value::as_array)
334 .map(|values| {
335 values
336 .iter()
337 .filter_map(Value::as_object)
338 .cloned()
339 .map(Self::from_map)
340 .collect::<Vec<_>>()
341 })
342 .unwrap_or_default()
343 }
344}
345
346fn parse_path(path: &str) -> Option<Vec<PathSegment>> {
347 let trimmed = path.trim();
348 if trimmed.is_empty() {
349 return None;
350 }
351 let chars = trimmed.chars().collect::<Vec<_>>();
352 let mut index = 0usize;
353 let mut segments = Vec::new();
354
355 while index < chars.len() {
356 match chars[index] {
357 '.' => return None,
358 '[' => {
359 index += 1;
360 let start = index;
361 while index < chars.len() && chars[index] != ']' {
362 index += 1;
363 }
364 if index >= chars.len() || start == index {
365 return None;
366 }
367 let raw_index = chars[start..index].iter().collect::<String>();
368 let parsed_index = raw_index.parse::<usize>().ok()?;
369 segments.push(PathSegment::Index(parsed_index));
370 index += 1;
371 }
372 ']' => return None,
373 _ => {
374 let start = index;
375 while index < chars.len() && chars[index] != '.' && chars[index] != '[' {
376 index += 1;
377 }
378 let key = chars[start..index].iter().collect::<String>();
379 if key.is_empty() {
380 return None;
381 }
382 segments.push(PathSegment::Key(key));
383 }
384 }
385
386 if index < chars.len() && chars[index] == '.' {
387 index += 1;
388 if index >= chars.len() || chars[index] == '.' || chars[index] == ']' {
389 return None;
390 }
391 }
392 }
393
394 if segments.is_empty() {
395 None
396 } else {
397 Some(segments)
398 }
399}
400
401fn value_by_path<'a>(input: &'a Map<String, Value>, segments: &[PathSegment]) -> Option<&'a Value> {
402 let mut iterator = segments.iter();
403 let first = iterator.next()?;
404 let mut current = match first {
405 PathSegment::Key(key) => input.get(key)?,
406 PathSegment::Index(_) => return None,
407 };
408
409 for segment in iterator {
410 current = match segment {
411 PathSegment::Key(key) => current.as_object()?.get(key)?,
412 PathSegment::Index(index) => current.as_array()?.get(*index)?,
413 };
414 }
415
416 Some(current)
417}
418
419#[cfg(test)]
420mod tests {
421 use super::Changeset;
422 use serde_json::json;
423
424 #[test]
425 fn changeset_collects_validation_errors() {
426 let mut changeset = Changeset::from_value(json!({
427 "name": "A",
428 "email": "missing-at"
429 }));
430 changeset
431 .required(&["name", "email", "plan"])
432 .string_length("name", Some(2), None)
433 .string_contains("email", "@", "email must include @");
434
435 assert!(!changeset.is_valid());
436 let by_field = changeset.errors_by_field();
437 assert!(by_field.contains_key("plan"));
438 assert!(by_field.contains_key("name"));
439 assert!(by_field.contains_key("email"));
440 }
441
442 #[test]
443 fn changeset_number_range_covers_min_and_max_errors() {
444 let mut low = Changeset::from_value(json!({ "score": 2.0 }));
445 low.number_range("score", Some(3.0), Some(10.0));
446 assert!(!low.is_valid());
447 assert!(low
448 .errors()
449 .iter()
450 .any(|error| error.code == "number_min" && error.field == "score"));
451
452 let mut high = Changeset::from_value(json!({ "score": 11.0 }));
453 high.number_range("score", Some(3.0), Some(10.0));
454 assert!(!high.is_valid());
455 assert!(high
456 .errors()
457 .iter()
458 .any(|error| error.code == "number_max" && error.field == "score"));
459
460 let mut ok = Changeset::from_value(json!({ "score": 7.0 }));
461 ok.number_range("score", Some(3.0), Some(10.0));
462 assert!(ok.is_valid());
463 }
464
465 #[test]
466 fn changeset_helper_methods_cover_optional_and_manual_error_paths() {
467 let mut changeset = Changeset::from_value(json!({
468 "name": " ",
469 "plan": "gold",
470 "score": "n/a",
471 "amount": 12.5
472 }));
473
474 changeset
475 .string_length("name", Some(2), Some(5))
476 .string_contains("name", "x", "name must include x")
477 .inclusion("plan", &["free", "pro"], "plan must be one of free/pro")
478 .number_range("score", Some(1.0), Some(2.0))
479 .add_error("manual", "custom", "manual validation");
480
481 assert!(!changeset.is_valid());
482 assert!(changeset
483 .errors()
484 .iter()
485 .any(|error| error.code == "inclusion" && error.field == "plan"));
486 assert!(changeset
487 .errors()
488 .iter()
489 .any(|error| error.code == "custom" && error.field == "manual"));
490 assert_eq!(changeset.string("name"), None);
491 assert_eq!(changeset.number("score"), None);
492 assert_eq!(changeset.number("amount"), Some(12.5));
493 assert!(changeset.value("missing").is_none());
494 }
495
496 #[test]
497 fn nested_changeset_paths_support_validation_and_lookup() {
498 let mut changeset = Changeset::from_value(json!({
499 "profile": {
500 "name": "A",
501 "contacts": [
502 { "email": "missing-at", "age": 17 },
503 { "email": "ok@example.com", "age": 34 }
504 ]
505 }
506 }));
507
508 changeset
509 .required_paths(&["profile.name", "profile.contacts[0].email"])
510 .string_length_path("profile.name", Some(2), None)
511 .string_contains_path("profile.contacts[0].email", "@", "email must include @")
512 .number_range_path("profile.contacts[0].age", Some(18.0), None);
513
514 assert_eq!(changeset.string_path("profile.name"), Some("A"));
515 assert_eq!(
516 changeset.string_path("profile.contacts[1].email"),
517 Some("ok@example.com")
518 );
519 assert_eq!(changeset.number_path("profile.contacts[0].age"), Some(17.0));
520 assert!(changeset.value_path("profile.contacts[7].email").is_none());
521 assert!(!changeset.is_valid());
522 assert_eq!(changeset.errors_for_prefix("profile.contacts").len(), 2);
523 }
524
525 #[test]
526 fn nested_changesets_extract_child_objects_and_arrays() {
527 let changeset = Changeset::from_value(json!({
528 "profile": {
529 "name": "Ada"
530 },
531 "contacts": [
532 { "email": "a@example.com" },
533 { "email": "b@example.com" }
534 ]
535 }));
536
537 let profile = changeset
538 .nested_changeset("profile")
539 .expect("profile should exist");
540 assert_eq!(profile.string("name"), Some("Ada"));
541
542 let contacts = changeset.nested_changesets("contacts");
543 assert_eq!(contacts.len(), 2);
544 assert_eq!(contacts[0].string("email"), Some("a@example.com"));
545 assert_eq!(contacts[1].string("email"), Some("b@example.com"));
546 }
547}