Skip to main content

oxirs_gql/
error_formatter.rs

1//! GraphQL error formatting and classification.
2//!
3//! Provides error classification (validation, execution, internal), path
4//! tracking, extension metadata (error codes, timestamps, trace IDs),
5//! deduplication, severity levels, and structured error response building.
6
7use std::collections::HashMap;
8
9// ────────────────────────────────────────────────────────────────────────────
10// Error classification
11// ────────────────────────────────────────────────────────────────────────────
12
13/// Classification of a GraphQL error.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum ErrorClass {
16    /// The query failed validation before execution.
17    Validation,
18    /// An error occurred during query execution (resolver failure, etc.).
19    Execution,
20    /// An internal server error not caused by the client.
21    Internal,
22    /// Authentication or authorization failure.
23    Authorization,
24    /// Rate limiting or resource exhaustion.
25    RateLimit,
26    /// Input coercion / type mismatch.
27    InputCoercion,
28}
29
30impl ErrorClass {
31    /// A short machine-readable code for this classification.
32    pub fn code(&self) -> &'static str {
33        match self {
34            ErrorClass::Validation => "VALIDATION_ERROR",
35            ErrorClass::Execution => "EXECUTION_ERROR",
36            ErrorClass::Internal => "INTERNAL_ERROR",
37            ErrorClass::Authorization => "AUTHORIZATION_ERROR",
38            ErrorClass::RateLimit => "RATE_LIMIT_ERROR",
39            ErrorClass::InputCoercion => "INPUT_COERCION_ERROR",
40        }
41    }
42
43    /// A human-readable label.
44    pub fn label(&self) -> &'static str {
45        match self {
46            ErrorClass::Validation => "Validation Error",
47            ErrorClass::Execution => "Execution Error",
48            ErrorClass::Internal => "Internal Error",
49            ErrorClass::Authorization => "Authorization Error",
50            ErrorClass::RateLimit => "Rate Limit Error",
51            ErrorClass::InputCoercion => "Input Coercion Error",
52        }
53    }
54}
55
56// ────────────────────────────────────────────────────────────────────────────
57// Error severity
58// ────────────────────────────────────────────────────────────────────────────
59
60/// Severity level for a GraphQL error.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
62pub enum Severity {
63    /// Informational; the request may have partially succeeded.
64    Info,
65    /// A warning; the response is still usable.
66    Warning,
67    /// An error; the affected field is null.
68    Error,
69    /// Critical; the entire request failed.
70    Critical,
71}
72
73impl Severity {
74    pub fn label(&self) -> &'static str {
75        match self {
76            Severity::Info => "info",
77            Severity::Warning => "warning",
78            Severity::Error => "error",
79            Severity::Critical => "critical",
80        }
81    }
82
83    /// Map a classification to its default severity.
84    pub fn from_class(class: ErrorClass) -> Self {
85        match class {
86            ErrorClass::Validation => Severity::Error,
87            ErrorClass::Execution => Severity::Error,
88            ErrorClass::Internal => Severity::Critical,
89            ErrorClass::Authorization => Severity::Error,
90            ErrorClass::RateLimit => Severity::Warning,
91            ErrorClass::InputCoercion => Severity::Error,
92        }
93    }
94}
95
96// ────────────────────────────────────────────────────────────────────────────
97// Path segment
98// ────────────────────────────────────────────────────────────────────────────
99
100/// A segment in the field path where an error occurred.
101#[derive(Debug, Clone, PartialEq, Eq, Hash)]
102pub enum PathSegment {
103    /// A named field.
104    Field(String),
105    /// An index into a list.
106    Index(usize),
107}
108
109impl PathSegment {
110    pub fn field(name: impl Into<String>) -> Self {
111        PathSegment::Field(name.into())
112    }
113
114    pub fn index(i: usize) -> Self {
115        PathSegment::Index(i)
116    }
117}
118
119impl std::fmt::Display for PathSegment {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            PathSegment::Field(name) => write!(f, "{}", name),
123            PathSegment::Index(i) => write!(f, "{}", i),
124        }
125    }
126}
127
128/// Format a path as a dotted string (e.g., `user.friends.0.name`).
129pub fn format_path(path: &[PathSegment]) -> String {
130    path.iter()
131        .map(|s| s.to_string())
132        .collect::<Vec<_>>()
133        .join(".")
134}
135
136// ────────────────────────────────────────────────────────────────────────────
137// Extension metadata
138// ────────────────────────────────────────────────────────────────────────────
139
140/// Extension metadata attached to a GraphQL error.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct ErrorExtensions {
143    /// Machine-readable error code (e.g., "VALIDATION_ERROR").
144    pub code: String,
145    /// ISO-8601 timestamp of when the error occurred.
146    pub timestamp: Option<String>,
147    /// Distributed trace identifier.
148    pub trace_id: Option<String>,
149    /// Extra key-value pairs.
150    pub extra: HashMap<String, String>,
151}
152
153impl ErrorExtensions {
154    pub fn new(code: impl Into<String>) -> Self {
155        Self {
156            code: code.into(),
157            timestamp: None,
158            trace_id: None,
159            extra: HashMap::new(),
160        }
161    }
162
163    pub fn with_timestamp(mut self, ts: impl Into<String>) -> Self {
164        self.timestamp = Some(ts.into());
165        self
166    }
167
168    pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
169        self.trace_id = Some(id.into());
170        self
171    }
172
173    pub fn with_extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
174        self.extra.insert(key.into(), value.into());
175        self
176    }
177}
178
179// ────────────────────────────────────────────────────────────────────────────
180// GraphQL error
181// ────────────────────────────────────────────────────────────────────────────
182
183/// A single GraphQL error.
184#[derive(Debug, Clone)]
185pub struct GraphQLError {
186    /// Human-readable error message.
187    pub message: String,
188    /// Classification.
189    pub classification: ErrorClass,
190    /// Severity level.
191    pub severity: Severity,
192    /// Path in the query where the error occurred.
193    pub path: Vec<PathSegment>,
194    /// Source locations in the query document.
195    pub locations: Vec<SourceLocation>,
196    /// Extension metadata.
197    pub extensions: Option<ErrorExtensions>,
198}
199
200/// A source location in the query document.
201#[derive(Debug, Clone, PartialEq, Eq, Hash)]
202pub struct SourceLocation {
203    pub line: usize,
204    pub column: usize,
205}
206
207impl SourceLocation {
208    pub fn new(line: usize, column: usize) -> Self {
209        Self { line, column }
210    }
211}
212
213impl GraphQLError {
214    /// Create a new error with a message and classification.
215    pub fn new(message: impl Into<String>, classification: ErrorClass) -> Self {
216        Self {
217            message: message.into(),
218            classification,
219            severity: Severity::from_class(classification),
220            path: Vec::new(),
221            locations: Vec::new(),
222            extensions: None,
223        }
224    }
225
226    /// Attach a field path.
227    pub fn with_path(mut self, path: Vec<PathSegment>) -> Self {
228        self.path = path;
229        self
230    }
231
232    /// Attach source locations.
233    pub fn with_locations(mut self, locations: Vec<SourceLocation>) -> Self {
234        self.locations = locations;
235        self
236    }
237
238    /// Override the default severity.
239    pub fn with_severity(mut self, severity: Severity) -> Self {
240        self.severity = severity;
241        self
242    }
243
244    /// Attach extension metadata.
245    pub fn with_extensions(mut self, ext: ErrorExtensions) -> Self {
246        self.extensions = Some(ext);
247        self
248    }
249
250    /// Convenience: attach a simple code extension.
251    pub fn with_code(self, code: impl Into<String>) -> Self {
252        self.with_extensions(ErrorExtensions::new(code))
253    }
254
255    /// Format the path as a dot-separated string.
256    pub fn path_string(&self) -> String {
257        format_path(&self.path)
258    }
259
260    /// Produce a deduplication key for grouping similar errors.
261    pub fn dedup_key(&self) -> String {
262        format!(
263            "{}:{}:{}",
264            self.classification.code(),
265            self.path_string(),
266            self.message
267        )
268    }
269}
270
271// ────────────────────────────────────────────────────────────────────────────
272// Custom error messages per type
273// ────────────────────────────────────────────────────────────────────────────
274
275/// Registry of custom error messages keyed by error code.
276pub struct ErrorMessageRegistry {
277    /// Maps error codes to custom message templates.
278    messages: HashMap<String, String>,
279}
280
281impl Default for ErrorMessageRegistry {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287impl ErrorMessageRegistry {
288    pub fn new() -> Self {
289        Self {
290            messages: HashMap::new(),
291        }
292    }
293
294    /// Register a custom message for an error code.
295    pub fn register(&mut self, code: impl Into<String>, message: impl Into<String>) {
296        self.messages.insert(code.into(), message.into());
297    }
298
299    /// Look up a custom message for the given error code.
300    pub fn get(&self, code: &str) -> Option<&str> {
301        self.messages.get(code).map(|s| s.as_str())
302    }
303
304    /// Apply the registry to an error: if a custom message exists for the
305    /// error's code, replace the error's message.
306    pub fn apply(&self, error: &mut GraphQLError) {
307        if let Some(ext) = &error.extensions {
308            if let Some(custom) = self.messages.get(&ext.code) {
309                error.message = custom.clone();
310            }
311        }
312    }
313
314    /// Number of registered custom messages.
315    pub fn len(&self) -> usize {
316        self.messages.len()
317    }
318
319    pub fn is_empty(&self) -> bool {
320        self.messages.is_empty()
321    }
322}
323
324// ────────────────────────────────────────────────────────────────────────────
325// Error aggregation (dedup)
326// ────────────────────────────────────────────────────────────────────────────
327
328/// Aggregates GraphQL errors, deduplicating identical ones and tracking counts.
329pub struct ErrorAggregator {
330    /// Deduplicated errors keyed by dedup_key.
331    errors: HashMap<String, AggregatedError>,
332    /// Insertion order for deterministic output.
333    order: Vec<String>,
334}
335
336/// An error that may have been seen multiple times.
337#[derive(Debug, Clone)]
338pub struct AggregatedError {
339    pub error: GraphQLError,
340    pub count: usize,
341}
342
343impl Default for ErrorAggregator {
344    fn default() -> Self {
345        Self::new()
346    }
347}
348
349impl ErrorAggregator {
350    pub fn new() -> Self {
351        Self {
352            errors: HashMap::new(),
353            order: Vec::new(),
354        }
355    }
356
357    /// Add an error to the aggregator.
358    pub fn add(&mut self, error: GraphQLError) {
359        let key = error.dedup_key();
360        if let Some(existing) = self.errors.get_mut(&key) {
361            existing.count += 1;
362        } else {
363            self.order.push(key.clone());
364            self.errors.insert(key, AggregatedError { error, count: 1 });
365        }
366    }
367
368    /// Number of unique errors.
369    pub fn unique_count(&self) -> usize {
370        self.errors.len()
371    }
372
373    /// Total errors (including duplicates).
374    pub fn total_count(&self) -> usize {
375        self.errors.values().map(|e| e.count).sum()
376    }
377
378    /// Retrieve unique errors in insertion order.
379    pub fn errors(&self) -> Vec<&AggregatedError> {
380        self.order
381            .iter()
382            .filter_map(|k| self.errors.get(k))
383            .collect()
384    }
385
386    /// Drain all aggregated errors, consuming the aggregator.
387    pub fn into_errors(self) -> Vec<AggregatedError> {
388        self.order
389            .into_iter()
390            .filter_map(|k| self.errors.get(&k).cloned())
391            .collect()
392    }
393
394    /// Clear all accumulated errors.
395    pub fn clear(&mut self) {
396        self.errors.clear();
397        self.order.clear();
398    }
399
400    /// Filter errors by minimum severity.
401    pub fn errors_above_severity(&self, min: Severity) -> Vec<&AggregatedError> {
402        self.errors()
403            .into_iter()
404            .filter(|ae| ae.error.severity >= min)
405            .collect()
406    }
407}
408
409// ────────────────────────────────────────────────────────────────────────────
410// Structured response builder
411// ────────────────────────────────────────────────────────────────────────────
412
413/// A structured GraphQL error response (matching the GraphQL spec shape).
414#[derive(Debug, Clone)]
415pub struct ErrorResponse {
416    pub errors: Vec<ErrorEntry>,
417}
418
419/// One entry in the `errors` array of a GraphQL response.
420#[derive(Debug, Clone)]
421pub struct ErrorEntry {
422    pub message: String,
423    pub path: Option<Vec<String>>,
424    pub locations: Option<Vec<SourceLocation>>,
425    pub extensions: Option<HashMap<String, String>>,
426}
427
428/// Builder for constructing a complete error response.
429pub struct ErrorResponseBuilder {
430    entries: Vec<ErrorEntry>,
431    default_timestamp: Option<String>,
432    default_trace_id: Option<String>,
433}
434
435impl Default for ErrorResponseBuilder {
436    fn default() -> Self {
437        Self::new()
438    }
439}
440
441impl ErrorResponseBuilder {
442    pub fn new() -> Self {
443        Self {
444            entries: Vec::new(),
445            default_timestamp: None,
446            default_trace_id: None,
447        }
448    }
449
450    /// Set a default timestamp for all entries.
451    pub fn with_timestamp(mut self, ts: impl Into<String>) -> Self {
452        self.default_timestamp = Some(ts.into());
453        self
454    }
455
456    /// Set a default trace ID for all entries.
457    pub fn with_trace_id(mut self, id: impl Into<String>) -> Self {
458        self.default_trace_id = Some(id.into());
459        self
460    }
461
462    /// Add a single GraphQL error.
463    pub fn add_error(&mut self, error: &GraphQLError) {
464        let path = if error.path.is_empty() {
465            None
466        } else {
467            Some(error.path.iter().map(|s| s.to_string()).collect())
468        };
469
470        let locations = if error.locations.is_empty() {
471            None
472        } else {
473            Some(error.locations.clone())
474        };
475
476        let mut ext_map = HashMap::new();
477        ext_map.insert("code".to_string(), error.classification.code().to_string());
478        ext_map.insert("severity".to_string(), error.severity.label().to_string());
479        ext_map.insert(
480            "classification".to_string(),
481            error.classification.label().to_string(),
482        );
483
484        if let Some(ref ts) = self.default_timestamp {
485            ext_map.insert("timestamp".to_string(), ts.clone());
486        }
487        if let Some(ref tid) = self.default_trace_id {
488            ext_map.insert("traceId".to_string(), tid.clone());
489        }
490
491        if let Some(ref exts) = error.extensions {
492            // Error-level code overrides classification code
493            if !exts.code.is_empty() {
494                ext_map.insert("code".to_string(), exts.code.clone());
495            }
496            if let Some(ref ts) = exts.timestamp {
497                ext_map.insert("timestamp".to_string(), ts.clone());
498            }
499            if let Some(ref tid) = exts.trace_id {
500                ext_map.insert("traceId".to_string(), tid.clone());
501            }
502            for (k, v) in &exts.extra {
503                ext_map.insert(k.clone(), v.clone());
504            }
505        }
506
507        self.entries.push(ErrorEntry {
508            message: error.message.clone(),
509            path,
510            locations,
511            extensions: Some(ext_map),
512        });
513    }
514
515    /// Add errors from an aggregator (with dedup counts).
516    pub fn add_aggregated(&mut self, aggregator: &ErrorAggregator) {
517        for ae in aggregator.errors() {
518            self.add_error(&ae.error);
519            // Include count in extensions if > 1
520            if ae.count > 1 {
521                if let Some(entry) = self.entries.last_mut() {
522                    if let Some(ref mut ext) = entry.extensions {
523                        ext.insert("occurrences".to_string(), ae.count.to_string());
524                    }
525                }
526            }
527        }
528    }
529
530    /// Build the final response.
531    pub fn build(self) -> ErrorResponse {
532        ErrorResponse {
533            errors: self.entries,
534        }
535    }
536
537    /// Number of entries accumulated.
538    pub fn entry_count(&self) -> usize {
539        self.entries.len()
540    }
541}
542
543/// Serialize an [`ErrorResponse`] to a JSON-like string representation.
544pub fn format_response_json(response: &ErrorResponse) -> String {
545    let mut out = String::from("{\"errors\":[");
546    for (i, entry) in response.errors.iter().enumerate() {
547        if i > 0 {
548            out.push(',');
549        }
550        out.push_str("{\"message\":");
551        out.push_str(&json_escape(&entry.message));
552
553        if let Some(ref path) = entry.path {
554            out.push_str(",\"path\":[");
555            for (j, seg) in path.iter().enumerate() {
556                if j > 0 {
557                    out.push(',');
558                }
559                // Try to parse as number for array indices
560                if seg.parse::<usize>().is_ok() {
561                    out.push_str(seg);
562                } else {
563                    out.push_str(&json_escape(seg));
564                }
565            }
566            out.push(']');
567        }
568
569        if let Some(ref locs) = entry.locations {
570            out.push_str(",\"locations\":[");
571            for (j, loc) in locs.iter().enumerate() {
572                if j > 0 {
573                    out.push(',');
574                }
575                out.push_str(&format!(
576                    "{{\"line\":{},\"column\":{}}}",
577                    loc.line, loc.column
578                ));
579            }
580            out.push(']');
581        }
582
583        if let Some(ref ext) = entry.extensions {
584            out.push_str(",\"extensions\":{");
585            let mut sorted_keys: Vec<&String> = ext.keys().collect();
586            sorted_keys.sort();
587            for (j, key) in sorted_keys.iter().enumerate() {
588                if j > 0 {
589                    out.push(',');
590                }
591                if let Some(val) = ext.get(*key) {
592                    out.push_str(&format!("{}:{}", json_escape(key), json_escape(val)));
593                }
594            }
595            out.push('}');
596        }
597
598        out.push('}');
599    }
600    out.push_str("]}");
601    out
602}
603
604/// Simple JSON string escaping.
605fn json_escape(s: &str) -> String {
606    let mut out = String::with_capacity(s.len() + 2);
607    out.push('"');
608    for ch in s.chars() {
609        match ch {
610            '"' => out.push_str("\\\""),
611            '\\' => out.push_str("\\\\"),
612            '\n' => out.push_str("\\n"),
613            '\r' => out.push_str("\\r"),
614            '\t' => out.push_str("\\t"),
615            c => out.push(c),
616        }
617    }
618    out.push('"');
619    out
620}
621
622// ────────────────────────────────────────────────────────────────────────────
623// Tests
624// ────────────────────────────────────────────────────────────────────────────
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    // ── ErrorClass ──────────────────────────────────────────────────────────
631
632    #[test]
633    fn test_error_class_codes() {
634        assert_eq!(ErrorClass::Validation.code(), "VALIDATION_ERROR");
635        assert_eq!(ErrorClass::Execution.code(), "EXECUTION_ERROR");
636        assert_eq!(ErrorClass::Internal.code(), "INTERNAL_ERROR");
637        assert_eq!(ErrorClass::Authorization.code(), "AUTHORIZATION_ERROR");
638        assert_eq!(ErrorClass::RateLimit.code(), "RATE_LIMIT_ERROR");
639        assert_eq!(ErrorClass::InputCoercion.code(), "INPUT_COERCION_ERROR");
640    }
641
642    #[test]
643    fn test_error_class_labels() {
644        assert_eq!(ErrorClass::Validation.label(), "Validation Error");
645        assert_eq!(ErrorClass::Internal.label(), "Internal Error");
646    }
647
648    // ── Severity ────────────────────────────────────────────────────────────
649
650    #[test]
651    fn test_severity_ordering() {
652        assert!(Severity::Info < Severity::Warning);
653        assert!(Severity::Warning < Severity::Error);
654        assert!(Severity::Error < Severity::Critical);
655    }
656
657    #[test]
658    fn test_severity_labels() {
659        assert_eq!(Severity::Info.label(), "info");
660        assert_eq!(Severity::Warning.label(), "warning");
661        assert_eq!(Severity::Error.label(), "error");
662        assert_eq!(Severity::Critical.label(), "critical");
663    }
664
665    #[test]
666    fn test_severity_from_class() {
667        assert_eq!(
668            Severity::from_class(ErrorClass::Validation),
669            Severity::Error
670        );
671        assert_eq!(
672            Severity::from_class(ErrorClass::Internal),
673            Severity::Critical
674        );
675        assert_eq!(
676            Severity::from_class(ErrorClass::RateLimit),
677            Severity::Warning
678        );
679    }
680
681    // ── PathSegment ─────────────────────────────────────────────────────────
682
683    #[test]
684    fn test_path_segment_display() {
685        assert_eq!(PathSegment::Field("user".into()).to_string(), "user");
686        assert_eq!(PathSegment::Index(3).to_string(), "3");
687    }
688
689    #[test]
690    fn test_format_path() {
691        let path = vec![
692            PathSegment::field("user"),
693            PathSegment::field("friends"),
694            PathSegment::index(0),
695            PathSegment::field("name"),
696        ];
697        assert_eq!(format_path(&path), "user.friends.0.name");
698    }
699
700    #[test]
701    fn test_format_path_empty() {
702        assert_eq!(format_path(&[]), "");
703    }
704
705    // ── ErrorExtensions ─────────────────────────────────────────────────────
706
707    #[test]
708    fn test_extensions_builder() {
709        let ext = ErrorExtensions::new("CUSTOM_CODE")
710            .with_timestamp("2026-03-03T00:00:00Z")
711            .with_trace_id("trace-abc-123")
712            .with_extra("service", "gql-server");
713
714        assert_eq!(ext.code, "CUSTOM_CODE");
715        assert_eq!(ext.timestamp, Some("2026-03-03T00:00:00Z".to_string()));
716        assert_eq!(ext.trace_id, Some("trace-abc-123".to_string()));
717        assert_eq!(ext.extra.get("service"), Some(&"gql-server".to_string()));
718    }
719
720    // ── GraphQLError ────────────────────────────────────────────────────────
721
722    #[test]
723    fn test_error_basic() {
724        let err = GraphQLError::new("field not found", ErrorClass::Validation);
725        assert_eq!(err.message, "field not found");
726        assert_eq!(err.classification, ErrorClass::Validation);
727        assert_eq!(err.severity, Severity::Error);
728        assert!(err.path.is_empty());
729    }
730
731    #[test]
732    fn test_error_with_path() {
733        let err = GraphQLError::new("null value", ErrorClass::Execution)
734            .with_path(vec![PathSegment::field("user"), PathSegment::field("name")]);
735        assert_eq!(err.path_string(), "user.name");
736    }
737
738    #[test]
739    fn test_error_with_locations() {
740        let err = GraphQLError::new("syntax error", ErrorClass::Validation)
741            .with_locations(vec![SourceLocation::new(3, 10)]);
742        assert_eq!(err.locations.len(), 1);
743        assert_eq!(err.locations[0].line, 3);
744        assert_eq!(err.locations[0].column, 10);
745    }
746
747    #[test]
748    fn test_error_with_severity_override() {
749        let err = GraphQLError::new("deprecated field", ErrorClass::Execution)
750            .with_severity(Severity::Warning);
751        assert_eq!(err.severity, Severity::Warning);
752    }
753
754    #[test]
755    fn test_error_with_code() {
756        let err = GraphQLError::new("test", ErrorClass::Internal).with_code("CUSTOM");
757        assert!(err.extensions.is_some());
758        let ext = err.extensions.as_ref().expect("should have extensions");
759        assert_eq!(ext.code, "CUSTOM");
760    }
761
762    #[test]
763    fn test_error_dedup_key() {
764        let err1 = GraphQLError::new("bad field", ErrorClass::Validation)
765            .with_path(vec![PathSegment::field("user")]);
766        let err2 = GraphQLError::new("bad field", ErrorClass::Validation)
767            .with_path(vec![PathSegment::field("user")]);
768        assert_eq!(err1.dedup_key(), err2.dedup_key());
769    }
770
771    #[test]
772    fn test_error_dedup_key_different() {
773        let err1 = GraphQLError::new("bad field", ErrorClass::Validation);
774        let err2 = GraphQLError::new("other error", ErrorClass::Validation);
775        assert_ne!(err1.dedup_key(), err2.dedup_key());
776    }
777
778    // ── ErrorMessageRegistry ────────────────────────────────────────────────
779
780    #[test]
781    fn test_message_registry_register_and_get() {
782        let mut reg = ErrorMessageRegistry::new();
783        reg.register("AUTH_FAIL", "Please login to continue.");
784        assert_eq!(reg.get("AUTH_FAIL"), Some("Please login to continue."));
785        assert_eq!(reg.get("OTHER"), None);
786    }
787
788    #[test]
789    fn test_message_registry_apply() {
790        let mut reg = ErrorMessageRegistry::new();
791        reg.register("CUSTOM", "A friendly error message");
792
793        let mut err = GraphQLError::new("raw error", ErrorClass::Execution)
794            .with_extensions(ErrorExtensions::new("CUSTOM"));
795        reg.apply(&mut err);
796        assert_eq!(err.message, "A friendly error message");
797    }
798
799    #[test]
800    fn test_message_registry_no_match() {
801        let mut reg = ErrorMessageRegistry::new();
802        reg.register("OTHER", "something");
803
804        let mut err = GraphQLError::new("original", ErrorClass::Execution)
805            .with_extensions(ErrorExtensions::new("NOMATCH"));
806        reg.apply(&mut err);
807        assert_eq!(err.message, "original");
808    }
809
810    #[test]
811    fn test_message_registry_counts() {
812        let mut reg = ErrorMessageRegistry::new();
813        assert!(reg.is_empty());
814        reg.register("A", "msg A");
815        reg.register("B", "msg B");
816        assert_eq!(reg.len(), 2);
817        assert!(!reg.is_empty());
818    }
819
820    #[test]
821    fn test_message_registry_default() {
822        let reg = ErrorMessageRegistry::default();
823        assert!(reg.is_empty());
824    }
825
826    // ── ErrorAggregator ─────────────────────────────────────────────────────
827
828    #[test]
829    fn test_aggregator_dedup() {
830        let mut agg = ErrorAggregator::new();
831        agg.add(GraphQLError::new("same error", ErrorClass::Execution));
832        agg.add(GraphQLError::new("same error", ErrorClass::Execution));
833        agg.add(GraphQLError::new("different", ErrorClass::Execution));
834
835        assert_eq!(agg.unique_count(), 2);
836        assert_eq!(agg.total_count(), 3);
837    }
838
839    #[test]
840    fn test_aggregator_order_preserved() {
841        let mut agg = ErrorAggregator::new();
842        agg.add(GraphQLError::new("first", ErrorClass::Validation));
843        agg.add(GraphQLError::new("second", ErrorClass::Execution));
844        agg.add(GraphQLError::new("third", ErrorClass::Internal));
845
846        let errors = agg.errors();
847        assert_eq!(errors[0].error.message, "first");
848        assert_eq!(errors[1].error.message, "second");
849        assert_eq!(errors[2].error.message, "third");
850    }
851
852    #[test]
853    fn test_aggregator_clear() {
854        let mut agg = ErrorAggregator::new();
855        agg.add(GraphQLError::new("e", ErrorClass::Execution));
856        agg.clear();
857        assert_eq!(agg.unique_count(), 0);
858        assert_eq!(agg.total_count(), 0);
859    }
860
861    #[test]
862    fn test_aggregator_into_errors() {
863        let mut agg = ErrorAggregator::new();
864        agg.add(GraphQLError::new("e1", ErrorClass::Execution));
865        agg.add(GraphQLError::new("e2", ErrorClass::Execution));
866        let errors = agg.into_errors();
867        assert_eq!(errors.len(), 2);
868    }
869
870    #[test]
871    fn test_aggregator_severity_filter() {
872        let mut agg = ErrorAggregator::new();
873        agg.add(GraphQLError::new("info", ErrorClass::Execution).with_severity(Severity::Info));
874        agg.add(GraphQLError::new("warn", ErrorClass::RateLimit).with_severity(Severity::Warning));
875        agg.add(GraphQLError::new("err", ErrorClass::Execution));
876        agg.add(GraphQLError::new("crit", ErrorClass::Internal));
877
878        let critical = agg.errors_above_severity(Severity::Critical);
879        assert_eq!(critical.len(), 1);
880        assert_eq!(critical[0].error.message, "crit");
881
882        let warnings = agg.errors_above_severity(Severity::Warning);
883        assert_eq!(warnings.len(), 3);
884    }
885
886    #[test]
887    fn test_aggregator_default() {
888        let agg = ErrorAggregator::default();
889        assert_eq!(agg.unique_count(), 0);
890    }
891
892    // ── ErrorResponseBuilder ────────────────────────────────────────────────
893
894    #[test]
895    fn test_response_builder_single_error() {
896        let mut builder = ErrorResponseBuilder::new();
897        let err = GraphQLError::new("not found", ErrorClass::Execution)
898            .with_path(vec![PathSegment::field("user")]);
899        builder.add_error(&err);
900
901        let response = builder.build();
902        assert_eq!(response.errors.len(), 1);
903        assert_eq!(response.errors[0].message, "not found");
904        assert!(response.errors[0].path.is_some());
905    }
906
907    #[test]
908    fn test_response_builder_with_timestamp() {
909        let mut builder = ErrorResponseBuilder::new().with_timestamp("2026-03-03T00:00:00Z");
910        builder.add_error(&GraphQLError::new("err", ErrorClass::Internal));
911
912        let response = builder.build();
913        let ext = response.errors[0].extensions.as_ref().expect("extensions");
914        assert_eq!(
915            ext.get("timestamp"),
916            Some(&"2026-03-03T00:00:00Z".to_string())
917        );
918    }
919
920    #[test]
921    fn test_response_builder_with_trace_id() {
922        let mut builder = ErrorResponseBuilder::new().with_trace_id("trace-42");
923        builder.add_error(&GraphQLError::new("err", ErrorClass::Execution));
924
925        let response = builder.build();
926        let ext = response.errors[0].extensions.as_ref().expect("extensions");
927        assert_eq!(ext.get("traceId"), Some(&"trace-42".to_string()));
928    }
929
930    #[test]
931    fn test_response_builder_aggregated() {
932        let mut agg = ErrorAggregator::new();
933        agg.add(GraphQLError::new("dup", ErrorClass::Execution));
934        agg.add(GraphQLError::new("dup", ErrorClass::Execution));
935
936        let mut builder = ErrorResponseBuilder::new();
937        builder.add_aggregated(&agg);
938
939        let response = builder.build();
940        assert_eq!(response.errors.len(), 1);
941        let ext = response.errors[0].extensions.as_ref().expect("extensions");
942        assert_eq!(ext.get("occurrences"), Some(&"2".to_string()));
943    }
944
945    #[test]
946    fn test_response_builder_entry_count() {
947        let mut builder = ErrorResponseBuilder::new();
948        assert_eq!(builder.entry_count(), 0);
949        builder.add_error(&GraphQLError::new("a", ErrorClass::Execution));
950        builder.add_error(&GraphQLError::new("b", ErrorClass::Validation));
951        assert_eq!(builder.entry_count(), 2);
952    }
953
954    #[test]
955    fn test_response_builder_default() {
956        let builder = ErrorResponseBuilder::default();
957        assert_eq!(builder.entry_count(), 0);
958    }
959
960    // ── JSON formatting ─────────────────────────────────────────────────────
961
962    #[test]
963    fn test_format_response_json_basic() {
964        let mut builder = ErrorResponseBuilder::new();
965        builder.add_error(&GraphQLError::new("oops", ErrorClass::Execution));
966        let response = builder.build();
967
968        let json = format_response_json(&response);
969        assert!(json.contains("\"errors\""));
970        assert!(json.contains("oops"));
971        assert!(json.contains("EXECUTION_ERROR"));
972    }
973
974    #[test]
975    fn test_format_response_json_with_path() {
976        let mut builder = ErrorResponseBuilder::new();
977        builder.add_error(
978            &GraphQLError::new("null", ErrorClass::Execution)
979                .with_path(vec![PathSegment::field("user"), PathSegment::index(0)]),
980        );
981        let response = builder.build();
982
983        let json = format_response_json(&response);
984        assert!(json.contains("\"path\""));
985        assert!(json.contains("\"user\""));
986        assert!(json.contains("0"));
987    }
988
989    #[test]
990    fn test_format_response_json_with_locations() {
991        let mut builder = ErrorResponseBuilder::new();
992        builder.add_error(
993            &GraphQLError::new("parse error", ErrorClass::Validation)
994                .with_locations(vec![SourceLocation::new(5, 12)]),
995        );
996        let response = builder.build();
997
998        let json = format_response_json(&response);
999        assert!(json.contains("\"locations\""));
1000        assert!(json.contains("\"line\":5"));
1001        assert!(json.contains("\"column\":12"));
1002    }
1003
1004    #[test]
1005    fn test_format_response_json_empty() {
1006        let builder = ErrorResponseBuilder::new();
1007        let response = builder.build();
1008        let json = format_response_json(&response);
1009        assert_eq!(json, "{\"errors\":[]}");
1010    }
1011
1012    #[test]
1013    fn test_format_response_json_multiple_errors() {
1014        let mut builder = ErrorResponseBuilder::new();
1015        builder.add_error(&GraphQLError::new("e1", ErrorClass::Execution));
1016        builder.add_error(&GraphQLError::new("e2", ErrorClass::Validation));
1017        let response = builder.build();
1018
1019        let json = format_response_json(&response);
1020        assert!(json.contains("e1"));
1021        assert!(json.contains("e2"));
1022    }
1023
1024    // ── json_escape ─────────────────────────────────────────────────────────
1025
1026    #[test]
1027    fn test_json_escape_simple() {
1028        assert_eq!(json_escape("hello"), "\"hello\"");
1029    }
1030
1031    #[test]
1032    fn test_json_escape_special_chars() {
1033        assert_eq!(json_escape("a\"b"), "\"a\\\"b\"");
1034        assert_eq!(json_escape("a\\b"), "\"a\\\\b\"");
1035        assert_eq!(json_escape("a\nb"), "\"a\\nb\"");
1036        assert_eq!(json_escape("a\tb"), "\"a\\tb\"");
1037        assert_eq!(json_escape("a\rb"), "\"a\\rb\"");
1038    }
1039
1040    // ── SourceLocation ──────────────────────────────────────────────────────
1041
1042    #[test]
1043    fn test_source_location_new() {
1044        let loc = SourceLocation::new(10, 20);
1045        assert_eq!(loc.line, 10);
1046        assert_eq!(loc.column, 20);
1047    }
1048
1049    // ── GraphQLError extensions chain ───────────────────────────────────────
1050
1051    #[test]
1052    fn test_error_extensions_override_builder_defaults() {
1053        let mut builder = ErrorResponseBuilder::new().with_timestamp("builder-ts");
1054
1055        let err = GraphQLError::new("test", ErrorClass::Execution)
1056            .with_extensions(ErrorExtensions::new("CODE").with_timestamp("error-ts"));
1057        builder.add_error(&err);
1058
1059        let response = builder.build();
1060        let ext = response.errors[0].extensions.as_ref().expect("extensions");
1061        // Error-level timestamp overrides builder-level
1062        assert_eq!(ext.get("timestamp"), Some(&"error-ts".to_string()));
1063    }
1064
1065    #[test]
1066    fn test_error_path_string_empty() {
1067        let err = GraphQLError::new("e", ErrorClass::Execution);
1068        assert_eq!(err.path_string(), "");
1069    }
1070
1071    // ── Integration: full pipeline ──────────────────────────────────────────
1072
1073    #[test]
1074    fn test_full_pipeline_aggregation_to_response() {
1075        let mut agg = ErrorAggregator::new();
1076
1077        // Add validation error
1078        agg.add(
1079            GraphQLError::new("field 'xyz' not found", ErrorClass::Validation)
1080                .with_path(vec![PathSegment::field("query"), PathSegment::field("xyz")])
1081                .with_locations(vec![SourceLocation::new(1, 5)])
1082                .with_extensions(ErrorExtensions::new("UNKNOWN_FIELD").with_trace_id("t-001")),
1083        );
1084
1085        // Add execution error (duplicate)
1086        agg.add(
1087            GraphQLError::new("resolver failed", ErrorClass::Execution)
1088                .with_path(vec![PathSegment::field("user"), PathSegment::field("name")]),
1089        );
1090        agg.add(
1091            GraphQLError::new("resolver failed", ErrorClass::Execution)
1092                .with_path(vec![PathSegment::field("user"), PathSegment::field("name")]),
1093        );
1094
1095        assert_eq!(agg.unique_count(), 2);
1096        assert_eq!(agg.total_count(), 3);
1097
1098        let mut builder = ErrorResponseBuilder::new()
1099            .with_timestamp("2026-03-03T12:00:00Z")
1100            .with_trace_id("global-trace");
1101
1102        builder.add_aggregated(&agg);
1103        let response = builder.build();
1104
1105        assert_eq!(response.errors.len(), 2);
1106
1107        // First error should have trace-id from extensions (overriding global)
1108        let ext0 = response.errors[0].extensions.as_ref().expect("extensions");
1109        assert_eq!(ext0.get("traceId"), Some(&"t-001".to_string()));
1110
1111        // Second error should have duplicate count
1112        let ext1 = response.errors[1].extensions.as_ref().expect("extensions");
1113        assert_eq!(ext1.get("occurrences"), Some(&"2".to_string()));
1114
1115        let json = format_response_json(&response);
1116        assert!(json.contains("UNKNOWN_FIELD"));
1117        assert!(json.contains("resolver failed"));
1118    }
1119}