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