eure_schema/validate/
error.rs

1//! Validation error types
2//!
3//! Two categories of errors:
4//! - `ValidationError`: Type errors accumulated during validation (non-fatal)
5//! - `ValidatorError`: Internal validator errors that cause fail-fast behavior
6
7use eure_document::document::NodeId;
8use eure_document::parse::ParseError;
9use eure_document::path::EurePath;
10use eure_document::value::ObjectKey;
11use thiserror::Error;
12
13use crate::SchemaNodeId;
14
15// =============================================================================
16// ValidatorError (fail-fast internal errors)
17// =============================================================================
18
19/// Internal validator errors that cause immediate failure.
20///
21/// These represent problems with the validator itself or invalid inputs,
22/// not type mismatches in the document being validated.
23#[derive(Debug, Clone, Error, PartialEq)]
24pub enum ValidatorError {
25    /// Undefined type reference in schema
26    #[error("undefined type reference: {name}")]
27    UndefinedTypeReference { name: String },
28
29    /// Invalid variant tag (parse error)
30    #[error("invalid variant tag '{tag}': {reason}")]
31    InvalidVariantTag { tag: String, reason: String },
32
33    /// Conflicting variant tags between $variant and repr
34    #[error("conflicting variant tags: $variant = {explicit}, repr = {repr}")]
35    ConflictingVariantTags { explicit: String, repr: String },
36
37    /// Cross-schema reference not supported
38    #[error("cross-schema reference not supported: {namespace}.{name}")]
39    CrossSchemaReference { namespace: String, name: String },
40
41    /// Parse error (from eure-document)
42    #[error("parse error: {0}")]
43    DocumentParseError(#[from] ParseError),
44
45    /// Inner validation errors were already propagated (no additional error needed)
46    #[error("inner errors propagated")]
47    InnerErrorsPropagated,
48}
49
50impl ValidatorError {
51    /// Get the underlying ParseError if this is a DocumentParseError variant.
52    pub fn as_parse_error(&self) -> Option<&ParseError> {
53        match self {
54            ValidatorError::DocumentParseError(e) => Some(e),
55            _ => None,
56        }
57    }
58}
59
60// =============================================================================
61// BestVariantMatch (for union error reporting)
62// =============================================================================
63
64/// Information about the best matching variant in a failed union validation.
65///
66/// When an untagged union validation fails, this structure captures detailed
67/// information about which variant came closest to matching, enabling better
68/// error diagnostics.
69///
70/// # Selection Criteria
71///
72/// The "best" variant is selected based on:
73/// 1. **Depth**: Errors deeper in the structure indicate better match (got further before failing)
74/// 2. **Error count**: Fewer errors indicate closer match
75/// 3. **Error priority**: Higher priority errors (like MissingRequiredField) indicate clearer mismatches
76///
77/// # Nested Unions
78///
79/// For nested unions like `Result<Option<T>, E>`, the error field itself may be a
80/// `NoVariantMatched` error, creating a hierarchical error structure that shows
81/// the full path of variant attempts.
82#[derive(Debug, Clone, PartialEq)]
83pub struct BestVariantMatch {
84    /// Name of the variant that matched best
85    pub variant_name: String,
86    /// Primary error from this variant (may be nested NoVariantMatched)
87    pub error: Box<ValidationError>,
88    /// All errors collected from this variant attempt
89    pub all_errors: Vec<ValidationError>,
90    /// Depth metric (path length of deepest error)
91    pub depth: usize,
92    /// Number of errors
93    pub error_count: usize,
94}
95
96// =============================================================================
97// ValidationError (accumulated type errors)
98// =============================================================================
99
100/// Type errors accumulated during validation.
101///
102/// These represent mismatches between the document and schema.
103/// Validation continues after recording these errors.
104#[derive(Debug, Clone, Error, PartialEq)]
105pub enum ValidationError {
106    #[error("Type mismatch: expected {expected}, got {actual} at path {path}")]
107    TypeMismatch {
108        expected: String,
109        actual: String,
110        path: EurePath,
111        node_id: NodeId,
112        schema_node_id: SchemaNodeId,
113    },
114
115    #[error("Missing required field '{field}' at path {path}")]
116    MissingRequiredField {
117        field: String,
118        path: EurePath,
119        node_id: NodeId,
120        schema_node_id: SchemaNodeId,
121    },
122
123    #[error("Unknown field '{field}' at path {path}")]
124    UnknownField {
125        field: String,
126        path: EurePath,
127        node_id: NodeId,
128        schema_node_id: SchemaNodeId,
129    },
130
131    #[error("Value {value} is out of range at path {path}")]
132    OutOfRange {
133        value: String,
134        path: EurePath,
135        node_id: NodeId,
136        schema_node_id: SchemaNodeId,
137    },
138
139    #[error("String length {length} is out of bounds at path {path}")]
140    StringLengthOutOfBounds {
141        length: usize,
142        min: Option<u32>,
143        max: Option<u32>,
144        path: EurePath,
145        node_id: NodeId,
146        schema_node_id: SchemaNodeId,
147    },
148
149    #[error("String does not match pattern '{pattern}' at path {path}")]
150    PatternMismatch {
151        pattern: String,
152        path: EurePath,
153        node_id: NodeId,
154        schema_node_id: SchemaNodeId,
155    },
156
157    #[error("Array length {length} is out of bounds at path {path}")]
158    ArrayLengthOutOfBounds {
159        length: usize,
160        min: Option<u32>,
161        max: Option<u32>,
162        path: EurePath,
163        node_id: NodeId,
164        schema_node_id: SchemaNodeId,
165    },
166
167    #[error("Map size {size} is out of bounds at path {path}")]
168    MapSizeOutOfBounds {
169        size: usize,
170        min: Option<u32>,
171        max: Option<u32>,
172        path: EurePath,
173        node_id: NodeId,
174        schema_node_id: SchemaNodeId,
175    },
176
177    #[error("Tuple length mismatch: expected {expected}, got {actual} at path {path}")]
178    TupleLengthMismatch {
179        expected: usize,
180        actual: usize,
181        path: EurePath,
182        node_id: NodeId,
183        schema_node_id: SchemaNodeId,
184    },
185
186    #[error("Array elements must be unique at path {path}")]
187    ArrayNotUnique {
188        path: EurePath,
189        node_id: NodeId,
190        schema_node_id: SchemaNodeId,
191    },
192
193    #[error("Array must contain required element at path {path}")]
194    ArrayMissingContains {
195        path: EurePath,
196        node_id: NodeId,
197        schema_node_id: SchemaNodeId,
198    },
199
200    /// No variant matched in an untagged union validation.
201    ///
202    /// This error occurs when all variants of a union are tried and none succeeds.
203    /// When available, `best_match` provides detailed information about which variant
204    /// came closest to matching and why it failed.
205    ///
206    /// For tagged unions (with `$variant` or `VariantRepr`), validation errors are
207    /// reported directly instead of wrapping them in `NoVariantMatched`.
208    #[error("{}", format_no_variant_matched(path, best_match))]
209    NoVariantMatched {
210        path: EurePath,
211        /// Best matching variant (None if no variants were tried)
212        best_match: Option<Box<BestVariantMatch>>,
213        node_id: NodeId,
214        schema_node_id: SchemaNodeId,
215    },
216
217    #[error("Multiple variants matched for union at path {path}: {variants:?}")]
218    AmbiguousUnion {
219        path: EurePath,
220        variants: Vec<String>,
221        node_id: NodeId,
222        schema_node_id: SchemaNodeId,
223    },
224
225    #[error("Invalid variant tag '{tag}' at path {path}")]
226    InvalidVariantTag {
227        tag: String,
228        path: EurePath,
229        node_id: NodeId,
230        schema_node_id: SchemaNodeId,
231    },
232
233    #[error("Conflicting variant tags: $variant = {explicit}, repr = {repr} at path {path}")]
234    ConflictingVariantTags {
235        explicit: String,
236        repr: String,
237        path: EurePath,
238        node_id: NodeId,
239        schema_node_id: SchemaNodeId,
240    },
241
242    #[error("Variant '{variant}' requires explicit $variant tag at path {path}")]
243    RequiresExplicitVariant {
244        variant: String,
245        path: EurePath,
246        node_id: NodeId,
247        schema_node_id: SchemaNodeId,
248    },
249
250    #[error("Literal value mismatch at path {path}")]
251    LiteralMismatch {
252        expected: String,
253        actual: String,
254        path: EurePath,
255        node_id: NodeId,
256        schema_node_id: SchemaNodeId,
257    },
258
259    #[error("Language mismatch: expected {expected}, got {actual} at path {path}")]
260    LanguageMismatch {
261        expected: String,
262        actual: String,
263        path: EurePath,
264        node_id: NodeId,
265        schema_node_id: SchemaNodeId,
266    },
267
268    #[error("Invalid key type at path {path}")]
269    InvalidKeyType {
270        /// The key that has the wrong type
271        key: ObjectKey,
272        path: EurePath,
273        node_id: NodeId,
274        schema_node_id: SchemaNodeId,
275    },
276
277    #[error("Integer not a multiple of {divisor} at path {path}")]
278    NotMultipleOf {
279        divisor: String,
280        path: EurePath,
281        node_id: NodeId,
282        schema_node_id: SchemaNodeId,
283    },
284
285    #[error("Undefined type reference '{name}' at path {path}")]
286    UndefinedTypeReference {
287        name: String,
288        path: EurePath,
289        node_id: NodeId,
290        schema_node_id: SchemaNodeId,
291    },
292
293    #[error("Missing required extension '{extension}' at path {path}")]
294    MissingRequiredExtension {
295        extension: String,
296        path: EurePath,
297        node_id: NodeId,
298        schema_node_id: SchemaNodeId,
299    },
300
301    /// Parse error with schema context.
302    /// Uses custom display to translate ParseErrorKind to user-friendly messages.
303    #[error("{}", format_parse_error(path, error))]
304    ParseError {
305        path: EurePath,
306        node_id: NodeId,
307        schema_node_id: SchemaNodeId,
308        error: eure_document::parse::ParseError,
309    },
310}
311
312/// Format a ParseError into a user-friendly validation error message.
313fn format_parse_error(path: &EurePath, error: &eure_document::parse::ParseError) -> String {
314    use eure_document::parse::ParseErrorKind;
315    match &error.kind {
316        ParseErrorKind::UnknownVariant(name) => {
317            format!("Invalid variant tag '{name}' at path {path}")
318        }
319        ParseErrorKind::ConflictingVariantTags { explicit, repr } => {
320            format!("Conflicting variant tags: $variant = {explicit}, repr = {repr} at path {path}")
321        }
322        ParseErrorKind::InvalidVariantType(kind) => {
323            format!("$variant must be a string, got {kind:?} at path {path}")
324        }
325        ParseErrorKind::InvalidVariantPath(path_str) => {
326            format!("Invalid $variant path syntax: '{path_str}' at path {path}")
327        }
328        // For other parse errors, use the default display
329        _ => format!("{} at path {}", error.kind, path),
330    }
331}
332
333/// Format NoVariantMatched error with best match information.
334fn format_no_variant_matched(
335    path: &EurePath,
336    best_match: &Option<Box<BestVariantMatch>>,
337) -> String {
338    match best_match {
339        Some(best) => {
340            let mut msg = format!(
341                "No variant matched for union at path {path}, most close variant is '{}': {}",
342                best.variant_name, best.error
343            );
344            if best.all_errors.len() > 1 {
345                msg.push_str(&format!(" (and {} more errors)", best.all_errors.len() - 1));
346            }
347            msg
348        }
349        None => format!("No variant matched for union at path {path}"),
350    }
351}
352
353impl ValidationError {
354    /// Get the node IDs associated with this error.
355    pub fn node_ids(&self) -> (NodeId, SchemaNodeId) {
356        match self {
357            Self::TypeMismatch {
358                node_id,
359                schema_node_id,
360                ..
361            }
362            | Self::MissingRequiredField {
363                node_id,
364                schema_node_id,
365                ..
366            }
367            | Self::UnknownField {
368                node_id,
369                schema_node_id,
370                ..
371            }
372            | Self::OutOfRange {
373                node_id,
374                schema_node_id,
375                ..
376            }
377            | Self::StringLengthOutOfBounds {
378                node_id,
379                schema_node_id,
380                ..
381            }
382            | Self::PatternMismatch {
383                node_id,
384                schema_node_id,
385                ..
386            }
387            | Self::ArrayLengthOutOfBounds {
388                node_id,
389                schema_node_id,
390                ..
391            }
392            | Self::MapSizeOutOfBounds {
393                node_id,
394                schema_node_id,
395                ..
396            }
397            | Self::TupleLengthMismatch {
398                node_id,
399                schema_node_id,
400                ..
401            }
402            | Self::ArrayNotUnique {
403                node_id,
404                schema_node_id,
405                ..
406            }
407            | Self::ArrayMissingContains {
408                node_id,
409                schema_node_id,
410                ..
411            }
412            | Self::NoVariantMatched {
413                node_id,
414                schema_node_id,
415                ..
416            }
417            | Self::AmbiguousUnion {
418                node_id,
419                schema_node_id,
420                ..
421            }
422            | Self::InvalidVariantTag {
423                node_id,
424                schema_node_id,
425                ..
426            }
427            | Self::ConflictingVariantTags {
428                node_id,
429                schema_node_id,
430                ..
431            }
432            | Self::RequiresExplicitVariant {
433                node_id,
434                schema_node_id,
435                ..
436            }
437            | Self::LiteralMismatch {
438                node_id,
439                schema_node_id,
440                ..
441            }
442            | Self::LanguageMismatch {
443                node_id,
444                schema_node_id,
445                ..
446            }
447            | Self::InvalidKeyType {
448                node_id,
449                schema_node_id,
450                ..
451            }
452            | Self::NotMultipleOf {
453                node_id,
454                schema_node_id,
455                ..
456            }
457            | Self::UndefinedTypeReference {
458                node_id,
459                schema_node_id,
460                ..
461            }
462            | Self::MissingRequiredExtension {
463                node_id,
464                schema_node_id,
465                ..
466            }
467            | Self::ParseError {
468                node_id,
469                schema_node_id,
470                ..
471            } => (*node_id, *schema_node_id),
472        }
473    }
474
475    /// Calculate the depth of this error (path length).
476    ///
477    /// Deeper errors indicate that validation got further into the structure
478    /// before failing, suggesting a better match.
479    pub fn depth(&self) -> usize {
480        match self {
481            Self::TypeMismatch { path, .. }
482            | Self::MissingRequiredField { path, .. }
483            | Self::UnknownField { path, .. }
484            | Self::OutOfRange { path, .. }
485            | Self::StringLengthOutOfBounds { path, .. }
486            | Self::PatternMismatch { path, .. }
487            | Self::ArrayLengthOutOfBounds { path, .. }
488            | Self::MapSizeOutOfBounds { path, .. }
489            | Self::TupleLengthMismatch { path, .. }
490            | Self::ArrayNotUnique { path, .. }
491            | Self::ArrayMissingContains { path, .. }
492            | Self::NoVariantMatched { path, .. }
493            | Self::AmbiguousUnion { path, .. }
494            | Self::InvalidVariantTag { path, .. }
495            | Self::ConflictingVariantTags { path, .. }
496            | Self::RequiresExplicitVariant { path, .. }
497            | Self::LiteralMismatch { path, .. }
498            | Self::LanguageMismatch { path, .. }
499            | Self::InvalidKeyType { path, .. }
500            | Self::NotMultipleOf { path, .. }
501            | Self::UndefinedTypeReference { path, .. }
502            | Self::MissingRequiredExtension { path, .. }
503            | Self::ParseError { path, .. } => path.0.len(),
504        }
505    }
506
507    /// Get priority score for error type (higher = more indicative of mismatch).
508    ///
509    /// Used for selecting the "best" variant error when multiple variants fail
510    /// with similar depth and error counts.
511    pub fn priority_score(&self) -> u8 {
512        match self {
513            Self::MissingRequiredField { .. } => 90,
514            Self::TypeMismatch { .. } => 80,
515            Self::TupleLengthMismatch { .. } => 70,
516            Self::LiteralMismatch { .. } => 70,
517            Self::InvalidVariantTag { .. } => 65,
518            Self::NoVariantMatched { .. } => 60, // Nested union mismatch
519            Self::UnknownField { .. } => 50,
520            Self::MissingRequiredExtension { .. } => 50,
521            Self::ParseError { .. } => 40, // Medium priority
522            Self::OutOfRange { .. } => 30,
523            Self::StringLengthOutOfBounds { .. } => 30,
524            Self::PatternMismatch { .. } => 30,
525            Self::ArrayLengthOutOfBounds { .. } => 30,
526            Self::MapSizeOutOfBounds { .. } => 30,
527            Self::NotMultipleOf { .. } => 30,
528            Self::ArrayNotUnique { .. } => 25,
529            Self::ArrayMissingContains { .. } => 25,
530            Self::InvalidKeyType { .. } => 20,
531            Self::LanguageMismatch { .. } => 20,
532            Self::AmbiguousUnion { .. } => 0, // Not a mismatch
533            Self::ConflictingVariantTags { .. } => 0, // Configuration error
534            Self::UndefinedTypeReference { .. } => 0, // Configuration error
535            Self::RequiresExplicitVariant { .. } => 0, // Configuration error
536        }
537    }
538}
539
540// =============================================================================
541// ValidationWarning
542// =============================================================================
543
544/// Warnings generated during validation.
545#[derive(Debug, Clone, PartialEq)]
546pub enum ValidationWarning {
547    /// Unknown extension on a node
548    UnknownExtension { name: String, path: EurePath },
549    /// Deprecated field usage
550    DeprecatedField { field: String, path: EurePath },
551}