Skip to main content

cratefield_tables/
value.rs

1//! One value against one [`FieldKind`].
2//!
3//! Every rule here is written to be reimplementable exactly, because a
4//! generated TypeScript client will check the same values in the browser
5//! and the two must agree. The pairs that drift are pinned in
6//! `corpus/rows.json` rather than left to prose.
7//!
8//! The rules, in full:
9//!
10//! - **No coercion.** A value has the JSON type its kind names, or it is
11//!   rejected. `"42"` is not an integer, `1` is not `true`, `"true"` is
12//!   not a boolean.
13//! - **An integer is a number with no fractional part.** `42` and `42.0`
14//!   are the same JSON number and both are accepted; `42.5` is not. The
15//!   rule is `Number.isInteger(v)` in JavaScript, so a corpus file cannot
16//!   mean one thing in Rust and another in a browser.
17//! - **Length is counted in Unicode scalar values.** Not bytes, and not
18//!   UTF-16 code units. A JavaScript implementation must count
19//!   `[...s].length`, never `s.length`: `"\u{1F600}"` is one character,
20//!   four bytes and two UTF-16 code units.
21//! - **Nothing is trimmed.** Surrounding whitespace is part of the value
22//!   and counts toward length. Trimming is a transformation, and a
23//!   transformation belongs to whatever writes the row.
24//! - **Bounds are inclusive** on both ends.
25//! - **`null` never reaches here.** The row validator treats a JSON
26//!   `null` as the absence of a value, so a null is answered before a
27//!   kind is consulted.
28
29use serde_json::Value;
30
31use crate::schema::{FieldKind, TextFormat};
32
33/// The stable vocabulary a conformance case is matched on. Messages are
34/// prose and may be reworded; a code may not, because a second
35/// implementation asserts against it.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ErrorCode {
39    /// The row was not a JSON object.
40    NotAnObject,
41    /// The row carried a key the table does not declare.
42    UnknownField,
43    /// A field that must be present was absent or `null`.
44    Required,
45    /// The value had the wrong JSON type for its kind.
46    WrongType,
47    /// A string was shorter than `min_len` characters.
48    TooShort,
49    /// A string was longer than `max_len` characters.
50    TooLong,
51    /// A number was below `min`.
52    TooSmall,
53    /// A number was above `max`.
54    TooLarge,
55    /// A string was not one of an enum's declared values.
56    NotInEnum,
57    /// A string did not match its declared format: `email`, `url`, a
58    /// timestamp or a UUID.
59    BadFormat,
60    /// A string carried U+0000, which no text column can hold.
61    ///
62    /// SQLite stores it and Postgres refuses the whole statement
63    /// (`22P05`), so without this the same declaration serves two
64    /// behaviours: a row that writes in development and a 500 in
65    /// production. Refused in the validator so the answer is a 422
66    /// naming the column rather than a database error naming nothing the
67    /// caller sent.
68    NulByte,
69}
70
71/// Declares [`ErrorCode::ALL`] and, from the same list, a match that has
72/// to be exhaustive.
73///
74/// `ALL` is what `the_corpus_covers_every_error_code` checks the corpus
75/// against, so a code missing from it is a code the corpus need never
76/// exercise — the coverage test would pass while one verdict went
77/// untested. Written twice it can fall behind; written once it cannot.
78macro_rules! codes {
79    ($($variant:ident),+ $(,)?) => {
80        impl ErrorCode {
81            /// Every code, for the corpus coverage check.
82            pub const ALL: &'static [ErrorCode] = &[$(ErrorCode::$variant),+];
83        }
84
85        /// Never called. It exists so that a variant absent from the list
86        /// above is a compile error here.
87        #[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    /// The wire name used in `corpus/rows.json`.
112    #[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/// Why one value was rejected: a code to match on and a line to read.
131#[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
146/// Checks `value` against `kind`.
147///
148/// # Errors
149///
150/// The first rule the value breaks, as a code and a message.
151pub 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` first, so a string or a boolean is reported as
160            // the wrong JSON type rather than as a fractional number.
161            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
244/// A `text` value: storable, then the right length, then the right shape.
245///
246/// Its own function because the arm outgrew `check_value`, and because
247/// the order is part of the contract a reimplementation matches — the
248/// corpus has a case for a value that is both too long and unstorable,
249/// and it expects the storability answer.
250fn 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    // Before length, because it is about whether the value can be
258    // stored at all rather than about its shape. SQLite keeps a
259    // NUL in a TEXT column and PostgreSQL refuses the statement
260    // (`22P05`), so a row accepted here writes in development and
261    // answers 500 in production — the same declaration serving
262    // two behaviours, which is what the boolean and real decoding
263    // rules already exist to stop.
264    //
265    // Only U+0000. Every other control character stores on both
266    // engines, and a text column is allowed to hold a newline:
267    // this is the character no text column has, not a taste in
268    // prose. The declared *default* is held to the stricter rule
269    // enum members are, because that one becomes schema.
270    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
304/// The JSON type name used in messages, so `"42"` against an integer says
305/// `a string` rather than repeating the value back.
306fn 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
328/// A JSON number with no fractional part, inside `i64`. `42.0` passes;
329/// `42.5` does not. JSON has one number type, so this is the only rule
330/// that means the same thing in Rust and in a browser: it is
331/// `Number.isInteger(v)` in JavaScript.
332///
333/// Shared with the DDL renderer, so a default written as `42.0` in the
334/// manifest renders as the literal `42` rather than as a quoted string.
335pub(crate) fn integral(value: &Value) -> Option<i64> {
336    // The bounds are literals, not `i64::MIN/MAX as f64`. `i64::MAX as
337    // f64` rounds *up* to 2^63, so `number <= i64::MAX as f64` admitted
338    // 2^63 itself and the saturating `as` cast then stored `i64::MAX` —
339    // `9223372036854775809` was accepted and written as
340    // `9223372036854775807`. Both literals are exactly representable and
341    // the upper one is exclusive.
342    const MIN: f64 = -9_223_372_036_854_775_808.0; // -2^63, exact
343    const ABOVE_MAX: f64 = 9_223_372_036_854_775_808.0; // 2^63, exact
344
345    let number = value.as_number()?;
346    if let Some(integer) = number.as_i64() {
347        // Exact integer text inside `i64`. `serde_json` only stores it
348        // this way when the source really was an integer in range.
349        return Some(integer);
350    }
351    // An exact integer above `i64::MAX` needs no branch of its own: it
352    // arrives as a `u64`, whose `f64` is never below 2^63, so the upper
353    // bound below refuses it. Checking `as_u64` first as well looked
354    // careful and was unreachable — disabling it changed no verdict.
355    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        // A float that lands exactly on -2^63 reached here as a float,
361        // so its source text was not the integer -9223372036854775808
362        // (that fits `i64` and would have been stored as one). It was
363        // some integer below the range, every one of which rounds here:
364        // `-9223372036854775809` was accepted and written as `i64::MIN`.
365        // Refuse the boundary rather than pick one of the candidates.
366        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/// An absolute `http` or `https` URL: a case-insensitive scheme, `://`, a
417/// non-empty authority, and no ASCII whitespace or control character
418/// anywhere. Deliberately narrow: a declared field is a form input, not
419/// a URL parser test suite.
420#[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/// A hyphenated 8-4-4-4-12 UUID in either case. No braces, no
445/// `urn:uuid:` prefix, and no version or variant check: the value is an
446/// identifier, and the harness does not mint it.
447#[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/// A strict RFC 3339 timestamp:
463/// `YYYY-MM-DDTHH:MM:SS[.fraction](Z|+HH:MM|-HH:MM)`.
464///
465/// Uppercase `T` and `Z` only, which is what
466/// `new Date().toISOString()` emits. The calendar day is checked against
467/// the month and the leap year. A leap second (`:60`) is accepted,
468/// because RFC 3339 allows it.
469#[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}