codetics-ocpp-core 0.1.0

Codetics OCPP: version-independent OCPP-J building blocks (RPC framing, error codes, RFC 3339 date-time, validation, subprotocol negotiation, message ids)
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)

//! Schema constraint validation for generated message types.
//!
//! `serde` enforces structure (required fields, types, enum values, unknown
//! fields). The generated `Validate` impls enforce the remaining JSON-schema
//! constraints: `maxLength`, `minimum`/`maximum`, `minItems`/`maxItems` and
//! `multipleOf`.

use std::fmt;

use crate::ErrorCode;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViolationKind {
    /// Not valid JSON / unknown property / structurally wrong.
    Format,
    /// Required field missing, or array cardinality violated.
    Occurrence,
    /// Value not allowed (enum, length, range).
    Property,
    /// Wrong JSON data type.
    Type,
}

impl ViolationKind {
    pub fn error_code(self) -> ErrorCode {
        match self {
            ViolationKind::Format => ErrorCode::FormatViolation,
            ViolationKind::Occurrence => ErrorCode::OccurrenceConstraintViolation,
            ViolationKind::Property => ErrorCode::PropertyConstraintViolation,
            ViolationKind::Type => ErrorCode::TypeConstraintViolation,
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct Violation {
    pub kind: ViolationKind,
    pub message: String,
}

impl Violation {
    pub fn new(kind: ViolationKind, message: impl Into<String>) -> Self {
        Violation {
            kind,
            message: message.into(),
        }
    }

    /// Classifies a serde_json error. The messages are produced by serde's
    /// default `de::Error` constructors, whose wording is part of serde's API.
    pub fn from_serde(e: serde_json::Error) -> Self {
        use serde_json::error::Category;
        let msg = e.to_string();
        let kind = match e.classify() {
            Category::Syntax | Category::Eof | Category::Io => ViolationKind::Format,
            Category::Data => {
                if msg.starts_with("missing field") {
                    ViolationKind::Occurrence
                } else if msg.starts_with("invalid type") {
                    ViolationKind::Type
                } else if msg.starts_with("unknown variant")
                    || msg.starts_with("invalid value")
                    || msg.starts_with("invalid length")
                {
                    ViolationKind::Property
                } else {
                    // unknown field, and anything else structural
                    ViolationKind::Format
                }
            }
        };
        Violation { kind, message: msg }
    }
}

impl fmt::Display for Violation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.message)
    }
}

impl std::error::Error for Violation {}

/// A JSON path built on the stack; only rendered when an error occurs.
#[derive(Clone, Copy)]
pub struct Path<'a> {
    parent: Option<&'a Path<'a>>,
    seg: Seg<'a>,
}

#[derive(Clone, Copy)]
enum Seg<'a> {
    Root,
    Field(&'a str),
    Index(usize),
}

impl<'a> Path<'a> {
    pub const fn root() -> Path<'static> {
        Path {
            parent: None,
            seg: Seg::Root,
        }
    }
    pub fn field(&'a self, name: &'a str) -> Path<'a> {
        Path {
            parent: Some(self),
            seg: Seg::Field(name),
        }
    }
    pub fn index(&'a self, i: usize) -> Path<'a> {
        Path {
            parent: Some(self),
            seg: Seg::Index(i),
        }
    }
}

impl fmt::Display for Path<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(p) = self.parent {
            fmt::Display::fmt(p, f)?;
        }
        match self.seg {
            Seg::Root => f.write_str("$"),
            Seg::Field(n) => write!(f, ".{n}"),
            Seg::Index(i) => write!(f, "[{i}]"),
        }
    }
}

