Skip to main content

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    /// Schema node ID of the variant (for span resolution)
87    pub variant_schema_id: SchemaNodeId,
88    /// Primary error from this variant (may be nested NoVariantMatched)
89    pub error: Box<ValidationError>,
90    /// All errors collected from this variant attempt
91    pub all_errors: Vec<ValidationError>,
92    /// Depth metric (path length of deepest error)
93    pub depth: usize,
94    /// Number of errors
95    pub error_count: usize,
96}
97
98// =============================================================================
99// ValidationError (accumulated type errors)
100// =============================================================================
101
102/// Type errors accumulated during validation.
103///
104/// These represent mismatches between the document and schema.
105/// Validation continues after recording these errors.
106#[derive(Debug, Clone, Error, PartialEq)]
107pub enum ValidationError {
108    #[error("Type mismatch: expected {expected}, got {actual} at path {path}")]
109    TypeMismatch {
110        expected: String,
111        actual: String,
112        path: EurePath,
113        node_id: NodeId,
114        schema_node_id: SchemaNodeId,
115    },
116
117    #[error("{}", format_missing_required_fields(fields, path))]
118    MissingRequiredField {
119        fields: Vec<String>,
120        path: EurePath,
121        node_id: NodeId,
122        schema_node_id: SchemaNodeId,
123    },
124
125    #[error("Unknown field '{field}' at path {path}")]
126    UnknownField {
127        field: String,
128        path: EurePath,
129        node_id: NodeId,
130        schema_node_id: SchemaNodeId,
131    },
132
133    #[error("Value {value} is out of range at path {path}")]
134    OutOfRange {
135        value: String,
136        path: EurePath,
137        node_id: NodeId,
138        schema_node_id: SchemaNodeId,
139    },
140
141    #[error("String length {length} is out of bounds at path {path}")]
142    StringLengthOutOfBounds {
143        length: usize,
144        min: Option<u32>,
145        max: Option<u32>,
146        path: EurePath,
147        node_id: NodeId,
148        schema_node_id: SchemaNodeId,
149    },
150
151    #[error("String does not match pattern '{pattern}' at path {path}")]
152    PatternMismatch {
153        pattern: String,
154        path: EurePath,
155        node_id: NodeId,
156        schema_node_id: SchemaNodeId,
157    },
158
159    #[error("Array length {length} is out of bounds at path {path}")]
160    ArrayLengthOutOfBounds {
161        length: usize,
162        min: Option<u32>,
163        max: Option<u32>,
164        path: EurePath,
165        node_id: NodeId,
166        schema_node_id: SchemaNodeId,
167    },
168
169    #[error("Map size {size} is out of bounds at path {path}")]
170    MapSizeOutOfBounds {
171        size: usize,
172        min: Option<u32>,
173        max: Option<u32>,
174        path: EurePath,
175        node_id: NodeId,
176        schema_node_id: SchemaNodeId,
177    },
178
179    #[error("Tuple length mismatch: expected {expected}, got {actual} at path {path}")]
180    TupleLengthMismatch {
181        expected: usize,
182        actual: usize,
183        path: EurePath,
184        node_id: NodeId,
185        schema_node_id: SchemaNodeId,
186    },
187
188    #[error("Array elements must be unique at path {path}")]
189    ArrayNotUnique {
190        path: EurePath,
191        node_id: NodeId,
192        schema_node_id: SchemaNodeId,
193    },
194
195    #[error("Array must contain required element at path {path}")]
196    ArrayMissingContains {
197        path: EurePath,
198        node_id: NodeId,
199        schema_node_id: SchemaNodeId,
200    },
201
202    /// No variant matched in an untagged union validation.
203    ///
204    /// This error occurs when all variants of a union are tried and none succeeds.
205    /// When available, `best_match` provides detailed information about which variant
206    /// came closest to matching and why it failed.
207    ///
208    /// For tagged unions (with `$variant` or `VariantRepr`), validation errors are
209    /// reported directly instead of wrapping them in `NoVariantMatched`.
210    #[error("{}", format_no_variant_matched(path, best_match))]
211    NoVariantMatched {
212        path: EurePath,
213        /// Best matching variant (None if no variants were tried)
214        best_match: Option<Box<BestVariantMatch>>,
215        node_id: NodeId,
216        schema_node_id: SchemaNodeId,
217    },
218
219    #[error("Multiple variants matched for union at path {path}: {variants:?}")]
220    AmbiguousUnion {
221        path: EurePath,
222        variants: Vec<String>,
223        node_id: NodeId,
224        schema_node_id: SchemaNodeId,
225    },
226
227    #[error("Invalid variant tag '{tag}' at path {path}")]
228    InvalidVariantTag {
229        tag: String,
230        path: EurePath,
231        node_id: NodeId,
232        schema_node_id: SchemaNodeId,
233    },
234
235    #[error("Conflicting variant tags: $variant = {explicit}, repr = {repr} at path {path}")]
236    ConflictingVariantTags {
237        explicit: String,
238        repr: String,
239        path: EurePath,
240        node_id: NodeId,
241        schema_node_id: SchemaNodeId,
242    },
243
244    #[error("Variant '{variant}' requires explicit $variant tag at path {path}")]
245    RequiresExplicitVariant {
246        variant: String,
247        path: EurePath,
248        node_id: NodeId,
249        schema_node_id: SchemaNodeId,
250    },
251
252    #[error("Literal value mismatch at path {path}")]
253    LiteralMismatch {
254        expected: String,
255        actual: String,
256        path: EurePath,
257        node_id: NodeId,
258        schema_node_id: SchemaNodeId,
259    },
260
261    #[error("Language mismatch: expected {expected}, got {actual} at path {path}")]
262    LanguageMismatch {
263        expected: String,
264        actual: String,
265        path: EurePath,
266        node_id: NodeId,
267        schema_node_id: SchemaNodeId,
268    },
269
270    #[error("Invalid key type at path {path}")]
271    InvalidKeyType {
272        /// The key that has the wrong type
273        key: ObjectKey,
274        path: EurePath,
275        node_id: NodeId,
276        schema_node_id: SchemaNodeId,
277    },
278
279    #[error("Integer not a multiple of {divisor} at path {path}")]
280    NotMultipleOf {
281        divisor: String,
282        path: EurePath,
283        node_id: NodeId,
284        schema_node_id: SchemaNodeId,
285    },
286
287    #[error("Undefined type reference '{name}' at path {path}")]
288    UndefinedTypeReference {
289        name: String,
290        path: EurePath,
291        node_id: NodeId,
292        schema_node_id: SchemaNodeId,
293    },
294
295    #[error(
296        "Invalid flatten target: expected Record, Union, or Map, got {actual_kind} at path {path}"
297    )]
298    InvalidFlattenTarget {
299        /// The actual schema kind that was found
300        actual_kind: crate::SchemaKind,
301        path: EurePath,
302        node_id: NodeId,
303        schema_node_id: SchemaNodeId,
304    },
305
306    #[error("Flatten map key '{key}' does not match pattern at path {path}")]
307    FlattenMapKeyMismatch {
308        /// The key that doesn't match the pattern
309        key: String,
310        /// The pattern that was expected (if any)
311        pattern: Option<String>,
312        path: EurePath,
313        node_id: NodeId,
314        schema_node_id: SchemaNodeId,
315    },
316
317    #[error("Missing required extension '{extension}' at path {path}")]
318    MissingRequiredExtension {
319        extension: String,
320        path: EurePath,
321        node_id: NodeId,
322        schema_node_id: SchemaNodeId,
323    },
324
325    /// Parse error with schema context.
326    /// Uses custom display to translate ParseErrorKind to user-friendly messages.
327    #[error("{}", format_parse_error(path, error))]
328    ParseError {
329        path: EurePath,
330        node_id: NodeId,
331        schema_node_id: SchemaNodeId,
332        error: eure_document::parse::ParseError,
333    },
334}
335
336/// Format missing required fields message with proper singular/plural handling.
337fn format_missing_required_fields(fields: &[String], path: &EurePath) -> String {
338    match fields.len() {
339        1 => format!("Missing required field '{}' at path {}", fields[0], path),
340        _ => {
341            let field_list = fields
342                .iter()
343                .map(|f| format!("'{}'", f))
344                .collect::<Vec<_>>()
345                .join(", ");
346            format!("Missing required fields {} at path {}", field_list, path)
347        }
348    }
349}
350
351/// Format a ParseError into a user-friendly validation error message.
352fn format_parse_error(path: &EurePath, error: &eure_document::parse::ParseError) -> String {
353    use eure_document::parse::ParseErrorKind;
354    match &error.kind {
355        ParseErrorKind::UnknownVariant(name) => {
356            format!("Invalid variant tag '{name}' at path {path}")
357        }
358        ParseErrorKind::ConflictingVariantTags { explicit, repr } => {
359            format!("Conflicting variant tags: $variant = {explicit}, repr = {repr} at path {path}")
360        }
361        ParseErrorKind::InvalidVariantType(kind) => {
362            format!("$variant must be a string, got {kind:?} at path {path}")
363        }
364        ParseErrorKind::InvalidVariantPath(path_str) => {
365            format!("Invalid $variant path syntax: '{path_str}' at path {path}")
366        }
367        // For other parse errors, use the default display
368        _ => format!("{} at path {}", error.kind, path),
369    }
370}
371
372/// Format NoVariantMatched error with best match information.
373///
374/// When a best match is available, shows the actual underlying error first,
375/// followed by a parenthetical note about which variant was selected.
376/// For nested unions, only shows the innermost variant to avoid redundancy.
377fn format_no_variant_matched(
378    path: &EurePath,
379    best_match: &Option<Box<BestVariantMatch>>,
380) -> String {
381    match best_match {
382        Some(best) => {
383            // For nested unions, the inner error already has the variant info
384            let is_nested_union = matches!(
385                best.error.as_ref(),
386                ValidationError::NoVariantMatched { .. }
387            );
388
389            if is_nested_union {
390                // Just use the inner error's message which already has the variant info
391                let mut msg = best.error.to_string();
392                if best.all_errors.len() > 1 {
393                    msg.push_str(&format!(" (and {} more errors)", best.all_errors.len() - 1));
394                }
395                msg
396            } else {
397                // Add the variant info for this level
398                let mut msg = best.error.to_string();
399                if best.all_errors.len() > 1 {
400                    msg.push_str(&format!(" (and {} more errors)", best.all_errors.len() - 1));
401                }
402                msg.push_str(&format!(
403                    " (based on nearest variant '{}' for union at path {})",
404                    best.variant_name, path
405                ));
406                msg
407            }
408        }
409        None => format!("No variant matched for union at path {path}"),
410    }
411}
412
413impl ValidationError {
414    /// Get the node IDs associated with this error.
415    pub fn node_ids(&self) -> (NodeId, SchemaNodeId) {
416        match self {
417            Self::TypeMismatch {
418                node_id,
419                schema_node_id,
420                ..
421            }
422            | Self::MissingRequiredField {
423                node_id,
424                schema_node_id,
425                ..
426            }
427            | Self::UnknownField {
428                node_id,
429                schema_node_id,
430                ..
431            }
432            | Self::OutOfRange {
433                node_id,
434                schema_node_id,
435                ..
436            }
437            | Self::StringLengthOutOfBounds {
438                node_id,
439                schema_node_id,
440                ..
441            }
442            | Self::PatternMismatch {
443                node_id,
444                schema_node_id,
445                ..
446            }
447            | Self::ArrayLengthOutOfBounds {
448                node_id,
449                schema_node_id,
450                ..
451            }
452            | Self::MapSizeOutOfBounds {
453                node_id,
454                schema_node_id,
455                ..
456            }
457            | Self::TupleLengthMismatch {
458                node_id,
459                schema_node_id,
460                ..
461            }
462            | Self::ArrayNotUnique {
463                node_id,
464                schema_node_id,
465                ..
466            }
467            | Self::ArrayMissingContains {
468                node_id,
469                schema_node_id,
470                ..
471            }
472            | Self::NoVariantMatched {
473                node_id,
474                schema_node_id,
475                ..
476            }
477            | Self::AmbiguousUnion {
478                node_id,
479                schema_node_id,
480                ..
481            }
482            | Self::InvalidVariantTag {
483                node_id,
484                schema_node_id,
485                ..
486            }
487            | Self::ConflictingVariantTags {
488                node_id,
489                schema_node_id,
490                ..
491            }
492            | Self::RequiresExplicitVariant {
493                node_id,
494                schema_node_id,
495                ..
496            }
497            | Self::LiteralMismatch {
498                node_id,
499                schema_node_id,
500                ..
501            }
502            | Self::LanguageMismatch {
503                node_id,
504                schema_node_id,
505                ..
506            }
507            | Self::InvalidKeyType {
508                node_id,
509                schema_node_id,
510                ..
511            }
512            | Self::NotMultipleOf {
513                node_id,
514                schema_node_id,
515                ..
516            }
517            | Self::UndefinedTypeReference {
518                node_id,
519                schema_node_id,
520                ..
521            }
522            | Self::InvalidFlattenTarget {
523                node_id,
524                schema_node_id,
525                ..
526            }
527            | Self::FlattenMapKeyMismatch {
528                node_id,
529                schema_node_id,
530                ..
531            }
532            | Self::MissingRequiredExtension {
533                node_id,
534                schema_node_id,
535                ..
536            }
537            | Self::ParseError {
538                node_id,
539                schema_node_id,
540                ..
541            } => (*node_id, *schema_node_id),
542        }
543    }
544
545    /// Find the deepest value-focused error in a chain of NoVariantMatched errors.
546    ///
547    /// For nested unions, this walks the best_match chain to find the actual error
548    /// location, but only for "value-focused" errors (TypeMismatch, LiteralMismatch, etc.)
549    /// where the deeper span is more useful. For structural errors (MissingRequiredField,
550    /// UnknownField), we stop at the current level since pointing to the outer block
551    /// is more helpful.
552    pub fn deepest_error(&self) -> &ValidationError {
553        match self {
554            Self::NoVariantMatched {
555                best_match: Some(best),
556                ..
557            } => {
558                // Check if the nested error is worth descending into
559                match best.error.as_ref() {
560                    // Continue descending for nested unions
561                    Self::NoVariantMatched { .. } => best.error.deepest_error(),
562                    // Continue for value-focused errors where deeper span is useful
563                    Self::TypeMismatch { .. }
564                    | Self::LiteralMismatch { .. }
565                    | Self::LanguageMismatch { .. }
566                    | Self::OutOfRange { .. }
567                    | Self::NotMultipleOf { .. }
568                    | Self::PatternMismatch { .. }
569                    | Self::StringLengthOutOfBounds { .. }
570                    | Self::InvalidKeyType { .. }
571                    | Self::UnknownField { .. } => best.error.deepest_error(),
572                    // For structural errors, keep the outer union span
573                    _ => self,
574                }
575            }
576            _ => self,
577        }
578    }
579
580    /// Calculate the depth of this error (path length).
581    ///
582    /// Deeper errors indicate that validation got further into the structure
583    /// before failing, suggesting a better match.
584    pub fn depth(&self) -> usize {
585        match self {
586            Self::TypeMismatch { path, .. }
587            | Self::MissingRequiredField { path, .. }
588            | Self::UnknownField { path, .. }
589            | Self::OutOfRange { path, .. }
590            | Self::StringLengthOutOfBounds { path, .. }
591            | Self::PatternMismatch { path, .. }
592            | Self::ArrayLengthOutOfBounds { path, .. }
593            | Self::MapSizeOutOfBounds { path, .. }
594            | Self::TupleLengthMismatch { path, .. }
595            | Self::ArrayNotUnique { path, .. }
596            | Self::ArrayMissingContains { path, .. }
597            | Self::NoVariantMatched { path, .. }
598            | Self::AmbiguousUnion { path, .. }
599            | Self::InvalidVariantTag { path, .. }
600            | Self::ConflictingVariantTags { path, .. }
601            | Self::RequiresExplicitVariant { path, .. }
602            | Self::LiteralMismatch { path, .. }
603            | Self::LanguageMismatch { path, .. }
604            | Self::InvalidKeyType { path, .. }
605            | Self::NotMultipleOf { path, .. }
606            | Self::UndefinedTypeReference { path, .. }
607            | Self::InvalidFlattenTarget { path, .. }
608            | Self::FlattenMapKeyMismatch { path, .. }
609            | Self::MissingRequiredExtension { path, .. }
610            | Self::ParseError { path, .. } => path.0.len(),
611        }
612    }
613
614    /// Get priority score for error type (higher = more indicative of mismatch).
615    ///
616    /// Used for selecting the "best" variant error when multiple variants fail
617    /// with similar depth and error counts.
618    pub fn priority_score(&self) -> u8 {
619        match self {
620            // UnknownField is highest priority because it tells the user exactly
621            // what they did wrong (e.g., used 'foo' instead of 'bar'). This is
622            // more actionable than MissingRequiredField which only says what's missing.
623            Self::UnknownField { .. } => 95,
624            Self::MissingRequiredField { .. } => 90,
625            Self::TypeMismatch { .. } => 80,
626            Self::TupleLengthMismatch { .. } => 70,
627            Self::LiteralMismatch { .. } => 70,
628            Self::InvalidVariantTag { .. } => 65,
629            Self::NoVariantMatched { .. } => 60, // Nested union mismatch
630            Self::MissingRequiredExtension { .. } => 50,
631            Self::ParseError { .. } => 40, // Medium priority
632            Self::OutOfRange { .. } => 30,
633            Self::StringLengthOutOfBounds { .. } => 30,
634            Self::PatternMismatch { .. } => 30,
635            Self::FlattenMapKeyMismatch { .. } => 30, // Similar to PatternMismatch
636            Self::ArrayLengthOutOfBounds { .. } => 30,
637            Self::MapSizeOutOfBounds { .. } => 30,
638            Self::NotMultipleOf { .. } => 30,
639            Self::ArrayNotUnique { .. } => 25,
640            Self::ArrayMissingContains { .. } => 25,
641            Self::InvalidKeyType { .. } => 20,
642            Self::LanguageMismatch { .. } => 20,
643            Self::AmbiguousUnion { .. } => 0, // Not a mismatch
644            Self::ConflictingVariantTags { .. } => 0, // Configuration error
645            Self::UndefinedTypeReference { .. } => 0, // Configuration error
646            Self::InvalidFlattenTarget { .. } => 0, // Schema construction error
647            Self::RequiresExplicitVariant { .. } => 0, // Configuration error
648        }
649    }
650}
651
652// =============================================================================
653// ValidationWarning
654// =============================================================================
655
656/// Warnings generated during validation.
657#[derive(Debug, Clone, PartialEq)]
658pub enum ValidationWarning {
659    /// Unknown extension on a node
660    UnknownExtension { name: String, path: EurePath },
661    /// Deprecated field usage
662    DeprecatedField { field: String, path: EurePath },
663}
664
665// =============================================================================
666// Best Variant Selection
667// =============================================================================
668
669/// Compute the effective depth and structural match status for a list of errors.
670///
671/// This function looks through `NoVariantMatched` errors to find the actual
672/// depth and structural match status from nested unions. This ensures that
673/// a variant containing a union (which has a structurally-matching sub-variant)
674/// is preferred over a variant with a direct structural mismatch.
675///
676/// A "structural mismatch" occurs when TypeMismatch happens at the union's level
677/// (e.g., expected array but got map). We detect this by checking if TypeMismatch
678/// is at the minimum depth among all errors - if so, it failed at the union level.
679///
680/// Returns (max_depth, structural_match) where:
681/// - max_depth: The deepest error path length, looking through nested unions
682/// - structural_match: true if no TypeMismatch at the union level (considering nested unions)
683fn compute_depth_and_structural_match(errors: &[ValidationError]) -> (usize, bool) {
684    // First pass: find the minimum depth (the union's level)
685    let min_depth = errors.iter().map(|e| e.depth()).min().unwrap_or(0);
686
687    let mut max_depth = 0;
688    let mut structural_match = true;
689
690    for error in errors {
691        match error {
692            // For NoVariantMatched, look inside to get the nested metrics
693            ValidationError::NoVariantMatched { best_match, .. } => {
694                if let Some(best) = best_match {
695                    // Recursively compute metrics from the nested union's best match
696                    let (nested_depth, nested_structural) =
697                        compute_depth_and_structural_match(&best.all_errors);
698                    max_depth = max_depth.max(nested_depth);
699                    // If nested union has structural mismatch, propagate it
700                    if !nested_structural {
701                        structural_match = false;
702                    }
703                }
704            }
705            // TypeMismatch at the union's level (min_depth) indicates structural mismatch
706            ValidationError::TypeMismatch { .. } if error.depth() == min_depth => {
707                max_depth = max_depth.max(error.depth());
708                structural_match = false;
709            }
710            // Other errors: just track depth
711            _ => {
712                max_depth = max_depth.max(error.depth());
713            }
714        }
715    }
716
717    (max_depth, structural_match)
718}
719
720/// Select the best matching variant from collected errors.
721///
722/// Used by both regular union validation and flattened union validation
723/// to determine which variant "almost matched" for error reporting.
724///
725/// The "best" variant is selected based on:
726/// 1. **Depth** (primary): Errors deeper in the structure indicate better match
727/// 2. **Error count** (secondary): Fewer errors indicate closer match
728/// 3. **Error priority** (tertiary): Higher priority errors indicate clearer mismatch
729///
730/// Returns None if no variants were tried or all had empty errors.
731pub fn select_best_variant_match(
732    variant_errors: Vec<(String, SchemaNodeId, Vec<ValidationError>)>,
733) -> Option<BestVariantMatch> {
734    if variant_errors.is_empty() {
735        return None;
736    }
737
738    // Find the best match based on metrics
739    let best = variant_errors
740        .into_iter()
741        .filter(|(_, _, errors)| !errors.is_empty())
742        .max_by_key(|(_, _, errors)| {
743            // Calculate metrics, looking through nested unions
744            let (max_depth, structural_match) = compute_depth_and_structural_match(errors);
745            let error_count = errors.len();
746            let max_priority = errors.iter().map(|e| e.priority_score()).max().unwrap_or(0);
747
748            // Return tuple for comparison:
749            // 1. structural_match: true > false (structural match is better)
750            // 2. depth: higher = better (got further into validation)
751            // 3. -count: fewer errors = better, so we use MAX - count
752            // 4. priority: higher = better (more significant mismatch to show)
753            (
754                structural_match,
755                max_depth,
756                usize::MAX - error_count,
757                max_priority,
758            )
759        });
760
761    best.map(|(variant_name, variant_schema_id, mut errors)| {
762        let depth = errors.iter().map(|e| e.depth()).max().unwrap_or(0);
763        let error_count = errors.len();
764
765        // Select primary error (highest priority, or deepest if tied)
766        errors.sort_by_key(|e| {
767            (
768                std::cmp::Reverse(e.priority_score()),
769                std::cmp::Reverse(e.depth()),
770            )
771        });
772        let primary_error = errors.first().cloned().unwrap();
773
774        BestVariantMatch {
775            variant_name,
776            variant_schema_id,
777            error: Box::new(primary_error),
778            all_errors: errors,
779            depth,
780            error_count,
781        }
782    })
783}