Skip to main content

copybook_error_reporter/
lib.rs

1#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Structured error reporting and handling system
4//!
5//! This module provides comprehensive error reporting with configurable handling modes,
6//! detailed logging, and summary statistics for copybook processing operations.
7
8use copybook_error::{Error, ErrorCode};
9use std::collections::HashMap;
10use std::fmt;
11use std::fmt::Write as _;
12use tracing::{debug, error, warn};
13
14/// Error handling mode configuration
15///
16/// # Examples
17///
18/// ```
19/// use copybook_error_reporter::ErrorMode;
20///
21/// let mode = ErrorMode::Strict;
22/// assert_eq!(mode, ErrorMode::Strict);
23/// assert_ne!(mode, ErrorMode::Lenient);
24/// ```
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ErrorMode {
27    /// Stop processing on first error
28    Strict,
29    /// Continue processing, skip bad records
30    Lenient,
31}
32
33/// Error severity levels
34///
35/// # Examples
36///
37/// ```
38/// use copybook_error_reporter::ErrorSeverity;
39///
40/// assert!(ErrorSeverity::Fatal > ErrorSeverity::Error);
41/// assert!(ErrorSeverity::Error > ErrorSeverity::Warning);
42/// assert!(ErrorSeverity::Warning > ErrorSeverity::Info);
43/// ```
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
45pub enum ErrorSeverity {
46    /// Informational messages
47    Info,
48    /// Warning conditions that don't prevent processing
49    Warning,
50    /// Error conditions that affect individual records
51    Error,
52    /// Fatal conditions that prevent further processing
53    Fatal,
54}
55
56/// Detailed error report with context and metadata
57#[derive(Debug, Clone)]
58pub struct ErrorReport {
59    /// The underlying error
60    pub error: Error,
61    /// Error severity level
62    pub severity: ErrorSeverity,
63    /// Timestamp when error occurred
64    pub timestamp: std::time::SystemTime,
65    /// Additional metadata
66    pub metadata: HashMap<String, String>,
67}
68
69/// Comprehensive error statistics and summary
70#[derive(Debug, Clone, Default)]
71pub struct ErrorSummary {
72    /// Total number of errors by severity
73    pub error_counts: HashMap<ErrorSeverity, u64>,
74    /// Error counts by error code
75    pub error_codes: HashMap<ErrorCode, u64>,
76    /// Records that had errors
77    pub records_with_errors: u64,
78    /// Total records processed
79    pub total_records: u64,
80    /// First error encountered (for debugging)
81    pub first_error: Option<ErrorReport>,
82    /// Most recent error (for debugging)
83    pub last_error: Option<ErrorReport>,
84    /// Transfer corruption warnings detected
85    pub corruption_warnings: u64,
86}
87
88/// Structured error reporter with configurable handling
89pub struct ErrorReporter {
90    /// Error handling mode
91    mode: ErrorMode,
92    /// Maximum errors before stopping (None = unlimited)
93    max_errors: Option<u64>,
94    /// Current error summary
95    summary: ErrorSummary,
96    /// Whether to log detailed error context
97    verbose_logging: bool,
98}
99
100impl ErrorReporter {
101    /// Create a new error reporter with the specified mode
102    #[must_use]
103    #[inline]
104    pub fn new(mode: ErrorMode, max_errors: Option<u64>) -> Self {
105        Self {
106            mode,
107            max_errors,
108            summary: ErrorSummary::default(),
109            verbose_logging: true,
110        }
111    }
112
113    /// Set verbose logging mode
114    #[must_use]
115    #[inline]
116    pub fn with_verbose_logging(mut self, verbose: bool) -> Self {
117        self.verbose_logging = verbose;
118        self
119    }
120
121    /// Report an error and determine if processing should continue.
122    ///
123    /// Returns `Ok(())` when work may proceed and `Err(error)` when it must stop.
124    ///
125    /// # Panics
126    /// Panics if `max_errors` is configured but becomes `None` during processing.
127    ///
128    /// # Errors
129    /// Returns an error when severity or error limits require halting processing.
130    #[inline]
131    #[must_use = "Handle the Result or propagate the error"]
132    pub fn report_error(&mut self, error: Error) -> Result<(), Error> {
133        let severity = self.determine_severity(&error);
134        let report = ErrorReport {
135            error: error.clone(),
136            severity,
137            timestamp: std::time::SystemTime::now(),
138            metadata: HashMap::new(),
139        };
140
141        // Check if we should stop processing before updating statistics
142        let should_continue = match severity {
143            ErrorSeverity::Fatal => false,
144            ErrorSeverity::Error => {
145                if self.mode == ErrorMode::Strict {
146                    false
147                } else if let Some(max) = self.max_errors {
148                    let current_error_count = self
149                        .summary
150                        .error_counts
151                        .get(&ErrorSeverity::Error)
152                        .unwrap_or(&0);
153                    *current_error_count < max
154                } else {
155                    true
156                }
157            }
158            ErrorSeverity::Warning | ErrorSeverity::Info => true,
159        };
160
161        // Update statistics
162        self.update_statistics(&report);
163
164        // Log the error with appropriate level
165        self.log_error(&report);
166
167        // Return result based on decision
168        if should_continue {
169            Ok(())
170        } else if matches!(severity, ErrorSeverity::Error) && self.max_errors.is_some() {
171            Err(Error::new(
172                ErrorCode::CBKS141_RECORD_TOO_LARGE, // Reusing for "too many errors"
173                format!(
174                    "Maximum error limit reached: {}",
175                    self.max_errors.unwrap_or(0)
176                ),
177            ))
178        } else {
179            Err(error)
180        }
181    }
182
183    /// Report a warning (always continues processing)
184    #[inline]
185    pub fn report_warning(&mut self, error: Error) {
186        let mut report = ErrorReport {
187            error,
188            severity: ErrorSeverity::Warning,
189            timestamp: std::time::SystemTime::now(),
190            metadata: HashMap::new(),
191        };
192
193        // Check for transfer corruption patterns
194        if is_corruption_warning(&report.error) {
195            self.summary.corruption_warnings += 1;
196            report
197                .metadata
198                .insert("corruption_type".to_string(), "transfer".to_string());
199        }
200
201        self.update_statistics(&report);
202        self.log_error(&report);
203    }
204
205    /// Report record processing start (for context tracking)
206    #[inline]
207    pub fn start_record(&mut self, record_index: u64) {
208        self.summary.total_records = record_index;
209        debug!("Processing record {record_index}");
210    }
211
212    /// Get the current error summary
213    #[must_use]
214    #[inline]
215    pub fn summary(&self) -> &ErrorSummary {
216        &self.summary
217    }
218
219    /// Check if any errors have been reported
220    #[must_use]
221    #[inline]
222    pub fn has_errors(&self) -> bool {
223        self.summary
224            .error_counts
225            .get(&ErrorSeverity::Error)
226            .unwrap_or(&0)
227            > &0
228            || self
229                .summary
230                .error_counts
231                .get(&ErrorSeverity::Fatal)
232                .unwrap_or(&0)
233                > &0
234    }
235
236    /// Check if any warnings have been reported
237    #[must_use]
238    #[inline]
239    pub fn has_warnings(&self) -> bool {
240        self.summary
241            .error_counts
242            .get(&ErrorSeverity::Warning)
243            .unwrap_or(&0)
244            > &0
245    }
246
247    /// Get total error count (excluding warnings)
248    #[must_use]
249    #[inline]
250    pub fn error_count(&self) -> u64 {
251        self.summary
252            .error_counts
253            .get(&ErrorSeverity::Error)
254            .unwrap_or(&0)
255            + self
256                .summary
257                .error_counts
258                .get(&ErrorSeverity::Fatal)
259                .unwrap_or(&0)
260    }
261
262    /// Get total warning count
263    #[must_use]
264    #[inline]
265    pub fn warning_count(&self) -> u64 {
266        *self
267            .summary
268            .error_counts
269            .get(&ErrorSeverity::Warning)
270            .unwrap_or(&0)
271    }
272
273    /// Generate a detailed error report for display
274    #[must_use]
275    #[inline]
276    pub fn generate_report(&self) -> String {
277        let mut report = String::new();
278
279        report.push_str("=== Error Summary ===\n");
280        let _ = writeln!(
281            report,
282            "Total records processed: {}",
283            self.summary.total_records
284        );
285        let _ = writeln!(
286            report,
287            "Records with errors: {}",
288            self.summary.records_with_errors
289        );
290
291        if !self.summary.error_counts.is_empty() {
292            report.push_str("\nError counts by severity:\n");
293            for (severity, count) in &self.summary.error_counts {
294                if *count > 0 {
295                    let _ = writeln!(report, "  {severity:?}: {count}");
296                }
297            }
298        }
299
300        if !self.summary.error_codes.is_empty() {
301            report.push_str("\nError counts by code:\n");
302            for (code, count) in &self.summary.error_codes {
303                if *count > 0 {
304                    let _ = writeln!(report, "  {code}: {count}");
305                }
306            }
307        }
308
309        if self.summary.corruption_warnings > 0 {
310            report.push('\n');
311            let _ = writeln!(
312                report,
313                "Transfer corruption warnings: {}",
314                self.summary.corruption_warnings
315            );
316        }
317
318        if let Some(ref first_error) = self.summary.first_error {
319            report.push('\n');
320            let _ = writeln!(report, "First error: {}", first_error.error);
321        }
322
323        report
324    }
325
326    /// Determine error severity based on error code
327    #[inline]
328    fn determine_severity(&self, error: &Error) -> ErrorSeverity {
329        match error.code {
330            // Parse errors are typically fatal
331            ErrorCode::CBKP001_SYNTAX
332            | ErrorCode::CBKP011_UNSUPPORTED_CLAUSE
333            | ErrorCode::CBKP021_ODO_NOT_TAIL
334            | ErrorCode::CBKP022_NESTED_ODO
335            | ErrorCode::CBKP023_ODO_REDEFINES
336            | ErrorCode::CBKP051_UNSUPPORTED_EDITED_PIC
337            | ErrorCode::CBKP101_INVALID_PIC
338            // Schema errors are fatal
339            | ErrorCode::CBKS121_COUNTER_NOT_FOUND
340            | ErrorCode::CBKS141_RECORD_TOO_LARGE
341            | ErrorCode::CBKS601_RENAME_UNKNOWN_FROM
342            | ErrorCode::CBKS602_RENAME_UNKNOWN_THRU
343            | ErrorCode::CBKS603_RENAME_NOT_CONTIGUOUS
344            | ErrorCode::CBKS604_RENAME_REVERSED_RANGE
345            | ErrorCode::CBKS605_RENAME_FROM_CROSSES_GROUP
346            | ErrorCode::CBKS606_RENAME_THRU_CROSSES_GROUP
347            | ErrorCode::CBKS607_RENAME_CROSSES_OCCURS
348            | ErrorCode::CBKS608_RENAME_QUALIFIED_NAME_NOT_FOUND
349            | ErrorCode::CBKS609_RENAME_OVER_REDEFINES
350            | ErrorCode::CBKS610_RENAME_MULTIPLE_REDEFINES
351            | ErrorCode::CBKS611_RENAME_PARTIAL_OCCURS
352            | ErrorCode::CBKS612_RENAME_ODO_NOT_SUPPORTED
353            | ErrorCode::CBKS701_PROJECTION_INVALID_ODO
354            | ErrorCode::CBKS702_PROJECTION_UNRESOLVED_ALIAS
355            | ErrorCode::CBKS703_PROJECTION_FIELD_NOT_FOUND
356            | ErrorCode::CBKE505_SCALE_MISMATCH
357            | ErrorCode::CBKE510_NUMERIC_OVERFLOW
358            | ErrorCode::CBKE515_STRING_LENGTH_VIOLATION
359            // JSON write errors are typically fatal
360            | ErrorCode::CBKC201_JSON_WRITE_ERROR
361            // Iterator/internal state errors are fatal
362            | ErrorCode::CBKI001_INVALID_STATE => ErrorSeverity::Fatal,
363
364            // ODO clipping is a warning in lenient mode, error in strict mode
365            ErrorCode::CBKS301_ODO_CLIPPED
366            | ErrorCode::CBKS302_ODO_RAISED
367            // Record format warnings
368            | ErrorCode::CBKR211_RDW_RESERVED_NONZERO => {
369                if self.mode == ErrorMode::Strict {
370                    ErrorSeverity::Fatal
371                } else {
372                    ErrorSeverity::Warning
373                }
374            }
375
376            // Character conversion warnings, BLANK WHEN ZERO, transfer corruption
377            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE
378            | ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO
379            | ErrorCode::CBKD423_EDITED_PIC_BLANK_WHEN_ZERO
380            | ErrorCode::CBKF104_RDW_SUSPECT_ASCII => ErrorSeverity::Warning,
381
382            // Data decode errors, encode errors, format errors, audit, arrow/writer
383            ErrorCode::CBKD101_INVALID_FIELD_TYPE
384            | ErrorCode::CBKD301_RECORD_TOO_SHORT
385            | ErrorCode::CBKD302_EDITED_PIC_NOT_IMPLEMENTED
386            | ErrorCode::CBKD401_COMP3_INVALID_NIBBLE
387            | ErrorCode::CBKD410_ZONED_OVERFLOW
388            | ErrorCode::CBKD411_ZONED_BAD_SIGN
389            | ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT
390            | ErrorCode::CBKD422_EDITED_PIC_SIGN_MISMATCH
391            | ErrorCode::CBKD431_FLOAT_NAN
392            | ErrorCode::CBKD432_FLOAT_INFINITY
393            | ErrorCode::CBKD413_ZONED_INVALID_ENCODING
394            | ErrorCode::CBKD414_ZONED_MIXED_ENCODING
395            | ErrorCode::CBKD415_ZONED_ENCODING_AMBIGUOUS
396            | ErrorCode::CBKE501_JSON_TYPE_MISMATCH
397            | ErrorCode::CBKE521_ARRAY_LEN_OOB
398            | ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR
399            | ErrorCode::CBKE531_FLOAT_ENCODE_OVERFLOW
400            | ErrorCode::CBKF102_RECORD_LENGTH_INVALID
401            | ErrorCode::CBKF221_RDW_UNDERFLOW
402            | ErrorCode::CBKA001_BASELINE_ERROR
403            | ErrorCode::CBKW001_SCHEMA_CONVERSION
404            | ErrorCode::CBKW002_TYPE_MAPPING
405            | ErrorCode::CBKW003_DECIMAL_OVERFLOW
406            | ErrorCode::CBKW004_BATCH_BUILD
407            | ErrorCode::CBKW005_PARQUET_WRITE => ErrorSeverity::Error,
408        }
409    }
410
411    /// Update error statistics
412    #[inline]
413    fn update_statistics(&mut self, report: &ErrorReport) {
414        // Update severity counts
415        *self
416            .summary
417            .error_counts
418            .entry(report.severity)
419            .or_insert(0) += 1;
420
421        // Update error code counts
422        *self
423            .summary
424            .error_codes
425            .entry(report.error.code)
426            .or_insert(0) += 1;
427
428        // Track records with errors
429        if matches!(report.severity, ErrorSeverity::Error | ErrorSeverity::Fatal)
430            && let Some(ref context) = report.error.context
431            && let Some(record_index) = context.record_index
432        {
433            // Only count each record once
434            if self.summary.records_with_errors < record_index {
435                self.summary.records_with_errors = record_index;
436            }
437        }
438
439        // Track first and last errors
440        if self.summary.first_error.is_none() {
441            self.summary.first_error = Some(report.clone());
442        }
443        self.summary.last_error = Some(report.clone());
444    }
445
446    /// Log error with appropriate level and detail
447    #[inline]
448    fn log_error(&self, report: &ErrorReport) {
449        let error_msg = if self.verbose_logging {
450            format!("{}", report.error)
451        } else {
452            format!("{}: {}", report.error.code, report.error.message)
453        };
454
455        match report.severity {
456            ErrorSeverity::Fatal | ErrorSeverity::Error => error!("{error_msg}"),
457            ErrorSeverity::Warning => warn!("{error_msg}"),
458            ErrorSeverity::Info => debug!("{error_msg}"),
459        }
460
461        // Log additional context if available and verbose
462        if self.verbose_logging
463            && let Some(ref context) = report.error.context
464            && (context.record_index.is_some()
465                || context.field_path.is_some()
466                || context.byte_offset.is_some())
467        {
468            debug!("  Context: {context}");
469        }
470    }
471}
472
473/// Check if error indicates transfer corruption
474#[inline]
475fn is_corruption_warning(error: &Error) -> bool {
476    matches!(
477        error.code,
478        ErrorCode::CBKF104_RDW_SUSPECT_ASCII | ErrorCode::CBKC301_INVALID_EBCDIC_BYTE
479    )
480}
481
482impl fmt::Display for ErrorSeverity {
483    #[inline]
484    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
485        match self {
486            ErrorSeverity::Info => write!(f, "INFO"),
487            ErrorSeverity::Warning => write!(f, "WARN"),
488            ErrorSeverity::Error => write!(f, "ERROR"),
489            ErrorSeverity::Fatal => write!(f, "FATAL"),
490        }
491    }
492}
493
494impl ErrorSummary {
495    /// Check if processing had any errors
496    #[must_use]
497    #[inline]
498    pub fn has_errors(&self) -> bool {
499        self.error_counts.get(&ErrorSeverity::Error).unwrap_or(&0) > &0
500            || self.error_counts.get(&ErrorSeverity::Fatal).unwrap_or(&0) > &0
501    }
502
503    /// Check if processing had any warnings
504    #[must_use]
505    #[inline]
506    pub fn has_warnings(&self) -> bool {
507        self.error_counts.get(&ErrorSeverity::Warning).unwrap_or(&0) > &0
508    }
509
510    /// Get total error count (excluding warnings)
511    #[must_use]
512    #[inline]
513    pub fn error_count(&self) -> u64 {
514        self.error_counts.get(&ErrorSeverity::Error).unwrap_or(&0)
515            + self.error_counts.get(&ErrorSeverity::Fatal).unwrap_or(&0)
516    }
517
518    /// Get total warning count
519    #[must_use]
520    #[inline]
521    pub fn warning_count(&self) -> u64 {
522        *self.error_counts.get(&ErrorSeverity::Warning).unwrap_or(&0)
523    }
524}
525
526#[cfg(test)]
527#[allow(clippy::expect_used)]
528#[allow(clippy::unwrap_used)]
529mod tests {
530    use super::*;
531    use copybook_error::ErrorCode;
532
533    #[test]
534    fn test_error_reporter_strict_mode() {
535        let mut reporter = ErrorReporter::new(ErrorMode::Strict, None);
536
537        let error = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "Invalid nibble")
538            .with_record(1)
539            .with_field("CUSTOMER.ID")
540            .with_offset(42);
541
542        // In strict mode, errors should stop processing
543        let result = reporter.report_error(error);
544        assert!(result.is_err());
545        assert!(reporter.has_errors());
546        assert_eq!(reporter.error_count(), 1);
547    }
548
549    #[test]
550    fn test_error_reporter_lenient_mode() {
551        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
552
553        let error = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "Invalid nibble")
554            .with_record(1)
555            .with_field("CUSTOMER.ID")
556            .with_offset(42);
557
558        // In lenient mode, errors should allow continuation
559        let result = reporter.report_error(error);
560        assert!(result.is_ok());
561        assert!(reporter.has_errors());
562        assert_eq!(reporter.error_count(), 1);
563    }
564
565    #[test]
566    fn test_max_errors_limit() {
567        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, Some(1));
568
569        // First error should be OK
570        let error1 = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "Error 1");
571        assert!(reporter.report_error(error1).is_ok());
572
573        // Second error should stop processing (we've reached the limit)
574        let error2 = Error::new(ErrorCode::CBKD411_ZONED_BAD_SIGN, "Error 2");
575        assert!(reporter.report_error(error2).is_err());
576    }
577
578    #[test]
579    fn test_warning_reporting() {
580        let mut reporter = ErrorReporter::new(ErrorMode::Strict, None);
581
582        let warning = Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "Blank field");
583        reporter.report_warning(warning);
584
585        assert!(!reporter.has_errors());
586        assert!(reporter.has_warnings());
587        assert_eq!(reporter.warning_count(), 1);
588    }
589
590    #[test]
591    fn test_corruption_detection() {
592        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
593
594        let corruption_error = Error::new(
595            ErrorCode::CBKF104_RDW_SUSPECT_ASCII,
596            "ASCII corruption detected",
597        );
598        reporter.report_warning(corruption_error);
599
600        assert_eq!(reporter.summary().corruption_warnings, 1);
601    }
602
603    #[test]
604    fn test_error_summary_generation() {
605        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
606
607        reporter.start_record(1);
608        let error =
609            Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "Test error").with_record(1);
610        let _ = reporter.report_error(error);
611
612        let report = reporter.generate_report();
613        assert!(report.contains("Total records processed: 1"));
614        assert!(report.contains("CBKD401_COMP3_INVALID_NIBBLE: 1"));
615    }
616
617    // --- New tests below ---
618
619    #[test]
620    fn test_empty_reporter_no_errors_no_warnings() {
621        let reporter = ErrorReporter::new(ErrorMode::Lenient, None);
622
623        assert!(!reporter.has_errors());
624        assert!(!reporter.has_warnings());
625        assert_eq!(reporter.error_count(), 0);
626        assert_eq!(reporter.warning_count(), 0);
627
628        let summary = reporter.summary();
629        assert!(!summary.has_errors());
630        assert!(!summary.has_warnings());
631        assert_eq!(summary.error_count(), 0);
632        assert_eq!(summary.warning_count(), 0);
633        assert!(summary.first_error.is_none());
634        assert!(summary.last_error.is_none());
635        assert_eq!(summary.corruption_warnings, 0);
636        assert_eq!(summary.total_records, 0);
637        assert_eq!(summary.records_with_errors, 0);
638    }
639
640    #[test]
641    fn test_empty_reporter_report_output() {
642        let reporter = ErrorReporter::new(ErrorMode::Strict, None);
643        let report = reporter.generate_report();
644
645        assert!(report.contains("=== Error Summary ==="));
646        assert!(report.contains("Total records processed: 0"));
647        assert!(report.contains("Records with errors: 0"));
648        // No error counts section when empty
649        assert!(!report.contains("Error counts by severity:"));
650        assert!(!report.contains("Error counts by code:"));
651    }
652
653    #[test]
654    fn test_fatal_error_stops_in_lenient_mode() {
655        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
656
657        // Fatal errors stop processing regardless of mode
658        let error = Error::new(ErrorCode::CBKP001_SYNTAX, "Unexpected token");
659        let result = reporter.report_error(error);
660        assert!(result.is_err());
661        assert!(reporter.has_errors());
662    }
663
664    #[test]
665    fn test_multiple_errors_summary_counts() {
666        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
667
668        let errors = [
669            Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "nibble 1"),
670            Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "nibble 2"),
671            Error::new(ErrorCode::CBKD411_ZONED_BAD_SIGN, "bad sign"),
672        ];
673        for e in errors {
674            let _ = reporter.report_error(e);
675        }
676
677        assert_eq!(reporter.error_count(), 3);
678        let summary = reporter.summary();
679        assert_eq!(
680            *summary
681                .error_codes
682                .get(&ErrorCode::CBKD401_COMP3_INVALID_NIBBLE)
683                .unwrap(),
684            2
685        );
686        assert_eq!(
687            *summary
688                .error_codes
689                .get(&ErrorCode::CBKD411_ZONED_BAD_SIGN)
690                .unwrap(),
691            1
692        );
693    }
694
695    #[test]
696    fn test_first_and_last_error_tracked() {
697        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
698
699        let _ = reporter.report_error(Error::new(
700            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
701            "first error",
702        ));
703        let _ = reporter.report_error(Error::new(
704            ErrorCode::CBKD411_ZONED_BAD_SIGN,
705            "second error",
706        ));
707        let _ = reporter.report_error(Error::new(ErrorCode::CBKD410_ZONED_OVERFLOW, "third error"));
708
709        let summary = reporter.summary();
710        let first = summary.first_error.as_ref().unwrap();
711        let last = summary.last_error.as_ref().unwrap();
712        assert_eq!(first.error.code, ErrorCode::CBKD401_COMP3_INVALID_NIBBLE);
713        assert_eq!(first.error.message, "first error");
714        assert_eq!(last.error.code, ErrorCode::CBKD410_ZONED_OVERFLOW);
715        assert_eq!(last.error.message, "third error");
716    }
717
718    #[test]
719    fn test_max_errors_boundary_exact_limit() {
720        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, Some(3));
721
722        // Errors 1-3 should be OK (under limit)
723        for i in 1..=3 {
724            let e = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, format!("err {i}"));
725            assert!(reporter.report_error(e).is_ok(), "error {i} should succeed");
726        }
727
728        // Error 4 should fail (limit reached)
729        let e4 = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "err 4");
730        assert!(reporter.report_error(e4).is_err());
731        assert_eq!(reporter.error_count(), 4);
732    }
733
734    #[test]
735    fn test_max_errors_unlimited_in_lenient() {
736        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
737
738        for i in 0..100 {
739            let e = Error::new(
740                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
741                format!("error {i}"),
742            );
743            assert!(reporter.report_error(e).is_ok());
744        }
745        assert_eq!(reporter.error_count(), 100);
746    }
747
748    #[test]
749    fn test_severity_display_formatting() {
750        assert_eq!(format!("{}", ErrorSeverity::Info), "INFO");
751        assert_eq!(format!("{}", ErrorSeverity::Warning), "WARN");
752        assert_eq!(format!("{}", ErrorSeverity::Error), "ERROR");
753        assert_eq!(format!("{}", ErrorSeverity::Fatal), "FATAL");
754    }
755
756    #[test]
757    fn test_severity_ordering() {
758        assert!(ErrorSeverity::Info < ErrorSeverity::Warning);
759        assert!(ErrorSeverity::Warning < ErrorSeverity::Error);
760        assert!(ErrorSeverity::Error < ErrorSeverity::Fatal);
761    }
762
763    #[test]
764    fn test_odo_clipped_severity_depends_on_mode() {
765        // In lenient mode: warning (continues)
766        let mut lenient = ErrorReporter::new(ErrorMode::Lenient, None);
767        let e = Error::new(ErrorCode::CBKS301_ODO_CLIPPED, "ODO clipped");
768        assert!(lenient.report_error(e).is_ok());
769        assert!(!lenient.has_errors());
770        assert!(lenient.has_warnings());
771
772        // In strict mode: fatal (stops)
773        let mut strict = ErrorReporter::new(ErrorMode::Strict, None);
774        let e = Error::new(ErrorCode::CBKS301_ODO_CLIPPED, "ODO clipped");
775        assert!(strict.report_error(e).is_err());
776    }
777
778    #[test]
779    fn test_mixed_errors_and_warnings_report() {
780        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
781
782        reporter.report_warning(Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blank"));
783        let _ = reporter.report_error(Error::new(
784            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
785            "bad nibble",
786        ));
787        reporter.report_warning(Error::new(
788            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE,
789            "bad byte",
790        ));
791
792        let report = reporter.generate_report();
793        assert!(report.contains("Error counts by severity:"));
794        assert!(report.contains("Error counts by code:"));
795        assert_eq!(reporter.error_count(), 1);
796        assert_eq!(reporter.warning_count(), 2);
797    }
798
799    #[test]
800    fn test_corruption_detection_ebcdic_byte() {
801        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
802
803        reporter.report_warning(Error::new(
804            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE,
805            "Invalid EBCDIC byte 0xFF",
806        ));
807
808        assert_eq!(reporter.summary().corruption_warnings, 1);
809        assert!(reporter.has_warnings());
810        assert!(!reporter.has_errors());
811    }
812
813    #[test]
814    fn test_multiple_corruption_warnings_counted() {
815        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
816
817        for _ in 0..5 {
818            reporter.report_warning(Error::new(
819                ErrorCode::CBKF104_RDW_SUSPECT_ASCII,
820                "ASCII suspected",
821            ));
822        }
823        reporter.report_warning(Error::new(
824            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE,
825            "Bad EBCDIC",
826        ));
827
828        assert_eq!(reporter.summary().corruption_warnings, 6);
829        assert_eq!(reporter.warning_count(), 6);
830    }
831
832    #[test]
833    fn test_verbose_logging_toggle() {
834        let reporter = ErrorReporter::new(ErrorMode::Lenient, None).with_verbose_logging(false);
835        // Verify the builder pattern works and reporter is usable
836        assert!(!reporter.has_errors());
837        assert_eq!(reporter.error_count(), 0);
838    }
839
840    #[test]
841    fn test_very_long_error_message() {
842        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
843        let long_msg = "x".repeat(10_000);
844        let e = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, long_msg.clone());
845        let _ = reporter.report_error(e);
846
847        let first = reporter.summary().first_error.as_ref().unwrap();
848        assert_eq!(first.error.message.len(), 10_000);
849
850        let report = reporter.generate_report();
851        assert!(report.contains("First error:"));
852    }
853
854    #[test]
855    fn test_start_record_tracks_total_records() {
856        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
857
858        reporter.start_record(0);
859        assert_eq!(reporter.summary().total_records, 0);
860
861        reporter.start_record(42);
862        assert_eq!(reporter.summary().total_records, 42);
863
864        reporter.start_record(100);
865        assert_eq!(reporter.summary().total_records, 100);
866
867        let report = reporter.generate_report();
868        assert!(report.contains("Total records processed: 100"));
869    }
870
871    #[test]
872    fn test_error_report_contains_first_error_in_output() {
873        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
874
875        let _ = reporter.report_error(Error::new(
876            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
877            "the very first problem",
878        ));
879        let _ = reporter.report_error(Error::new(
880            ErrorCode::CBKD411_ZONED_BAD_SIGN,
881            "second problem",
882        ));
883
884        let report = reporter.generate_report();
885        assert!(report.contains("First error:"));
886        assert!(report.contains("the very first problem"));
887        // Last error is not shown in generate_report output
888    }
889
890    #[test]
891    fn test_error_summary_default_is_clean() {
892        let summary = ErrorSummary::default();
893        assert!(!summary.has_errors());
894        assert!(!summary.has_warnings());
895        assert_eq!(summary.error_count(), 0);
896        assert_eq!(summary.warning_count(), 0);
897        assert!(summary.first_error.is_none());
898        assert!(summary.last_error.is_none());
899        assert_eq!(summary.records_with_errors, 0);
900        assert_eq!(summary.total_records, 0);
901        assert_eq!(summary.corruption_warnings, 0);
902    }
903
904    // -----------------------------------------------------------------------
905    // Severity classification tests
906    // -----------------------------------------------------------------------
907
908    #[test]
909    fn test_severity_parse_errors_are_fatal() {
910        let parse_codes = [
911            ErrorCode::CBKP001_SYNTAX,
912            ErrorCode::CBKP011_UNSUPPORTED_CLAUSE,
913            ErrorCode::CBKP021_ODO_NOT_TAIL,
914            ErrorCode::CBKP022_NESTED_ODO,
915            ErrorCode::CBKP023_ODO_REDEFINES,
916            ErrorCode::CBKP051_UNSUPPORTED_EDITED_PIC,
917            ErrorCode::CBKP101_INVALID_PIC,
918        ];
919        for code in parse_codes {
920            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
921            let err = Error::new(code, "parse error");
922            let result = reporter.report_error(err);
923            assert!(
924                result.is_err(),
925                "CBKP code {code} should be fatal even in lenient mode"
926            );
927        }
928    }
929
930    #[test]
931    fn test_severity_schema_errors_are_fatal() {
932        let schema_codes = [
933            ErrorCode::CBKS121_COUNTER_NOT_FOUND,
934            ErrorCode::CBKS141_RECORD_TOO_LARGE,
935            ErrorCode::CBKS601_RENAME_UNKNOWN_FROM,
936            ErrorCode::CBKS701_PROJECTION_INVALID_ODO,
937            ErrorCode::CBKS703_PROJECTION_FIELD_NOT_FOUND,
938        ];
939        for code in schema_codes {
940            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
941            let err = Error::new(code, "schema error");
942            let result = reporter.report_error(err);
943            assert!(result.is_err(), "CBKS code {code} should be fatal");
944        }
945    }
946
947    #[test]
948    fn test_severity_data_errors_are_error_level() {
949        let data_codes = [
950            ErrorCode::CBKD101_INVALID_FIELD_TYPE,
951            ErrorCode::CBKD301_RECORD_TOO_SHORT,
952            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
953            ErrorCode::CBKD410_ZONED_OVERFLOW,
954            ErrorCode::CBKD411_ZONED_BAD_SIGN,
955            ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
956            ErrorCode::CBKD431_FLOAT_NAN,
957            ErrorCode::CBKD432_FLOAT_INFINITY,
958        ];
959        for code in data_codes {
960            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
961            let err = Error::new(code, "data error");
962            let result = reporter.report_error(err);
963            assert!(
964                result.is_ok(),
965                "CBKD code {code} should continue in lenient mode"
966            );
967            assert!(reporter.has_errors());
968        }
969    }
970
971    #[test]
972    fn test_severity_warning_codes_are_warnings() {
973        let warning_codes = [
974            ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO,
975            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE,
976            ErrorCode::CBKD423_EDITED_PIC_BLANK_WHEN_ZERO,
977            ErrorCode::CBKF104_RDW_SUSPECT_ASCII,
978        ];
979        for code in warning_codes {
980            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
981            let err = Error::new(code, "warning");
982            let result = reporter.report_error(err);
983            assert!(result.is_ok(), "code {code} should continue");
984            assert!(
985                !reporter.has_errors(),
986                "code {code} should be warning not error"
987            );
988            assert!(reporter.has_warnings(), "code {code} should be a warning");
989        }
990    }
991
992    #[test]
993    fn test_severity_odo_clipped_warning_in_lenient_fatal_in_strict() {
994        for code in [
995            ErrorCode::CBKS301_ODO_CLIPPED,
996            ErrorCode::CBKS302_ODO_RAISED,
997        ] {
998            // Lenient: warning
999            let mut lenient = ErrorReporter::new(ErrorMode::Lenient, None);
1000            assert!(lenient.report_error(Error::new(code, "odo")).is_ok());
1001            assert!(lenient.has_warnings());
1002            assert!(!lenient.has_errors());
1003
1004            // Strict: fatal
1005            let mut strict = ErrorReporter::new(ErrorMode::Strict, None);
1006            assert!(strict.report_error(Error::new(code, "odo")).is_err());
1007        }
1008    }
1009
1010    #[test]
1011    fn test_severity_encode_errors_are_error_level_in_lenient() {
1012        let encode_codes = [
1013            ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
1014            ErrorCode::CBKE521_ARRAY_LEN_OOB,
1015            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1016            ErrorCode::CBKE531_FLOAT_ENCODE_OVERFLOW,
1017        ];
1018        for code in encode_codes {
1019            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1020            let result = reporter.report_error(Error::new(code, "encode"));
1021            assert!(result.is_ok(), "{code} should continue in lenient");
1022            assert!(reporter.has_errors());
1023        }
1024    }
1025
1026    #[test]
1027    fn test_severity_infrastructure_error_is_fatal() {
1028        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1029        let err = Error::new(ErrorCode::CBKI001_INVALID_STATE, "bad state");
1030        assert!(reporter.report_error(err).is_err());
1031    }
1032
1033    // -----------------------------------------------------------------------
1034    // Record tracking tests
1035    // -----------------------------------------------------------------------
1036
1037    #[test]
1038    fn test_records_with_errors_tracking() {
1039        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1040
1041        // Report errors on different records
1042        let _ = reporter.report_error(
1043            Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "bad").with_record(5),
1044        );
1045        let _ = reporter
1046            .report_error(Error::new(ErrorCode::CBKD411_ZONED_BAD_SIGN, "bad").with_record(10));
1047
1048        assert_eq!(reporter.summary().records_with_errors, 10);
1049    }
1050
1051    #[test]
1052    fn test_records_with_errors_not_incremented_for_warnings() {
1053        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1054        reporter.report_warning(
1055            Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blank").with_record(5),
1056        );
1057        assert_eq!(reporter.summary().records_with_errors, 0);
1058    }
1059
1060    // -----------------------------------------------------------------------
1061    // ErrorReport metadata tests
1062    // -----------------------------------------------------------------------
1063
1064    #[test]
1065    fn test_corruption_warning_adds_metadata() {
1066        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1067        reporter.report_warning(Error::new(
1068            ErrorCode::CBKF104_RDW_SUSPECT_ASCII,
1069            "ASCII transfer",
1070        ));
1071        assert_eq!(reporter.summary().corruption_warnings, 1);
1072    }
1073
1074    #[test]
1075    fn test_non_corruption_warning_no_corruption_count() {
1076        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1077        reporter.report_warning(Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blanks"));
1078        assert_eq!(reporter.summary().corruption_warnings, 0);
1079    }
1080
1081    // -----------------------------------------------------------------------
1082    // ErrorMode and ErrorSeverity tests
1083    // -----------------------------------------------------------------------
1084
1085    #[test]
1086    fn test_error_mode_equality() {
1087        assert_eq!(ErrorMode::Strict, ErrorMode::Strict);
1088        assert_eq!(ErrorMode::Lenient, ErrorMode::Lenient);
1089        assert_ne!(ErrorMode::Strict, ErrorMode::Lenient);
1090    }
1091
1092    #[test]
1093    fn test_error_severity_equality_and_hash() {
1094        use std::collections::HashSet;
1095        let mut set = HashSet::new();
1096        set.insert(ErrorSeverity::Info);
1097        set.insert(ErrorSeverity::Warning);
1098        set.insert(ErrorSeverity::Error);
1099        set.insert(ErrorSeverity::Fatal);
1100        set.insert(ErrorSeverity::Info); // duplicate
1101        assert_eq!(set.len(), 4);
1102    }
1103
1104    // -----------------------------------------------------------------------
1105    // Report generation tests
1106    // -----------------------------------------------------------------------
1107
1108    #[test]
1109    fn test_generate_report_includes_corruption_section() {
1110        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1111        reporter.report_warning(Error::new(ErrorCode::CBKF104_RDW_SUSPECT_ASCII, "ASCII"));
1112        reporter.report_warning(Error::new(ErrorCode::CBKC301_INVALID_EBCDIC_BYTE, "EBCDIC"));
1113
1114        let report = reporter.generate_report();
1115        assert!(report.contains("Transfer corruption warnings: 2"));
1116    }
1117
1118    #[test]
1119    fn test_generate_report_no_corruption_section_when_zero() {
1120        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1121        reporter.report_warning(Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blank"));
1122
1123        let report = reporter.generate_report();
1124        assert!(!report.contains("Transfer corruption warnings"));
1125    }
1126
1127    #[test]
1128    fn test_error_summary_has_errors_with_fatal_only() {
1129        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1130        // Parse errors are Fatal severity
1131        let _ = reporter.report_error(Error::new(ErrorCode::CBKP001_SYNTAX, "syntax"));
1132        assert!(reporter.summary().has_errors());
1133        assert_eq!(reporter.summary().error_count(), 1);
1134    }
1135
1136    // -----------------------------------------------------------------------
1137    // ErrorCode JSON serialization tests (through reporter context)
1138    // -----------------------------------------------------------------------
1139
1140    #[test]
1141    fn test_error_code_json_roundtrip_in_summary() {
1142        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1143        let _ = reporter.report_error(Error::new(
1144            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1145            "nibble",
1146        ));
1147
1148        // Serialize error code from summary
1149        for (code, count) in &reporter.summary().error_codes {
1150            let json = serde_json::to_string(code).unwrap();
1151            let rt: ErrorCode = serde_json::from_str(&json).unwrap();
1152            assert_eq!(*code, rt);
1153            assert_eq!(*count, 1);
1154        }
1155    }
1156
1157    #[test]
1158    fn test_error_code_json_serialization_format() {
1159        // Verify codes serialize as quoted variant names (stable API contract)
1160        let test_cases = [
1161            (ErrorCode::CBKP001_SYNTAX, r#""CBKP001_SYNTAX""#),
1162            (
1163                ErrorCode::CBKS121_COUNTER_NOT_FOUND,
1164                r#""CBKS121_COUNTER_NOT_FOUND""#,
1165            ),
1166            (
1167                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1168                r#""CBKD401_COMP3_INVALID_NIBBLE""#,
1169            ),
1170            (
1171                ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
1172                r#""CBKE501_JSON_TYPE_MISMATCH""#,
1173            ),
1174            (
1175                ErrorCode::CBKR211_RDW_RESERVED_NONZERO,
1176                r#""CBKR211_RDW_RESERVED_NONZERO""#,
1177            ),
1178            (
1179                ErrorCode::CBKF102_RECORD_LENGTH_INVALID,
1180                r#""CBKF102_RECORD_LENGTH_INVALID""#,
1181            ),
1182        ];
1183        for (code, expected_json) in test_cases {
1184            let json = serde_json::to_string(&code).unwrap();
1185            assert_eq!(json, expected_json, "serialization mismatch for {code}");
1186        }
1187    }
1188
1189    // -----------------------------------------------------------------------
1190    // Max errors edge cases
1191    // -----------------------------------------------------------------------
1192
1193    #[test]
1194    fn test_max_errors_zero_stops_immediately() {
1195        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, Some(0));
1196        let err = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "first");
1197        // max_errors=0 means 0 < 0 is false, so first error should fail
1198        assert!(reporter.report_error(err).is_err());
1199    }
1200
1201    #[test]
1202    fn test_strict_mode_stops_on_every_data_error() {
1203        let data_codes = [
1204            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1205            ErrorCode::CBKD411_ZONED_BAD_SIGN,
1206            ErrorCode::CBKD410_ZONED_OVERFLOW,
1207            ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
1208        ];
1209        for code in data_codes {
1210            let mut reporter = ErrorReporter::new(ErrorMode::Strict, None);
1211            let result = reporter.report_error(Error::new(code, "data error"));
1212            assert!(result.is_err(), "strict mode should stop on {code}");
1213        }
1214    }
1215
1216    #[test]
1217    fn test_warning_does_not_count_as_error() {
1218        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, Some(1));
1219        // Report many warnings - they shouldn't count against max_errors
1220        for _ in 0..10 {
1221            reporter.report_warning(Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blank"));
1222        }
1223        assert_eq!(reporter.error_count(), 0);
1224        assert_eq!(reporter.warning_count(), 10);
1225        // Next real error should still succeed (first error under limit)
1226        let err = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "first real error");
1227        assert!(reporter.report_error(err).is_ok());
1228    }
1229}