Skip to main content

vld/error/
mod.rs

1//! Validation errors: [`VldError`], [`ValidationIssue`], [`IssueCode`].
2//!
3//! Every issue carries a machine-readable [`IssueCode`] and a `message`. The
4//! default message is rendered from the English template table
5//! ([`i18n::english()`]) using [`IssueCode::message_keys()`] and
6//! [`IssueCode::params()`]; per-check overrides (`*_msg` builders,
7//! `.message()`, `type_error()`) replace it. [`i18n::translate_error`]
8//! re-renders messages with another table.
9//!
10//! # Issue codes
11//!
12//! | Variant | `key()` | Template keys (specific → generic) | Params |
13//! |---|---|---|---|
14//! | `InvalidType` | `invalid_type` | `invalid_type` | `expected`, `received` |
15//! | `TooSmall` | `too_small` | `too_small.<origin>`, `too_small` (exclusive: `too_small_exclusive[.<origin>]`) | `origin`, `minimum`, `inclusive` |
16//! | `TooBig` | `too_big` | `too_big.<origin>`, `too_big` (exclusive: `too_big_exclusive[.<origin>]`) | `origin`, `maximum`, `inclusive` |
17//! | `InvalidLength` | `invalid_length` | `invalid_length.<origin>` | `origin`, `expected` |
18//! | `InvalidString` | `invalid_string` | `invalid_string.<validation>` | `validation` (+ `prefix` / `suffix` / `substring`) |
19//! | `InvalidFormat` | `invalid_format` | `invalid_format.<format>` | `format` |
20//! | `InvalidDate` | `invalid_date` | `invalid_date.<kind>` | `kind` |
21//! | `InvalidTimezoneOffset` | `invalid_timezone_offset` | — | `expected` |
22//! | `NotInPast` | `not_in_past` | `not_in_past.<kind>` | `kind` |
23//! | `NotInFuture` | `not_in_future` | `not_in_future.<kind>` | `kind` |
24//! | `NotInt` | `not_int` | — | — |
25//! | `NotFinite` | `not_finite` | — | — |
26//! | `NotMultipleOf` | `not_multiple_of` | — | `divisor` |
27//! | `NotSafeInteger` | `not_safe_integer` | — | — |
28//! | `InvalidLiteral` | `invalid_literal` | — | `expected` |
29//! | `InvalidEnumValue` | `invalid_enum_value` | — | `options` |
30//! | `InvalidUnion` | `invalid_union` | — | — |
31//! | `InvalidDiscriminator` | `invalid_discriminator` | — | `options` |
32//! | `NotUnique` | `not_unique` | — | — |
33//! | `MissingContains` | `missing_contains` | — | — |
34//! | `DuplicateKey` | `duplicate_key` | — | — |
35//! | `InvalidMapEntry` | `invalid_map_entry` | — | — |
36//! | `MissingField` | `missing_field` | — | — |
37//! | `UnrecognizedField` | `unrecognized_field` | — | — |
38//! | `PathOutsideBase` | `path_outside_base` | — | `base` |
39//! | `PathNotAbsolute` | `path_not_absolute` | — | — |
40//! | `PathNotRelative` | `path_not_relative` | — | — |
41//! | `PathNotFound` | `path_not_found` | — | — |
42//! | `PathNotFile` | `path_not_file` | — | — |
43//! | `PathNotDir` | `path_not_dir` | — | — |
44//! | `JsonTooDeep` | `json_too_deep` | — | `max_depth` |
45//! | `FileTypeNotAllowed` | `file_type_not_allowed` | — | `kind`, `actual`, `allowed` |
46//! | `FileTypeDenied` | `file_type_denied` | — | `kind`, `actual` |
47//! | `FileTypeUnknown` | `file_type_unknown` | — | `kind` |
48//! | `ChecksumMismatch` | `checksum_mismatch` | — | `algorithm` |
49//! | `MissingExif` | `missing_exif` | — | — |
50//! | `SerializeError` | `serialize_error` | — | `detail` |
51//! | `IoError` | `io_error` | — | — |
52//! | `ParseError` | `parse_error` | — | — |
53//! | `Custom` | the user code | `<code>`, `custom` | `code` |
54//!
55//! Origins ([`SizeOrigin::key`]): `string`, `number`, `decimal`, `array`,
56//! `tuple`, `set`, `record`, `object`, `bytes`, `date`, `datetime`, `time`,
57//! `duration`, `file`, `image_width`, `image_height`, `port`, `contains`.
58//!
59//! `invalid_type`'s `expected` / `received` are stable type keys
60//! (`string`, `number`, `integer`, `boolean`, `array`, `object`, `null`,
61//! `never`, `date`, `datetime`, `time`, `duration`, `decimal`, `path`,
62//! `file`, `socket_addr`, `ip_network`, `bytes`, `bytes_or_string`, `byte`);
63//! messages render them through the `type.<key>` templates (English:
64//! `type.date` → "date string (YYYY-MM-DD)", plain words render as
65//! themselves).
66//!
67//! Size templates have plural variants (`too_small.string_one`, Russian
68//! `invalid_length.string_few`, …); see [`i18n::translate_issue`].
69
70use std::fmt;
71
72mod builder;
73mod code;
74/// Error formatting: flat, tree and pretty output.
75pub mod format;
76pub mod i18n;
77mod i18n_langs;
78
79pub use crate::object::{FieldResult, ParseResult};
80pub use builder::IssueBuilder;
81pub use code::{DateKind, FileTypeKind, FormatKind, IssueCode, SizeOrigin, StringValidation};
82
83/// A segment in a validation error path.
84#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
85pub enum PathSegment {
86    /// Object field name.
87    Field(String),
88    /// Array index.
89    Index(usize),
90}
91
92impl fmt::Display for PathSegment {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        match self {
95            PathSegment::Field(name) => write!(f, ".{}", name),
96            PathSegment::Index(idx) => write!(f, "[{}]", idx),
97        }
98    }
99}
100
101/// A single validation issue with path, message and received value.
102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
103pub struct ValidationIssue {
104    /// What went wrong.
105    pub code: IssueCode,
106    /// Human-readable message.
107    pub message: String,
108    /// Location of the value in the input.
109    pub path: Vec<PathSegment>,
110    /// The value that was received (if available).
111    pub received: Option<serde_json::Value>,
112}
113
114impl ValidationIssue {
115    /// Issue with the default English message for `code`, no path, no value.
116    pub fn new(code: IssueCode) -> Self {
117        Self {
118            message: code.default_message(),
119            code,
120            path: vec![],
121            received: None,
122        }
123    }
124
125    /// The path as text: `.lab.fund[0].vigor`; empty for the root.
126    ///
127    /// ```
128    /// use vld::prelude::*;
129    /// let err = vld::array(vld::string()).parse(r#"["a", 1]"#).unwrap_err();
130    /// assert_eq!(err.issues[0].path_string(), "[1]");
131    /// ```
132    pub fn path_string(&self) -> String {
133        self.path.iter().map(|p| p.to_string()).collect()
134    }
135
136    /// Replace the message.
137    pub fn with_message(mut self, message: impl Into<String>) -> Self {
138        self.message = message.into();
139        self
140    }
141
142    /// Attach the received value (large strings / arrays are truncated).
143    pub fn with_received(mut self, value: &serde_json::Value) -> Self {
144        self.received = Some(truncate_value(value));
145        self
146    }
147
148    /// Whether `message` differs from the default English message of `code`
149    /// (ignoring a union's `[variant N] ` prefix): set by `*_msg` builders,
150    /// `with_messages`, `.message()`, `type_error()`, refinements, or an
151    /// earlier translation. [`i18n::translate_error`] keeps such messages.
152    pub fn has_custom_message(&self) -> bool {
153        let (_, message) = i18n::split_union_prefix(&self.message);
154        message != self.code.default_message()
155    }
156}
157
158impl From<ValidationIssue> for VldError {
159    fn from(issue: ValidationIssue) -> Self {
160        Self {
161            issues: vec![issue],
162        }
163    }
164}
165
166/// Collection of validation errors.
167///
168/// Errors are accumulated (not short-circuited), so all issues are reported at once.
169#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
170pub struct VldError {
171    /// Every issue found, in input order.
172    pub issues: Vec<ValidationIssue>,
173}
174
175impl VldError {
176    /// Create an empty error container.
177    pub fn new() -> Self {
178        Self { issues: vec![] }
179    }
180
181    /// Error with a single issue and its default English message.
182    pub fn from_code(code: IssueCode) -> Self {
183        ValidationIssue::new(code).into()
184    }
185
186    /// Error with a single issue, its default English message and the received value.
187    pub fn from_code_with_value(code: IssueCode, received: &serde_json::Value) -> Self {
188        ValidationIssue::new(code).with_received(received).into()
189    }
190
191    /// Create an error with a single issue (no received value).
192    pub fn single(code: IssueCode, message: impl Into<String>) -> Self {
193        ValidationIssue::new_raw(code, message.into()).into()
194    }
195
196    /// Create an error with a single issue and the received value.
197    pub fn single_with_value(
198        code: IssueCode,
199        message: impl Into<String>,
200        received: &serde_json::Value,
201    ) -> Self {
202        ValidationIssue::new_raw(code, message.into())
203            .with_received(received)
204            .into()
205    }
206
207    /// Prepend a path segment to all issues (used for nested objects/arrays).
208    pub fn with_prefix(mut self, segment: PathSegment) -> Self {
209        for issue in &mut self.issues {
210            issue.path.insert(0, segment.clone());
211        }
212        self
213    }
214
215    /// Merge another error's issues into this one.
216    pub fn merge(mut self, other: VldError) -> Self {
217        self.issues.extend(other.issues);
218        self
219    }
220
221    /// Check if there are no issues.
222    pub fn is_empty(&self) -> bool {
223        self.issues.is_empty()
224    }
225
226    /// Push a single issue (no received value).
227    pub fn push(&mut self, code: IssueCode, message: impl Into<String>) {
228        self.issues
229            .push(ValidationIssue::new_raw(code, message.into()));
230    }
231
232    /// Push a single issue with the received value.
233    pub fn push_with_value(
234        &mut self,
235        code: IssueCode,
236        message: impl Into<String>,
237        received: &serde_json::Value,
238    ) {
239        self.issues
240            .push(ValidationIssue::new_raw(code, message.into()).with_received(received));
241    }
242
243    /// Push a single issue with its default English message.
244    pub fn push_code(&mut self, code: IssueCode) {
245        self.issues.push(ValidationIssue::new(code));
246    }
247
248    /// Push a single issue with its default English message and the received value.
249    pub fn push_code_with_value(&mut self, code: IssueCode, received: &serde_json::Value) {
250        self.issues
251            .push(ValidationIssue::new(code).with_received(received));
252    }
253}
254
255impl ValidationIssue {
256    fn new_raw(code: IssueCode, message: String) -> Self {
257        Self {
258            code,
259            message,
260            path: vec![],
261            received: None,
262        }
263    }
264
265    /// Issue with `custom` as message when set, else the default message.
266    pub(crate) fn with_override(code: IssueCode, custom: Option<&String>) -> Self {
267        match custom {
268            Some(msg) => Self::new_raw(code, msg.clone()),
269            None => Self::new(code),
270        }
271    }
272}
273
274/// Error with one issue: `custom` message (or the default) plus the received value.
275pub(crate) fn issue_error(
276    code: IssueCode,
277    custom: Option<&String>,
278    received: &serde_json::Value,
279) -> VldError {
280    ValidationIssue::with_override(code, custom)
281        .with_received(received)
282        .into()
283}
284
285/// Error of a field whose key is absent from its object.
286///
287/// A required field rejects the implied `null` with `invalid_type`
288/// ("received null"); for an absent key that is reported as a single
289/// [`IssueCode::MissingField`] instead. A custom type-error message is kept.
290pub(crate) fn absent_field_error(err: VldError) -> VldError {
291    let rejected_null = err.issues.iter().find(|i| {
292        i.path.is_empty()
293            && matches!(&i.code, IssueCode::InvalidType { received, .. } if received == "null")
294    });
295    match rejected_null {
296        Some(issue) => {
297            let custom = issue.message != issue.code.default_message();
298            let missing = ValidationIssue::new(IssueCode::MissingField);
299            if custom {
300                missing.with_message(issue.message.clone()).into()
301            } else {
302                missing.into()
303            }
304        }
305        None => err,
306    }
307}
308
309impl Default for VldError {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315impl fmt::Display for VldError {
316    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317        for (i, issue) in self.issues.iter().enumerate() {
318            if i > 0 {
319                writeln!(f)?;
320            }
321            if !issue.path.is_empty() {
322                write!(f, "{}: ", issue.path_string())?;
323            }
324            write!(f, "{}", issue.message)?;
325            // `invalid_type` messages already name the received type.
326            if let (Some(val), false) = (
327                &issue.received,
328                matches!(issue.code, IssueCode::InvalidType { .. }),
329            ) {
330                write!(f, ", received {}", format_value_short(val))?;
331            }
332        }
333        Ok(())
334    }
335}
336
337impl std::error::Error for VldError {}
338
339/// Returns the JSON type name for a value.
340pub(crate) fn value_type_name(value: &serde_json::Value) -> String {
341    match value {
342        serde_json::Value::Null => "null",
343        serde_json::Value::Bool(_) => "boolean",
344        serde_json::Value::Number(_) => "number",
345        serde_json::Value::String(_) => "string",
346        serde_json::Value::Array(_) => "array",
347        serde_json::Value::Object(_) => "object",
348    }
349    .to_string()
350}
351
352/// Return the value as a JSON object, or an `invalid_type` error
353/// ("Expected object, received ...").
354///
355/// Shared by [`ZObject`](crate::object::ZObject), `schema!` and `#[derive(Validate)]`.
356pub(crate) fn expect_object(
357    value: &serde_json::Value,
358) -> Result<&serde_json::Map<String, serde_json::Value>, VldError> {
359    value
360        .as_object()
361        .ok_or_else(|| crate::primitives::type_error(&None, "object", value))
362}
363
364/// Format a JSON value for display in errors (short form).
365pub(crate) fn format_value_short(value: &serde_json::Value) -> String {
366    match value {
367        serde_json::Value::Null => "null".to_string(),
368        serde_json::Value::Bool(b) => b.to_string(),
369        serde_json::Value::Number(n) => n.to_string(),
370        serde_json::Value::String(s) => {
371            let char_count = s.chars().count();
372            if char_count > 50 {
373                let prefix: String = s.chars().take(47).collect();
374                format!("\"{}...\"", prefix)
375            } else {
376                format!("\"{}\"", s)
377            }
378        }
379        serde_json::Value::Array(arr) => format!("Array(len={})", arr.len()),
380        serde_json::Value::Object(obj) => format!("Object(keys={})", obj.len()),
381    }
382}
383
384/// Truncate large values to avoid storing huge payloads in errors.
385pub(crate) fn truncate_value(value: &serde_json::Value) -> serde_json::Value {
386    match value {
387        // Slice by chars, not bytes: a byte slice can split a multi-byte
388        // UTF-8 sequence and panic on attacker-controlled input.
389        serde_json::Value::String(s) if s.chars().count() > 100 => {
390            let prefix: String = s.chars().take(97).collect();
391            serde_json::Value::String(format!("{}...", prefix))
392        }
393        serde_json::Value::Array(arr) if arr.len() > 5 => {
394            let mut truncated: Vec<serde_json::Value> = arr[..5].to_vec();
395            truncated.push(serde_json::Value::String(format!(
396                "... ({} more)",
397                arr.len() - 5
398            )));
399            serde_json::Value::Array(truncated)
400        }
401        _ => value.clone(),
402    }
403}