serde_field_result 0.1.0

Field-level Serde recovery for schema-drift-tolerant API clients
Documentation
use alloc::{
    borrow::{Cow, ToOwned},
    format,
    string::String,
};
use core::{error, fmt};

/// Error captured for a malformed field value.
///
/// Equality compares the displayed error message, not the internal storage form.
#[derive(Clone, Debug)]
pub struct FieldError {
    kind: FieldErrorKind,
}

#[derive(Clone, Debug)]
enum FieldErrorKind {
    Owned(String),
    Static(&'static str),
    TypeMismatch {
        expected: &'static str,
        actual: ActualValue,
    },
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ActualValue {
    Boolean,
    Character,
    SignedInteger,
    UnsignedInteger,
    FloatingPoint,
    String,
    Bytes,
    Array,
    Object,
}

impl ActualValue {
    const fn as_str(self) -> &'static str {
        match self {
            Self::Boolean => "boolean",
            Self::Character => "character",
            Self::SignedInteger => "signed integer",
            Self::UnsignedInteger => "unsigned integer",
            Self::FloatingPoint => "floating point number",
            Self::String => "string",
            Self::Bytes => "bytes",
            Self::Array => "array",
            Self::Object => "object",
        }
    }
}

impl FieldError {
    /// Creates a field error from a message.
    #[must_use]
    pub fn new(message: impl Into<String>) -> Self {
        Self {
            kind: FieldErrorKind::Owned(message.into()),
        }
    }

    /// Creates a field error from a static message without allocating.
    #[must_use]
    pub const fn static_message(message: &'static str) -> Self {
        Self {
            kind: FieldErrorKind::Static(message),
        }
    }

    pub(crate) const fn type_mismatch(expected: &'static str, actual: ActualValue) -> Self {
        Self {
            kind: FieldErrorKind::TypeMismatch { expected, actual },
        }
    }

    /// Returns the captured error message.
    ///
    /// Structured errors are formatted into an owned string only when this
    /// method is called. Use [`core::fmt::Display`] when writing directly to a
    /// formatter.
    #[must_use]
    pub fn message(&self) -> Cow<'_, str> {
        match &self.kind {
            FieldErrorKind::Owned(message) => Cow::Borrowed(message),
            FieldErrorKind::Static(message) => Cow::Borrowed(message),
            FieldErrorKind::TypeMismatch { expected, actual } => {
                let actual = actual.as_str();
                Cow::Owned(format!("expected {expected}, got {actual}"))
            }
        }
    }

    /// Consumes the error and returns the captured message.
    #[must_use]
    pub fn into_message(self) -> String {
        match self.kind {
            FieldErrorKind::Owned(message) => message,
            FieldErrorKind::Static(message) => message.to_owned(),
            FieldErrorKind::TypeMismatch { expected, actual } => {
                let actual = actual.as_str();
                format!("expected {expected}, got {actual}")
            }
        }
    }
}

impl PartialEq for FieldError {
    fn eq(&self, other: &Self) -> bool {
        field_error_messages_eq(&self.kind, &other.kind)
    }
}

impl Eq for FieldError {}

impl fmt::Display for FieldError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.kind {
            FieldErrorKind::Owned(message) => formatter.write_str(message),
            FieldErrorKind::Static(message) => formatter.write_str(message),
            FieldErrorKind::TypeMismatch { expected, actual } => {
                let actual = actual.as_str();
                write!(formatter, "expected {expected}, got {actual}")
            }
        }
    }
}

impl error::Error for FieldError {}

impl From<String> for FieldError {
    fn from(message: String) -> Self {
        Self::new(message)
    }
}

impl From<&'static str> for FieldError {
    fn from(message: &'static str) -> Self {
        Self::static_message(message)
    }
}

fn field_error_messages_eq(left: &FieldErrorKind, right: &FieldErrorKind) -> bool {
    match (left, right) {
        (FieldErrorKind::Owned(left), FieldErrorKind::Owned(right)) => left == right,
        (FieldErrorKind::Owned(left), FieldErrorKind::Static(right))
        | (FieldErrorKind::Static(right), FieldErrorKind::Owned(left)) => left == right,
        (FieldErrorKind::Static(left), FieldErrorKind::Static(right)) => left == right,
        (
            FieldErrorKind::TypeMismatch {
                expected: left_expected,
                actual: left_actual,
            },
            FieldErrorKind::TypeMismatch {
                expected: right_expected,
                actual: right_actual,
            },
        ) => left_expected == right_expected && left_actual == right_actual,
        (FieldErrorKind::TypeMismatch { expected, actual }, FieldErrorKind::Owned(message))
        | (FieldErrorKind::Owned(message), FieldErrorKind::TypeMismatch { expected, actual }) => {
            type_mismatch_message_eq(expected, *actual, message)
        }
        (FieldErrorKind::TypeMismatch { expected, actual }, FieldErrorKind::Static(message))
        | (FieldErrorKind::Static(message), FieldErrorKind::TypeMismatch { expected, actual }) => {
            type_mismatch_message_eq(expected, *actual, message)
        }
    }
}

fn type_mismatch_message_eq(expected: &str, actual: ActualValue, message: &str) -> bool {
    let Some(rest) = message.strip_prefix("expected ") else {
        return false;
    };
    let Some(rest) = rest.strip_prefix(expected) else {
        return false;
    };
    let Some(rest) = rest.strip_prefix(", got ") else {
        return false;
    };

    rest == actual.as_str()
}

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

    #[test]
    fn equality_compares_displayed_messages() {
        assert_eq!(FieldError::new("bad"), FieldError::static_message("bad"));
        assert_eq!(
            FieldError::type_mismatch("unsigned integer", ActualValue::String),
            FieldError::new("expected unsigned integer, got string"),
        );
    }
}