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}