shelly-data 0.5.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"));
    }

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