pub trait Validate {
    fn validate(&self, path: &Path<'_>) -> Result<(), Violation>;
}

impl<T: Validate> Validate for Option<T> {
    fn validate(&self, path: &Path<'_>) -> Result<(), Violation> {
        match self {
            Some(v) => v.validate(path),
            None => Ok(()),
        }
    }
}

impl<T: Validate> Validate for Vec<T> {
    fn validate(&self, path: &Path<'_>) -> Result<(), Violation> {
        for (i, v) in self.iter().enumerate() {
            v.validate(&path.index(i))?;
        }
        Ok(())
    }
}

impl<T: Validate> Validate for Box<T> {
    fn validate(&self, path: &Path<'_>) -> Result<(), Violation> {
        (**self).validate(path)
    }
}

macro_rules! no_constraints {
    ($($t:ty),*) => { $(impl Validate for $t { #[inline] fn validate(&self, _: &Path<'_>) -> Result<(), Violation> { Ok(()) } })* };
}
no_constraints!(String, i64, f64, bool, crate::DateTime, serde_json::Value);

// Helpers called by generated code.

#[inline]
pub fn max_length(v: &str, max: usize, path: &Path<'_>) -> Result<(), Violation> {
    // JSON-schema maxLength counts characters (code points), not bytes.
    if v.len() > max && v.chars().count() > max {
        return Err(Violation::new(
            ViolationKind::Property,
            format!("{path}: string longer than {max} characters"),
        ));
    }
    Ok(())
}

#[inline]
pub fn minimum<T: PartialOrd + fmt::Display>(
    v: T,
    min: T,
    path: &Path<'_>,
) -> Result<(), Violation> {
    if v < min {
        return Err(Violation::new(
            ViolationKind::Property,
            format!("{path}: value {v} is below minimum {min}"),
        ));
    }
    Ok(())
}

#[inline]
pub fn maximum<T: PartialOrd + fmt::Display>(
    v: T,
    max: T,
    path: &Path<'_>,
) -> Result<(), Violation> {
    if v > max {
        return Err(Violation::new(
            ViolationKind::Property,
            format!("{path}: value {v} is above maximum {max}"),
        ));
    }
    Ok(())
}

#[inline]
pub fn items<T>(
    v: &[T],
    min: Option<usize>,
    max: Option<usize>,
    path: &Path<'_>,
) -> Result<(), Violation> {
    if let Some(min) = min
        && v.len() < min
    {
        return Err(Violation::new(
            ViolationKind::Occurrence,
            format!("{path}: expected at least {min} items, got {}", v.len()),
        ));
    }
    if let Some(max) = max
        && v.len() > max
    {
        return Err(Violation::new(
            ViolationKind::Occurrence,
            format!("{path}: expected at most {max} items, got {}", v.len()),
        ));
    }
    Ok(())
}

#[inline]
pub fn multiple_of(v: f64, step: f64, path: &Path<'_>) -> Result<(), Violation> {
    let q = v / step;
    if (q - q.round()).abs() > 1e-6 {
        return Err(Violation::new(
            ViolationKind::Property,
            format!("{path}: value {v} is not a multiple of {step}"),
        ));
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn path_rendering() {
        let r = Path::root();
        let a = r.field("a");
        let b = a.index(3);
        let c = b.field("c");
        assert_eq!(c.to_string(), "$.a[3].c");
    }

    #[test]
    fn serde_classification() {
        #[derive(serde::Deserialize, Debug)]
        #[serde(deny_unknown_fields)]
        #[allow(dead_code)]
        struct T {
            a: i64,
            e: Option<E>,
        }
        #[derive(serde::Deserialize, Debug)]
        enum E {
            X,
        }
        let k = |s: &str| Violation::from_serde(serde_json::from_str::<T>(s).unwrap_err()).kind;
        assert_eq!(k("{}"), ViolationKind::Occurrence);
        assert_eq!(k(r#"{"a":"x"}"#), ViolationKind::Type);
        assert_eq!(k(r#"{"a":1,"e":"Y"}"#), ViolationKind::Property);
        assert_eq!(k(r#"{"a":1,"z":1}"#), ViolationKind::Format);
        assert_eq!(k(r#"{"a":1"#), ViolationKind::Format);
    }

    #[test]
    fn helpers() {
        let p = Path::root();
        assert!(max_length("ééé", 3, &p).is_ok());
        assert!(max_length("éééé", 3, &p).is_err());
        assert!(multiple_of(8.1, 0.1, &p).is_ok());
        assert!(multiple_of(8.15, 0.1, &p).is_err());
        assert!(items(&[1, 2], Some(1), Some(2), &p).is_ok());
        assert!(items::<i32>(&[], Some(1), None, &p).is_err());
    }
}