1use serde_json::Value;
30
31use crate::schema::{FieldKind, TextFormat};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ErrorCode {
39 NotAnObject,
41 UnknownField,
43 Required,
45 WrongType,
47 TooShort,
49 TooLong,
51 TooSmall,
53 TooLarge,
55 NotInEnum,
57 BadFormat,
60 NulByte,
69}
70
71macro_rules! codes {
79 ($($variant:ident),+ $(,)?) => {
80 impl ErrorCode {
81 pub const ALL: &'static [ErrorCode] = &[$(ErrorCode::$variant),+];
83 }
84
85 #[expect(dead_code, reason = "its only job is to be exhaustive")]
88 fn every_code_is_in_all(code: ErrorCode) {
89 match code {
90 $(ErrorCode::$variant => {}),+
91 }
92 }
93 };
94}
95
96codes!(
97 NotAnObject,
98 UnknownField,
99 Required,
100 WrongType,
101 TooShort,
102 TooLong,
103 TooSmall,
104 TooLarge,
105 NotInEnum,
106 BadFormat,
107 NulByte,
108);
109
110impl ErrorCode {
111 #[must_use]
113 pub fn as_str(self) -> &'static str {
114 match self {
115 ErrorCode::NotAnObject => "not_an_object",
116 ErrorCode::UnknownField => "unknown_field",
117 ErrorCode::Required => "required",
118 ErrorCode::WrongType => "wrong_type",
119 ErrorCode::TooShort => "too_short",
120 ErrorCode::TooLong => "too_long",
121 ErrorCode::TooSmall => "too_small",
122 ErrorCode::TooLarge => "too_large",
123 ErrorCode::NotInEnum => "not_in_enum",
124 ErrorCode::BadFormat => "bad_format",
125 ErrorCode::NulByte => "nul_byte",
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ValueError {
133 pub code: ErrorCode,
134 pub message: String,
135}
136
137impl ValueError {
138 fn new(code: ErrorCode, message: impl Into<String>) -> Self {
139 Self {
140 code,
141 message: message.into(),
142 }
143 }
144}
145
146pub fn check_value(kind: &FieldKind, value: &Value) -> Result<(), ValueError> {
152 match kind {
153 FieldKind::Text {
154 min_len,
155 max_len,
156 format,
157 } => check_text(value, *min_len, *max_len, *format),
158 FieldKind::Integer { min, max } => {
159 as_number(value)?;
162 let integer = integral(value).ok_or_else(|| {
163 ValueError::new(
164 ErrorCode::WrongType,
165 "must be a whole number that fits in 64 bits",
166 )
167 })?;
168 if let Some(min) = *min
169 && integer < min
170 {
171 return Err(too_small(min));
172 }
173 if let Some(max) = *max
174 && integer > max
175 {
176 return Err(too_large(max));
177 }
178 Ok(())
179 }
180 FieldKind::Real { min, max } => {
181 let number = as_number(value)?;
182 if let Some(min) = *min
183 && number < min
184 {
185 return Err(too_small(min));
186 }
187 if let Some(max) = *max
188 && number > max
189 {
190 return Err(too_large(max));
191 }
192 Ok(())
193 }
194 FieldKind::Boolean => {
195 if value.is_boolean() {
196 Ok(())
197 } else {
198 Err(wrong_type(value, "true or false"))
199 }
200 }
201 FieldKind::Timestamp => {
202 let text = as_string(value, "an RFC 3339 timestamp string")?;
203 if is_rfc3339(text) {
204 Ok(())
205 } else {
206 Err(ValueError::new(
207 ErrorCode::BadFormat,
208 "is not an RFC 3339 timestamp such as 2026-09-08T09:30:00Z",
209 ))
210 }
211 }
212 FieldKind::Uuid => {
213 let text = as_string(value, "a UUID string")?;
214 if is_uuid(text) {
215 Ok(())
216 } else {
217 Err(ValueError::new(
218 ErrorCode::BadFormat,
219 "is not a hyphenated 8-4-4-4-12 UUID",
220 ))
221 }
222 }
223 FieldKind::Json => {
224 if value.is_null() {
225 Err(wrong_type(value, "any JSON value other than null"))
226 } else {
227 Ok(())
228 }
229 }
230 FieldKind::Enum { values } => {
231 let text = as_string(value, "one of the declared values")?;
232 if values.iter().any(|allowed| allowed == text) {
233 Ok(())
234 } else {
235 Err(ValueError::new(
236 ErrorCode::NotInEnum,
237 format!("is not one of {}", values.join(", ")),
238 ))
239 }
240 }
241 }
242}
243
244fn check_text(
251 value: &Value,
252 min_len: Option<u32>,
253 max_len: Option<u32>,
254 format: Option<TextFormat>,
255) -> Result<(), ValueError> {
256 let text = as_string(value, "a string")?;
257 if let Some(position) = text.find('\0') {
271 return Err(ValueError::new(
272 ErrorCode::NulByte,
273 format!(
274 "holds U+0000 at byte {position}, which no text column can store \
275 (PostgreSQL refuses it outright)"
276 ),
277 ));
278 }
279 check_length(text, min_len, max_len)?;
280 check_format(text, format)
281}
282
283fn too_small(min: impl std::fmt::Display) -> ValueError {
284 ValueError::new(
285 ErrorCode::TooSmall,
286 format!("is below the minimum of {min}"),
287 )
288}
289
290fn too_large(max: impl std::fmt::Display) -> ValueError {
291 ValueError::new(
292 ErrorCode::TooLarge,
293 format!("is above the maximum of {max}"),
294 )
295}
296
297fn wrong_type(value: &Value, expected: &str) -> ValueError {
298 ValueError::new(
299 ErrorCode::WrongType,
300 format!("must be {expected}, not {}", json_type_name(value)),
301 )
302}
303
304fn json_type_name(value: &Value) -> &'static str {
307 match value {
308 Value::Null => "null",
309 Value::Bool(_) => "a boolean",
310 Value::Number(_) => "a number",
311 Value::String(_) => "a string",
312 Value::Array(_) => "an array",
313 Value::Object(_) => "an object",
314 }
315}
316
317fn as_string<'a>(value: &'a Value, expected: &str) -> Result<&'a str, ValueError> {
318 value.as_str().ok_or_else(|| wrong_type(value, expected))
319}
320
321fn as_number(value: &Value) -> Result<f64, ValueError> {
322 value
323 .as_f64()
324 .filter(|number| number.is_finite())
325 .ok_or_else(|| wrong_type(value, "a number"))
326}
327
328pub(crate) fn integral(value: &Value) -> Option<i64> {
336 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()?;
346 if let Some(integer) = number.as_i64() {
347 return Some(integer);
350 }
351 let float = number.as_f64()?;
356 if !float.is_finite() || float.fract() != 0.0 || float < MIN || float >= ABOVE_MAX {
357 return None;
358 }
359 if float == MIN {
360 return None;
367 }
368 #[allow(clippy::cast_possible_truncation)]
369 Some(float as i64)
370}
371
372fn check_length(text: &str, min_len: Option<u32>, max_len: Option<u32>) -> Result<(), ValueError> {
373 let length = u64::try_from(text.chars().count()).unwrap_or(u64::MAX);
374 if let Some(min) = min_len
375 && length < u64::from(min)
376 {
377 return Err(ValueError::new(
378 ErrorCode::TooShort,
379 format!("is {length} characters, shorter than the minimum of {min}"),
380 ));
381 }
382 if let Some(max) = max_len
383 && length > u64::from(max)
384 {
385 return Err(ValueError::new(
386 ErrorCode::TooLong,
387 format!("is {length} characters, longer than the maximum of {max}"),
388 ));
389 }
390 Ok(())
391}
392
393fn check_format(text: &str, format: Option<TextFormat>) -> Result<(), ValueError> {
394 match format {
395 None => Ok(()),
396 Some(TextFormat::Email) => {
397 if let Some(reason) = cratefield_core::validation_error(text) {
398 Err(ValueError::new(ErrorCode::BadFormat, reason))
399 } else {
400 Ok(())
401 }
402 }
403 Some(TextFormat::Url) => {
404 if is_url(text) {
405 Ok(())
406 } else {
407 Err(ValueError::new(
408 ErrorCode::BadFormat,
409 "is not an absolute http or https URL",
410 ))
411 }
412 }
413 }
414}
415
416#[must_use]
421pub fn is_url(text: &str) -> bool {
422 if text
423 .chars()
424 .any(|c| c.is_ascii_whitespace() || c.is_control())
425 {
426 return false;
427 }
428 let lower = text.to_ascii_lowercase();
429 let rest = if let Some(rest) = lower.strip_prefix("https://") {
430 rest
431 } else if let Some(rest) = lower.strip_prefix("http://") {
432 rest
433 } else {
434 return false;
435 };
436 let authority = rest
437 .split(['/', '?', '#'])
438 .next()
439 .unwrap_or_default()
440 .to_owned();
441 !authority.is_empty()
442}
443
444#[must_use]
448pub fn is_uuid(text: &str) -> bool {
449 const GROUPS: [usize; 5] = [8, 4, 4, 4, 12];
450 let mut parts = text.split('-');
451 for width in GROUPS {
452 let Some(part) = parts.next() else {
453 return false;
454 };
455 if part.len() != width || !part.bytes().all(|b| b.is_ascii_hexdigit()) {
456 return false;
457 }
458 }
459 parts.next().is_none()
460}
461
462#[must_use]
470pub fn is_rfc3339(text: &str) -> bool {
471 let bytes = text.as_bytes();
472 if bytes.len() < 20 || !text.is_ascii() {
473 return false;
474 }
475 let digits = |range: std::ops::Range<usize>| -> Option<u32> {
476 text.get(range)
477 .filter(|part| part.bytes().all(|b| b.is_ascii_digit()))
478 .and_then(|part| part.parse().ok())
479 };
480 if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
481 return false;
482 }
483 if bytes[13] != b':' || bytes[16] != b':' {
484 return false;
485 }
486 let (Some(year), Some(month), Some(day)) = (digits(0..4), digits(5..7), digits(8..10)) else {
487 return false;
488 };
489 let (Some(hour), Some(minute), Some(second)) = (digits(11..13), digits(14..16), digits(17..19))
490 else {
491 return false;
492 };
493 if month == 0 || month > 12 || day == 0 || day > days_in_month(year, month) {
494 return false;
495 }
496 if hour > 23 || minute > 59 || second > 60 {
497 return false;
498 }
499
500 let mut rest = &text[19..];
501 if let Some(after_dot) = rest.strip_prefix('.') {
502 let fraction: String = after_dot
503 .chars()
504 .take_while(char::is_ascii_digit)
505 .collect::<String>();
506 if fraction.is_empty() {
507 return false;
508 }
509 rest = &after_dot[fraction.len()..];
510 }
511 if rest == "Z" {
512 return true;
513 }
514 let signed = rest
515 .strip_prefix('+')
516 .or_else(|| rest.strip_prefix('-'))
517 .unwrap_or("");
518 if signed.len() != 5 || signed.as_bytes()[2] != b':' {
519 return false;
520 }
521 let (Some(offset_hour), Some(offset_minute)) = (
522 signed[0..2]
523 .parse::<u32>()
524 .ok()
525 .filter(|_| signed[0..2].bytes().all(|b| b.is_ascii_digit())),
526 signed[3..5]
527 .parse::<u32>()
528 .ok()
529 .filter(|_| signed[3..5].bytes().all(|b| b.is_ascii_digit())),
530 ) else {
531 return false;
532 };
533 offset_hour <= 23 && offset_minute <= 59
534}
535
536fn days_in_month(year: u32, month: u32) -> u32 {
537 match month {
538 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
539 4 | 6 | 9 | 11 => 30,
540 2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => {
541 29
542 }
543 2 => 28,
544 _ => 0,
545 }
546}