use serde_json::Value;
use crate::schema::{FieldKind, TextFormat};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
NotAnObject,
UnknownField,
Required,
WrongType,
TooShort,
TooLong,
TooSmall,
TooLarge,
NotInEnum,
BadFormat,
NulByte,
}
macro_rules! codes {
($($variant:ident),+ $(,)?) => {
impl ErrorCode {
pub const ALL: &'static [ErrorCode] = &[$(ErrorCode::$variant),+];
}
#[expect(dead_code, reason = "its only job is to be exhaustive")]
fn every_code_is_in_all(code: ErrorCode) {
match code {
$(ErrorCode::$variant => {}),+
}
}
};
}
codes!(
NotAnObject,
UnknownField,
Required,
WrongType,
TooShort,
TooLong,
TooSmall,
TooLarge,
NotInEnum,
BadFormat,
NulByte,
);
impl ErrorCode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
ErrorCode::NotAnObject => "not_an_object",
ErrorCode::UnknownField => "unknown_field",
ErrorCode::Required => "required",
ErrorCode::WrongType => "wrong_type",
ErrorCode::TooShort => "too_short",
ErrorCode::TooLong => "too_long",
ErrorCode::TooSmall => "too_small",
ErrorCode::TooLarge => "too_large",
ErrorCode::NotInEnum => "not_in_enum",
ErrorCode::BadFormat => "bad_format",
ErrorCode::NulByte => "nul_byte",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValueError {
pub code: ErrorCode,
pub message: String,
}
impl ValueError {
fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
}
pub fn check_value(kind: &FieldKind, value: &Value) -> Result<(), ValueError> {
match kind {
FieldKind::Text {
min_len,
max_len,
format,
} => check_text(value, *min_len, *max_len, *format),
FieldKind::Integer { min, max } => {
as_number(value)?;
let integer = integral(value).ok_or_else(|| {
ValueError::new(
ErrorCode::WrongType,
"must be a whole number that fits in 64 bits",
)
})?;
if let Some(min) = *min
&& integer < min
{
return Err(too_small(min));
}
if let Some(max) = *max
&& integer > max
{
return Err(too_large(max));
}
Ok(())
}
FieldKind::Real { min, max } => {
let number = as_number(value)?;
if let Some(min) = *min
&& number < min
{
return Err(too_small(min));
}
if let Some(max) = *max
&& number > max
{
return Err(too_large(max));
}
Ok(())
}
FieldKind::Boolean => {
if value.is_boolean() {
Ok(())
} else {
Err(wrong_type(value, "true or false"))
}
}
FieldKind::Timestamp => {
let text = as_string(value, "an RFC 3339 timestamp string")?;
if is_rfc3339(text) {
Ok(())
} else {
Err(ValueError::new(
ErrorCode::BadFormat,
"is not an RFC 3339 timestamp such as 2026-09-08T09:30:00Z",
))
}
}
FieldKind::Uuid => {
let text = as_string(value, "a UUID string")?;
if is_uuid(text) {
Ok(())
} else {
Err(ValueError::new(
ErrorCode::BadFormat,
"is not a hyphenated 8-4-4-4-12 UUID",
))
}
}
FieldKind::Json => {
if value.is_null() {
Err(wrong_type(value, "any JSON value other than null"))
} else {
Ok(())
}
}
FieldKind::Enum { values } => {
let text = as_string(value, "one of the declared values")?;
if values.iter().any(|allowed| allowed == text) {
Ok(())
} else {
Err(ValueError::new(
ErrorCode::NotInEnum,
format!("is not one of {}", values.join(", ")),
))
}
}
}
}
fn check_text(
value: &Value,
min_len: Option<u32>,
max_len: Option<u32>,
format: Option<TextFormat>,
) -> Result<(), ValueError> {
let text = as_string(value, "a string")?;
if let Some(position) = text.find('\0') {
return Err(ValueError::new(
ErrorCode::NulByte,
format!(
"holds U+0000 at byte {position}, which no text column can store \
(PostgreSQL refuses it outright)"
),
));
}
check_length(text, min_len, max_len)?;
check_format(text, format)
}
fn too_small(min: impl std::fmt::Display) -> ValueError {
ValueError::new(
ErrorCode::TooSmall,
format!("is below the minimum of {min}"),
)
}
fn too_large(max: impl std::fmt::Display) -> ValueError {
ValueError::new(
ErrorCode::TooLarge,
format!("is above the maximum of {max}"),
)
}
fn wrong_type(value: &Value, expected: &str) -> ValueError {
ValueError::new(
ErrorCode::WrongType,
format!("must be {expected}, not {}", json_type_name(value)),
)
}
fn json_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
fn as_string<'a>(value: &'a Value, expected: &str) -> Result<&'a str, ValueError> {
value.as_str().ok_or_else(|| wrong_type(value, expected))
}
fn as_number(value: &Value) -> Result<f64, ValueError> {
value
.as_f64()
.filter(|number| number.is_finite())
.ok_or_else(|| wrong_type(value, "a number"))
}
pub(crate) fn integral(value: &Value) -> Option<i64> {
const MIN: f64 = -9_223_372_036_854_775_808.0; const ABOVE_MAX: f64 = 9_223_372_036_854_775_808.0;
let number = value.as_number()?;
if let Some(integer) = number.as_i64() {
return Some(integer);
}
let float = number.as_f64()?;
if !float.is_finite() || float.fract() != 0.0 || float < MIN || float >= ABOVE_MAX {
return None;
}
if float == MIN {
return None;
}
#[allow(clippy::cast_possible_truncation)]
Some(float as i64)
}
fn check_length(text: &str, min_len: Option<u32>, max_len: Option<u32>) -> Result<(), ValueError> {
let length = u64::try_from(text.chars().count()).unwrap_or(u64::MAX);
if let Some(min) = min_len
&& length < u64::from(min)
{
return Err(ValueError::new(
ErrorCode::TooShort,
format!("is {length} characters, shorter than the minimum of {min}"),
));
}
if let Some(max) = max_len
&& length > u64::from(max)
{
return Err(ValueError::new(
ErrorCode::TooLong,
format!("is {length} characters, longer than the maximum of {max}"),
));
}
Ok(())
}
fn check_format(text: &str, format: Option<TextFormat>) -> Result<(), ValueError> {
match format {
None => Ok(()),
Some(TextFormat::Email) => {
if let Some(reason) = cratefield_core::validation_error(text) {
Err(ValueError::new(ErrorCode::BadFormat, reason))
} else {
Ok(())
}
}
Some(TextFormat::Url) => {
if is_url(text) {
Ok(())
} else {
Err(ValueError::new(
ErrorCode::BadFormat,
"is not an absolute http or https URL",
))
}
}
}
}
#[must_use]
pub fn is_url(text: &str) -> bool {
if text
.chars()
.any(|c| c.is_ascii_whitespace() || c.is_control())
{
return false;
}
let lower = text.to_ascii_lowercase();
let rest = if let Some(rest) = lower.strip_prefix("https://") {
rest
} else if let Some(rest) = lower.strip_prefix("http://") {
rest
} else {
return false;
};
let authority = rest
.split(['/', '?', '#'])
.next()
.unwrap_or_default()
.to_owned();
!authority.is_empty()
}
#[must_use]
pub fn is_uuid(text: &str) -> bool {
const GROUPS: [usize; 5] = [8, 4, 4, 4, 12];
let mut parts = text.split('-');
for width in GROUPS {
let Some(part) = parts.next() else {
return false;
};
if part.len() != width || !part.bytes().all(|b| b.is_ascii_hexdigit()) {
return false;
}
}
parts.next().is_none()
}
#[must_use]
pub fn is_rfc3339(text: &str) -> bool {
let bytes = text.as_bytes();
if bytes.len() < 20 || !text.is_ascii() {
return false;
}
let digits = |range: std::ops::Range<usize>| -> Option<u32> {
text.get(range)
.filter(|part| part.bytes().all(|b| b.is_ascii_digit()))
.and_then(|part| part.parse().ok())
};
if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
return false;
}
if bytes[13] != b':' || bytes[16] != b':' {
return false;
}
let (Some(year), Some(month), Some(day)) = (digits(0..4), digits(5..7), digits(8..10)) else {
return false;
};
let (Some(hour), Some(minute), Some(second)) = (digits(11..13), digits(14..16), digits(17..19))
else {
return false;
};
if month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
return false;
}
if hour > 23 || minute > 59 || second > 60 {
return false;
}
let mut rest = &text[19..];
if let Some(after_dot) = rest.strip_prefix('.') {
let fraction: String = after_dot
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>();
if fraction.is_empty() {
return false;
}
rest = &after_dot[fraction.len()..];
}
if rest == "Z" {
return true;
}
let signed = rest
.strip_prefix('+')
.or_else(|| rest.strip_prefix('-'))
.unwrap_or("");
if signed.len() != 5 || signed.as_bytes()[2] != b':' {
return false;
}
let (Some(offset_hour), Some(offset_minute)) = (
signed[0..2]
.parse::<u32>()
.ok()
.filter(|_| signed[0..2].bytes().all(|b| b.is_ascii_digit())),
signed[3..5]
.parse::<u32>()
.ok()
.filter(|_| signed[3..5].bytes().all(|b| b.is_ascii_digit())),
) else {
return false;
};
offset_hour <= 23 && offset_minute <= 59
}
fn days_in_month(year: u32, month: u32) -> u32 {
match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => {
29
}
2 => 28,
_ => 0,
}
}