use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ValidationError {
pub field: String,
pub code: String,
pub message: String,
}
impl ValidationError {
pub fn new(
field: impl Into<String>,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
field: field.into(),
code: code.into(),
message: message.into(),
}
}
}
#[derive(Debug, Clone)]
pub struct Changeset {
input: Map<String, Value>,
errors: Vec<ValidationError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum PathSegment {
Key(String),
Index(usize),
}
impl Changeset {
pub fn from_map(input: Map<String, Value>) -> Self {
Self {
input,
errors: Vec::new(),
}
}
pub fn from_value(value: Value) -> Self {
let input = value.as_object().cloned().unwrap_or_default();
Self::from_map(input)
}
pub fn required(&mut self, fields: &[&str]) -> &mut Self {
for field in fields {
if self.string(field).is_none() {
self.errors.push(ValidationError::new(
*field,
"required",
format!("{field} is required"),
));
}
}
self
}
pub fn string_length(
&mut self,
field: &str,
min: Option<usize>,
max: Option<usize>,
) -> &mut Self {
let Some(value) = self.string(field) else {
return self;
};
let len = value.chars().count();
if let Some(min) = min {
if len < min {
self.errors.push(ValidationError::new(
field,
"length_min",
format!("{field} must be at least {min} characters."),
));
}
}
if let Some(max) = max {
if len > max {
self.errors.push(ValidationError::new(
field,
"length_max",
format!("{field} must be at most {max} characters."),
));
}
}
self
}
pub fn string_contains(&mut self, field: &str, needle: &str, message: &str) -> &mut Self {
let Some(value) = self.string(field) else {
return self;
};
if !value.contains(needle) {
self.errors
.push(ValidationError::new(field, "format", message.to_string()));
}
self
}
pub fn inclusion(&mut self, field: &str, allowed: &[&str], message: &str) -> &mut Self {
let Some(value) = self.string(field) else {
return self;
};
if !allowed.contains(&value) {
self.errors.push(ValidationError::new(
field,
"inclusion",
message.to_string(),
));
}
self
}
pub fn number_range(&mut self, field: &str, min: Option<f64>, max: Option<f64>) -> &mut Self {
let Some(value) = self.number(field) else {
return self;
};
if let Some(min) = min {
if value < min {
self.errors.push(ValidationError::new(
field,
"number_min",
format!("{field} must be >= {min}."),
));
}
}
if let Some(max) = max {
if value > max {
self.errors.push(ValidationError::new(
field,
"number_max",
format!("{field} must be <= {max}."),
));
}
}
self
}
pub fn add_error(
&mut self,
field: impl Into<String>,
code: impl Into<String>,
message: impl Into<String>,
) -> &mut Self {
self.errors.push(ValidationError::new(field, code, message));
self
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn errors(&self) -> &[ValidationError] {
&self.errors
}
pub fn errors_by_field(&self) -> BTreeMap<String, Vec<String>> {
let mut out = BTreeMap::<String, Vec<String>>::new();
for error in &self.errors {
out.entry(error.field.clone())
.or_default()
.push(error.message.clone());
}
out
}
pub fn errors_for_prefix(&self, prefix: &str) -> Vec<ValidationError> {
let prefix = prefix.trim();
if prefix.is_empty() {
return self.errors.clone();
}
self.errors
.iter()
.filter(|error| {
error.field == prefix
|| error.field.starts_with(&format!("{prefix}."))
|| error.field.starts_with(&format!("{prefix}["))
})
.cloned()
.collect()
}
pub fn value(&self, field: &str) -> Option<&Value> {
self.input.get(field)
}
pub fn value_path(&self, path: &str) -> Option<&Value> {
let segments = parse_path(path)?;
value_by_path(&self.input, &segments)
}
pub fn string(&self, field: &str) -> Option<&str> {
self.value(field).and_then(Value::as_str).and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
})
}
pub fn number(&self, field: &str) -> Option<f64> {
self.value(field).and_then(Value::as_f64)
}
pub fn string_path(&self, path: &str) -> Option<&str> {
self.value_path(path)
.and_then(Value::as_str)
.and_then(|value| {
if value.trim().is_empty() {
None
} else {
Some(value)
}
})
}
pub fn number_path(&self, path: &str) -> Option<f64> {
self.value_path(path).and_then(Value::as_f64)
}
pub fn required_paths(&mut self, paths: &[&str]) -> &mut Self {
for path in paths {
if self.string_path(path).is_none() {
self.errors.push(ValidationError::new(
*path,
"required",
format!("{path} is required"),
));
}
}
self
}
pub fn string_length_path(
&mut self,
path: &str,
min: Option<usize>,
max: Option<usize>,
) -> &mut Self {
let Some(value) = self.string_path(path) else {
return self;
};
let len = value.chars().count();
if let Some(min) = min {
if len < min {
self.errors.push(ValidationError::new(
path,
"length_min",
format!("{path} must be at least {min} characters."),
));
}
}
if let Some(max) = max {
if len > max {
self.errors.push(ValidationError::new(
path,
"length_max",
format!("{path} must be at most {max} characters."),
));
}
}
self
}
pub fn string_contains_path(&mut self, path: &str, needle: &str, message: &str) -> &mut Self {
let Some(value) = self.string_path(path) else {
return self;
};
if !value.contains(needle) {
self.errors
.push(ValidationError::new(path, "format", message.to_string()));
}
self
}
pub fn inclusion_path(&mut self, path: &str, allowed: &[&str], message: &str) -> &mut Self {
let Some(value) = self.string_path(path) else {
return self;
};
if !allowed.contains(&value) {
self.errors
.push(ValidationError::new(path, "inclusion", message.to_string()));
}
self
}
pub fn number_range_path(
&mut self,
path: &str,
min: Option<f64>,
max: Option<f64>,
) -> &mut Self {
let Some(value) = self.number_path(path) else {
return self;
};
if let Some(min) = min {
if value < min {
self.errors.push(ValidationError::new(
path,
"number_min",
format!("{path} must be >= {min}."),
));
}
}
if let Some(max) = max {
if value > max {
self.errors.push(ValidationError::new(
path,
"number_max",
format!("{path} must be <= {max}."),
));
}
}
self
}
pub fn nested_changeset(&self, path: &str) -> Option<Self> {
self.value_path(path)
.and_then(Value::as_object)
.cloned()
.map(Self::from_map)
}
pub fn nested_changesets(&self, path: &str) -> Vec<Self> {
self.value_path(path)
.and_then(Value::as_array)
.map(|values| {
values
.iter()
.filter_map(Value::as_object)
.cloned()
.map(Self::from_map)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
}
fn parse_path(path: &str) -> Option<Vec<PathSegment>> {
let trimmed = path.trim();
if trimmed.is_empty() {
return None;
}
let chars = trimmed.chars().collect::<Vec<_>>();
let mut index = 0usize;
let mut segments = Vec::new();
while index < chars.len() {
match chars[index] {
'.' => return None,
'[' => {
index += 1;
let start = index;
while index < chars.len() && chars[index] != ']' {
index += 1;
}
if index >= chars.len() || start == index {
return None;
}
let raw_index = chars[start..index].iter().collect::<String>();
let parsed_index = raw_index.parse::<usize>().ok()?;
segments.push(PathSegment::Index(parsed_index));
index += 1;
}
']' => return None,
_ => {
let start = index;
while index < chars.len() && chars[index] != '.' && chars[index] != '[' {
index += 1;
}
let key = chars[start..index].iter().collect::<String>();
if key.is_empty() {
return None;
}
segments.push(PathSegment::Key(key));
}
}
if index < chars.len() && chars[index] == '.' {
index += 1;
if index >= chars.len() || chars[index] == '.' || chars[index] == ']' {
return None;
}
}
}
if segments.is_empty() {
None
} else {
Some(segments)
}
}
fn value_by_path<'a>(input: &'a Map<String, Value>, segments: &[PathSegment]) -> Option<&'a Value> {
let mut iterator = segments.iter();
let first = iterator.next()?;
let mut current = match first {
PathSegment::Key(key) => input.get(key)?,
PathSegment::Index(_) => return None,
};
for segment in iterator {
current = match segment {
PathSegment::Key(key) => current.as_object()?.get(key)?,
PathSegment::Index(index) => current.as_array()?.get(*index)?,
};
}
Some(current)
}
#[cfg(test)]
mod tests {
use super::Changeset;
use serde_json::json;
#[test]
fn changeset_collects_validation_errors() {
let mut changeset = Changeset::from_value(json!({
"name": "A",
"email": "missing-at"
}));
changeset
.required(&["name", "email", "plan"])
.string_length("name", Some(2), None)
.string_contains("email", "@", "email must include @");
assert!(!changeset.is_valid());
let by_field = changeset.errors_by_field();
assert!(by_field.contains_key("plan"));
assert!(by_field.contains_key("name"));
assert!(by_field.contains_key("email"));
}
#[test]
fn changeset_number_range_covers_min_and_max_errors() {
let mut low = Changeset::from_value(json!({ "score": 2.0 }));
low.number_range("score", Some(3.0), Some(10.0));
assert!(!low.is_valid());
assert!(low
.errors()
.iter()
.any(|error| error.code == "number_min" && error.field == "score"));
let mut high = Changeset::from_value(json!({ "score": 11.0 }));
high.number_range("score", Some(3.0), Some(10.0));
assert!(!high.is_valid());
assert!(high
.errors()
.iter()
.any(|error| error.code == "number_max" && error.field == "score"));
let mut ok = Changeset::from_value(json!({ "score": 7.0 }));
ok.number_range("score", Some(3.0), Some(10.0));
assert!(ok.is_valid());
}
#[test]
fn changeset_helper_methods_cover_optional_and_manual_error_paths() {
let mut changeset = Changeset::from_value(json!({
"name": " ",
"plan": "gold",
"score": "n/a",
"amount": 12.5
}));
changeset
.string_length("name", Some(2), Some(5))
.string_contains("name", "x", "name must include x")
.inclusion("plan", &["free", "pro"], "plan must be one of free/pro")
.number_range("score", Some(1.0), Some(2.0))
.add_error("manual", "custom", "manual validation");
assert!(!changeset.is_valid());
assert!(changeset
.errors()
.iter()
.any(|error| error.code == "inclusion" && error.field == "plan"));
assert!(changeset
.errors()
.iter()
.any(|error| error.code == "custom" && error.field == "manual"));
assert_eq!(changeset.string("name"), None);
assert_eq!(changeset.number("score"), None);
assert_eq!(changeset.number("amount"), Some(12.5));
assert!(changeset.value("missing").is_none());
}
#[test]
fn nested_changeset_paths_support_validation_and_lookup() {
let mut changeset = Changeset::from_value(json!({
"profile": {
"name": "A",
"contacts": [
{ "email": "missing-at", "age": 17 },
{ "email": "ok@example.com", "age": 34 }
]
}
}));
changeset
.required_paths(&["profile.name", "profile.contacts[0].email"])
.string_length_path("profile.name", Some(2), None)
.string_contains_path("profile.contacts[0].email", "@", "email must include @")
.number_range_path("profile.contacts[0].age", Some(18.0), None);
assert_eq!(changeset.string_path("profile.name"), Some("A"));
assert_eq!(
changeset.string_path("profile.contacts[1].email"),
Some("ok@example.com")
);
assert_eq!(changeset.number_path("profile.contacts[0].age"), Some(17.0));
assert!(changeset.value_path("profile.contacts[7].email").is_none());
assert!(!changeset.is_valid());
assert_eq!(changeset.errors_for_prefix("profile.contacts").len(), 2);
}
#[test]
fn nested_changesets_extract_child_objects_and_arrays() {
let changeset = Changeset::from_value(json!({
"profile": {
"name": "Ada"
},
"contacts": [
{ "email": "a@example.com" },
{ "email": "b@example.com" }
]
}));
let profile = changeset
.nested_changeset("profile")
.expect("profile should exist");
assert_eq!(profile.string("name"), Some("Ada"));
let contacts = changeset.nested_changesets("contacts");
assert_eq!(contacts.len(), 2);
assert_eq!(contacts[0].string("email"), Some("a@example.com"));
assert_eq!(contacts[1].string("email"), Some("b@example.com"));
}
}