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}