Skip to main content

shelly_data/
changeset.rs

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}