shelly-data 0.4.0

Data-layer primitives for Shelly LiveView (schemas, changesets, repo, migrations).
Documentation
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>,
}

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 value(&self, field: &str) -> Option<&Value> {
        self.input.get(field)
    }

    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)
    }
}

#[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"));
    }
}