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