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            // Record framing/read errors are fatal (unrecoverable I/O)
362            | ErrorCode::CBKR101_FIXED_RECORD_ERROR
363            | ErrorCode::CBKR201_RDW_READ_ERROR
364            // Iterator/internal state errors are fatal
365            | ErrorCode::CBKI001_INVALID_STATE => ErrorSeverity::Fatal,
366
367            // ODO clipping is a warning in lenient mode, error in strict mode
368            ErrorCode::CBKS301_ODO_CLIPPED
369            | ErrorCode::CBKS302_ODO_RAISED
370            // Record format warnings
371            | ErrorCode::CBKR211_RDW_RESERVED_NONZERO => {
372                if self.mode == ErrorMode::Strict {
373                    ErrorSeverity::Fatal
374                } else {
375                    ErrorSeverity::Warning
376                }
377            }
378
379            // Character conversion warnings, BLANK WHEN ZERO, transfer corruption
380            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE
381            | ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO
382            | ErrorCode::CBKD423_EDITED_PIC_BLANK_WHEN_ZERO
383            | ErrorCode::CBKF104_RDW_SUSPECT_ASCII => ErrorSeverity::Warning,
384
385            // Data decode errors, encode errors, format errors, audit, arrow/writer
386            ErrorCode::CBKD101_INVALID_FIELD_TYPE
387            | ErrorCode::CBKD301_RECORD_TOO_SHORT
388            | ErrorCode::CBKD302_EDITED_PIC_NOT_IMPLEMENTED
389            | ErrorCode::CBKD401_COMP3_INVALID_NIBBLE
390            | ErrorCode::CBKD410_ZONED_OVERFLOW
391            | ErrorCode::CBKD411_ZONED_BAD_SIGN
392            | ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT
393            | ErrorCode::CBKD422_EDITED_PIC_SIGN_MISMATCH
394            | ErrorCode::CBKD431_FLOAT_NAN
395            | ErrorCode::CBKD432_FLOAT_INFINITY
396            | ErrorCode::CBKD413_ZONED_INVALID_ENCODING
397            | ErrorCode::CBKD414_ZONED_MIXED_ENCODING
398            | ErrorCode::CBKD415_ZONED_ENCODING_AMBIGUOUS
399            | ErrorCode::CBKE501_JSON_TYPE_MISMATCH
400            | ErrorCode::CBKE521_ARRAY_LEN_OOB
401            | ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR
402            | ErrorCode::CBKE531_FLOAT_ENCODE_OVERFLOW
403            | ErrorCode::CBKF102_RECORD_LENGTH_INVALID
404            | ErrorCode::CBKF221_RDW_UNDERFLOW
405            | ErrorCode::CBKA001_BASELINE_ERROR
406            | ErrorCode::CBKW001_SCHEMA_CONVERSION
407            | ErrorCode::CBKW002_TYPE_MAPPING
408            | ErrorCode::CBKW003_DECIMAL_OVERFLOW
409            | ErrorCode::CBKW004_BATCH_BUILD
410            | ErrorCode::CBKW005_PARQUET_WRITE => ErrorSeverity::Error,
411        }
412    }
413
414    /// Update error statistics
415    #[inline]
416    fn update_statistics(&mut self, report: &ErrorReport) {
417        // Update severity counts
418        *self
419            .summary
420            .error_counts
421            .entry(report.severity)
422            .or_insert(0) += 1;
423
424        // Update error code counts
425        *self
426            .summary
427            .error_codes
428            .entry(report.error.code)
429            .or_insert(0) += 1;
430
431        // Track records with errors
432        if matches!(report.severity, ErrorSeverity::Error | ErrorSeverity::Fatal)
433            && let Some(ref context) = report.error.context
434            && let Some(record_index) = context.record_index
435        {
436            // Only count each record once
437            if self.summary.records_with_errors < record_index {
438                self.summary.records_with_errors = record_index;
439            }
440        }
441
442        // Track first and last errors
443        if self.summary.first_error.is_none() {
444            self.summary.first_error = Some(report.clone());
445        }
446        self.summary.last_error = Some(report.clone());
447    }
448
449    /// Log error with appropriate level and detail
450    #[inline]
451    fn log_error(&self, report: &ErrorReport) {
452        let error_msg = if self.verbose_logging {
453            format!("{}", report.error)
454        } else {
455            format!("{}: {}", report.error.code, report.error.message)
456        };
457
458        match report.severity {
459            ErrorSeverity::Fatal | ErrorSeverity::Error => error!("{error_msg}"),
460            ErrorSeverity::Warning => warn!("{error_msg}"),
461            ErrorSeverity::Info => debug!("{error_msg}"),
462        }
463
464        // Log additional context if available and verbose
465        if self.verbose_logging
466            && let Some(ref context) = report.error.context
467            && (context.record_index.is_some()
468                || context.field_path.is_some()
469                || context.byte_offset.is_some())
470        {
471            debug!("  Context: {context}");
472        }
473    }
474}
475
476/// Check if error indicates transfer corruption
477#[inline]
478fn is_corruption_warning(error: &Error) -> bool {
479    matches!(
480        error.code,
481        ErrorCode::CBKF104_RDW_SUSPECT_ASCII | ErrorCode::CBKC301_INVALID_EBCDIC_BYTE
482    )
483}
484
485impl fmt::Display for ErrorSeverity {
486    #[inline]
487    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488        match self {
489            ErrorSeverity::Info => write!(f, "INFO"),
490            ErrorSeverity::Warning => write!(f, "WARN"),
491            ErrorSeverity::Error => write!(f, "ERROR"),
492            ErrorSeverity::Fatal => write!(f, "FATAL"),
493        }
494    }
495}
496
497impl ErrorSummary {
498    /// Check if processing had any errors
499    #[must_use]
500    #[inline]
501    pub fn has_errors(&self) -> bool {
502        self.error_counts.get(&ErrorSeverity::Error).unwrap_or(&0) > &0
503            || self.error_counts.get(&ErrorSeverity::Fatal).unwrap_or(&0) > &0
504    }
505
506    /// Check if processing had any warnings
507    #[must_use]
508    #[inline]
509    pub fn has_warnings(&self) -> bool {
510        self.error_counts.get(&ErrorSeverity::Warning).unwrap_or(&0) > &0
511    }
512
513    /// Get total error count (excluding warnings)
514    #[must_use]
515    #[inline]
516    pub fn error_count(&self) -> u64 {
517        self.error_counts.get(&ErrorSeverity::Error).unwrap_or(&0)
518            + self.error_counts.get(&ErrorSeverity::Fatal).unwrap_or(&0)
519    }
520
521    /// Get total warning count
522    #[must_use]
523    #[inline]
524    pub fn warning_count(&self) -> u64 {
525        *self.error_counts.get(&ErrorSeverity::Warning).unwrap_or(&0)
526    }
527}
528
529#[cfg(test)]
530#[allow(clippy::expect_used)]
531#[allow(clippy::unwrap_used)]
532mod tests {
533    use super::*;
534    use copybook_error::ErrorCode;
535
536    #[test]
537    fn test_error_reporter_strict_mode() {
538        let mut reporter = ErrorReporter::new(ErrorMode::Strict, None);
539
540        let error = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "Invalid nibble")
541            .with_record(1)
542            .with_field("CUSTOMER.ID")
543            .with_offset(42);
544
545        // In strict mode, errors should stop processing
546        let result = reporter.report_error(error);
547        assert!(result.is_err());
548        assert!(reporter.has_errors());
549        assert_eq!(reporter.error_count(), 1);
550    }
551
552    #[test]
553    fn test_error_reporter_lenient_mode() {
554        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
555
556        let error = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "Invalid nibble")
557            .with_record(1)
558            .with_field("CUSTOMER.ID")
559            .with_offset(42);
560
561        // In lenient mode, errors should allow continuation
562        let result = reporter.report_error(error);
563        assert!(result.is_ok());
564        assert!(reporter.has_errors());
565        assert_eq!(reporter.error_count(), 1);
566    }
567
568    #[test]
569    fn test_max_errors_limit() {
570        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, Some(1));
571
572        // First error should be OK
573        let error1 = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "Error 1");
574        assert!(reporter.report_error(error1).is_ok());
575
576        // Second error should stop processing (we've reached the limit)
577        let error2 = Error::new(ErrorCode::CBKD411_ZONED_BAD_SIGN, "Error 2");
578        assert!(reporter.report_error(error2).is_err());
579    }
580
581    #[test]
582    fn test_warning_reporting() {
583        let mut reporter = ErrorReporter::new(ErrorMode::Strict, None);
584
585        let warning = Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "Blank field");
586        reporter.report_warning(warning);
587
588        assert!(!reporter.has_errors());
589        assert!(reporter.has_warnings());
590        assert_eq!(reporter.warning_count(), 1);
591    }
592
593    #[test]
594    fn test_corruption_detection() {
595        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
596
597        let corruption_error = Error::new(
598            ErrorCode::CBKF104_RDW_SUSPECT_ASCII,
599            "ASCII corruption detected",
600        );
601        reporter.report_warning(corruption_error);
602
603        assert_eq!(reporter.summary().corruption_warnings, 1);
604    }
605
606    #[test]
607    fn test_error_summary_generation() {
608        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
609
610        reporter.start_record(1);
611        let error =
612            Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "Test error").with_record(1);
613        let _ = reporter.report_error(error);
614
615        let report = reporter.generate_report();
616        assert!(report.contains("Total records processed: 1"));
617        assert!(report.contains("CBKD401_COMP3_INVALID_NIBBLE: 1"));
618    }
619
620    // --- New tests below ---
621
622    #[test]
623    fn test_empty_reporter_no_errors_no_warnings() {
624        let reporter = ErrorReporter::new(ErrorMode::Lenient, None);
625
626        assert!(!reporter.has_errors());
627        assert!(!reporter.has_warnings());
628        assert_eq!(reporter.error_count(), 0);
629        assert_eq!(reporter.warning_count(), 0);
630
631        let summary = reporter.summary();
632        assert!(!summary.has_errors());
633        assert!(!summary.has_warnings());
634        assert_eq!(summary.error_count(), 0);
635        assert_eq!(summary.warning_count(), 0);
636        assert!(summary.first_error.is_none());
637        assert!(summary.last_error.is_none());
638        assert_eq!(summary.corruption_warnings, 0);
639        assert_eq!(summary.total_records, 0);
640        assert_eq!(summary.records_with_errors, 0);
641    }
642
643    #[test]
644    fn test_empty_reporter_report_output() {
645        let reporter = ErrorReporter::new(ErrorMode::Strict, None);
646        let report = reporter.generate_report();
647
648        assert!(report.contains("=== Error Summary ==="));
649        assert!(report.contains("Total records processed: 0"));
650        assert!(report.contains("Records with errors: 0"));
651        // No error counts section when empty
652        assert!(!report.contains("Error counts by severity:"));
653        assert!(!report.contains("Error counts by code:"));
654    }
655
656    #[test]
657    fn test_fatal_error_stops_in_lenient_mode() {
658        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
659
660        // Fatal errors stop processing regardless of mode
661        let error = Error::new(ErrorCode::CBKP001_SYNTAX, "Unexpected token");
662        let result = reporter.report_error(error);
663        assert!(result.is_err());
664        assert!(reporter.has_errors());
665    }
666
667    #[test]
668    fn test_multiple_errors_summary_counts() {
669        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
670
671        let errors = [
672            Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "nibble 1"),
673            Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "nibble 2"),
674            Error::new(ErrorCode::CBKD411_ZONED_BAD_SIGN, "bad sign"),
675        ];
676        for e in errors {
677            let _ = reporter.report_error(e);
678        }
679
680        assert_eq!(reporter.error_count(), 3);
681        let summary = reporter.summary();
682        assert_eq!(
683            *summary
684                .error_codes
685                .get(&ErrorCode::CBKD401_COMP3_INVALID_NIBBLE)
686                .unwrap(),
687            2
688        );
689        assert_eq!(
690            *summary
691                .error_codes
692                .get(&ErrorCode::CBKD411_ZONED_BAD_SIGN)
693                .unwrap(),
694            1
695        );
696    }
697
698    #[test]
699    fn test_first_and_last_error_tracked() {
700        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
701
702        let _ = reporter.report_error(Error::new(
703            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
704            "first error",
705        ));
706        let _ = reporter.report_error(Error::new(
707            ErrorCode::CBKD411_ZONED_BAD_SIGN,
708            "second error",
709        ));
710        let _ = reporter.report_error(Error::new(ErrorCode::CBKD410_ZONED_OVERFLOW, "third error"));
711
712        let summary = reporter.summary();
713        let first = summary.first_error.as_ref().unwrap();
714        let last = summary.last_error.as_ref().unwrap();
715        assert_eq!(first.error.code, ErrorCode::CBKD401_COMP3_INVALID_NIBBLE);
716        assert_eq!(first.error.message, "first error");
717        assert_eq!(last.error.code, ErrorCode::CBKD410_ZONED_OVERFLOW);
718        assert_eq!(last.error.message, "third error");
719    }
720
721    #[test]
722    fn test_max_errors_boundary_exact_limit() {
723        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, Some(3));
724
725        // Errors 1-3 should be OK (under limit)
726        for i in 1..=3 {
727            let e = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, format!("err {i}"));
728            assert!(reporter.report_error(e).is_ok(), "error {i} should succeed");
729        }
730
731        // Error 4 should fail (limit reached)
732        let e4 = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "err 4");
733        assert!(reporter.report_error(e4).is_err());
734        assert_eq!(reporter.error_count(), 4);
735    }
736
737    #[test]
738    fn test_max_errors_unlimited_in_lenient() {
739        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
740
741        for i in 0..100 {
742            let e = Error::new(
743                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
744                format!("error {i}"),
745            );
746            assert!(reporter.report_error(e).is_ok());
747        }
748        assert_eq!(reporter.error_count(), 100);
749    }
750
751    #[test]
752    fn test_severity_display_formatting() {
753        assert_eq!(format!("{}", ErrorSeverity::Info), "INFO");
754        assert_eq!(format!("{}", ErrorSeverity::Warning), "WARN");
755        assert_eq!(format!("{}", ErrorSeverity::Error), "ERROR");
756        assert_eq!(format!("{}", ErrorSeverity::Fatal), "FATAL");
757    }
758
759    #[test]
760    fn test_severity_ordering() {
761        assert!(ErrorSeverity::Info < ErrorSeverity::Warning);
762        assert!(ErrorSeverity::Warning < ErrorSeverity::Error);
763        assert!(ErrorSeverity::Error < ErrorSeverity::Fatal);
764    }
765
766    #[test]
767    fn test_odo_clipped_severity_depends_on_mode() {
768        // In lenient mode: warning (continues)
769        let mut lenient = ErrorReporter::new(ErrorMode::Lenient, None);
770        let e = Error::new(ErrorCode::CBKS301_ODO_CLIPPED, "ODO clipped");
771        assert!(lenient.report_error(e).is_ok());
772        assert!(!lenient.has_errors());
773        assert!(lenient.has_warnings());
774
775        // In strict mode: fatal (stops)
776        let mut strict = ErrorReporter::new(ErrorMode::Strict, None);
777        let e = Error::new(ErrorCode::CBKS301_ODO_CLIPPED, "ODO clipped");
778        assert!(strict.report_error(e).is_err());
779    }
780
781    #[test]
782    fn test_mixed_errors_and_warnings_report() {
783        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
784
785        reporter.report_warning(Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blank"));
786        let _ = reporter.report_error(Error::new(
787            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
788            "bad nibble",
789        ));
790        reporter.report_warning(Error::new(
791            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE,
792            "bad byte",
793        ));
794
795        let report = reporter.generate_report();
796        assert!(report.contains("Error counts by severity:"));
797        assert!(report.contains("Error counts by code:"));
798        assert_eq!(reporter.error_count(), 1);
799        assert_eq!(reporter.warning_count(), 2);
800    }
801
802    #[test]
803    fn test_corruption_detection_ebcdic_byte() {
804        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
805
806        reporter.report_warning(Error::new(
807            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE,
808            "Invalid EBCDIC byte 0xFF",
809        ));
810
811        assert_eq!(reporter.summary().corruption_warnings, 1);
812        assert!(reporter.has_warnings());
813        assert!(!reporter.has_errors());
814    }
815
816    #[test]
817    fn test_multiple_corruption_warnings_counted() {
818        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
819
820        for _ in 0..5 {
821            reporter.report_warning(Error::new(
822                ErrorCode::CBKF104_RDW_SUSPECT_ASCII,
823                "ASCII suspected",
824            ));
825        }
826        reporter.report_warning(Error::new(
827            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE,
828            "Bad EBCDIC",
829        ));
830
831        assert_eq!(reporter.summary().corruption_warnings, 6);
832        assert_eq!(reporter.warning_count(), 6);
833    }
834
835    #[test]
836    fn test_verbose_logging_toggle() {
837        let reporter = ErrorReporter::new(ErrorMode::Lenient, None).with_verbose_logging(false);
838        // Verify the builder pattern works and reporter is usable
839        assert!(!reporter.has_errors());
840        assert_eq!(reporter.error_count(), 0);
841    }
842
843    #[test]
844    fn test_very_long_error_message() {
845        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
846        let long_msg = "x".repeat(10_000);
847        let e = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, long_msg.clone());
848        let _ = reporter.report_error(e);
849
850        let first = reporter.summary().first_error.as_ref().unwrap();
851        assert_eq!(first.error.message.len(), 10_000);
852
853        let report = reporter.generate_report();
854        assert!(report.contains("First error:"));
855    }
856
857    #[test]
858    fn test_start_record_tracks_total_records() {
859        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
860
861        reporter.start_record(0);
862        assert_eq!(reporter.summary().total_records, 0);
863
864        reporter.start_record(42);
865        assert_eq!(reporter.summary().total_records, 42);
866
867        reporter.start_record(100);
868        assert_eq!(reporter.summary().total_records, 100);
869
870        let report = reporter.generate_report();
871        assert!(report.contains("Total records processed: 100"));
872    }
873
874    #[test]
875    fn test_error_report_contains_first_error_in_output() {
876        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
877
878        let _ = reporter.report_error(Error::new(
879            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
880            "the very first problem",
881        ));
882        let _ = reporter.report_error(Error::new(
883            ErrorCode::CBKD411_ZONED_BAD_SIGN,
884            "second problem",
885        ));
886
887        let report = reporter.generate_report();
888        assert!(report.contains("First error:"));
889        assert!(report.contains("the very first problem"));
890        // Last error is not shown in generate_report output
891    }
892
893    #[test]
894    fn test_error_summary_default_is_clean() {
895        let summary = ErrorSummary::default();
896        assert!(!summary.has_errors());
897        assert!(!summary.has_warnings());
898        assert_eq!(summary.error_count(), 0);
899        assert_eq!(summary.warning_count(), 0);
900        assert!(summary.first_error.is_none());
901        assert!(summary.last_error.is_none());
902        assert_eq!(summary.records_with_errors, 0);
903        assert_eq!(summary.total_records, 0);
904        assert_eq!(summary.corruption_warnings, 0);
905    }
906
907    // -----------------------------------------------------------------------
908    // Severity classification tests
909    // -----------------------------------------------------------------------
910
911    #[test]
912    fn test_severity_parse_errors_are_fatal() {
913        let parse_codes = [
914            ErrorCode::CBKP001_SYNTAX,
915            ErrorCode::CBKP011_UNSUPPORTED_CLAUSE,
916            ErrorCode::CBKP021_ODO_NOT_TAIL,
917            ErrorCode::CBKP022_NESTED_ODO,
918            ErrorCode::CBKP023_ODO_REDEFINES,
919            ErrorCode::CBKP051_UNSUPPORTED_EDITED_PIC,
920            ErrorCode::CBKP101_INVALID_PIC,
921        ];
922        for code in parse_codes {
923            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
924            let err = Error::new(code, "parse error");
925            let result = reporter.report_error(err);
926            assert!(
927                result.is_err(),
928                "CBKP code {code} should be fatal even in lenient mode"
929            );
930        }
931    }
932
933    #[test]
934    fn test_severity_schema_errors_are_fatal() {
935        let schema_codes = [
936            ErrorCode::CBKS121_COUNTER_NOT_FOUND,
937            ErrorCode::CBKS141_RECORD_TOO_LARGE,
938            ErrorCode::CBKS601_RENAME_UNKNOWN_FROM,
939            ErrorCode::CBKS701_PROJECTION_INVALID_ODO,
940            ErrorCode::CBKS703_PROJECTION_FIELD_NOT_FOUND,
941        ];
942        for code in schema_codes {
943            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
944            let err = Error::new(code, "schema error");
945            let result = reporter.report_error(err);
946            assert!(result.is_err(), "CBKS code {code} should be fatal");
947        }
948    }
949
950    #[test]
951    fn test_severity_data_errors_are_error_level() {
952        let data_codes = [
953            ErrorCode::CBKD101_INVALID_FIELD_TYPE,
954            ErrorCode::CBKD301_RECORD_TOO_SHORT,
955            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
956            ErrorCode::CBKD410_ZONED_OVERFLOW,
957            ErrorCode::CBKD411_ZONED_BAD_SIGN,
958            ErrorCode::CBKD421_EDITED_PIC_INVALID_FORMAT,
959            ErrorCode::CBKD431_FLOAT_NAN,
960            ErrorCode::CBKD432_FLOAT_INFINITY,
961        ];
962        for code in data_codes {
963            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
964            let err = Error::new(code, "data error");
965            let result = reporter.report_error(err);
966            assert!(
967                result.is_ok(),
968                "CBKD code {code} should continue in lenient mode"
969            );
970            assert!(reporter.has_errors());
971        }
972    }
973
974    #[test]
975    fn test_severity_warning_codes_are_warnings() {
976        let warning_codes = [
977            ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO,
978            ErrorCode::CBKC301_INVALID_EBCDIC_BYTE,
979            ErrorCode::CBKD423_EDITED_PIC_BLANK_WHEN_ZERO,
980            ErrorCode::CBKF104_RDW_SUSPECT_ASCII,
981        ];
982        for code in warning_codes {
983            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
984            let err = Error::new(code, "warning");
985            let result = reporter.report_error(err);
986            assert!(result.is_ok(), "code {code} should continue");
987            assert!(
988                !reporter.has_errors(),
989                "code {code} should be warning not error"
990            );
991            assert!(reporter.has_warnings(), "code {code} should be a warning");
992        }
993    }
994
995    #[test]
996    fn test_severity_odo_clipped_warning_in_lenient_fatal_in_strict() {
997        for code in [
998            ErrorCode::CBKS301_ODO_CLIPPED,
999            ErrorCode::CBKS302_ODO_RAISED,
1000        ] {
1001            // Lenient: warning
1002            let mut lenient = ErrorReporter::new(ErrorMode::Lenient, None);
1003            assert!(lenient.report_error(Error::new(code, "odo")).is_ok());
1004            assert!(lenient.has_warnings());
1005            assert!(!lenient.has_errors());
1006
1007            // Strict: fatal
1008            let mut strict = ErrorReporter::new(ErrorMode::Strict, None);
1009            assert!(strict.report_error(Error::new(code, "odo")).is_err());
1010        }
1011    }
1012
1013    #[test]
1014    fn test_severity_encode_errors_are_error_level_in_lenient() {
1015        let encode_codes = [
1016            ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
1017            ErrorCode::CBKE521_ARRAY_LEN_OOB,
1018            ErrorCode::CBKE530_SIGN_SEPARATE_ENCODE_ERROR,
1019            ErrorCode::CBKE531_FLOAT_ENCODE_OVERFLOW,
1020        ];
1021        for code in encode_codes {
1022            let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1023            let result = reporter.report_error(Error::new(code, "encode"));
1024            assert!(result.is_ok(), "{code} should continue in lenient");
1025            assert!(reporter.has_errors());
1026        }
1027    }
1028
1029    #[test]
1030    fn test_severity_infrastructure_error_is_fatal() {
1031        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1032        let err = Error::new(ErrorCode::CBKI001_INVALID_STATE, "bad state");
1033        assert!(reporter.report_error(err).is_err());
1034    }
1035
1036    // -----------------------------------------------------------------------
1037    // Record tracking tests
1038    // -----------------------------------------------------------------------
1039
1040    #[test]
1041    fn test_records_with_errors_tracking() {
1042        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1043
1044        // Report errors on different records
1045        let _ = reporter.report_error(
1046            Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "bad").with_record(5),
1047        );
1048        let _ = reporter
1049            .report_error(Error::new(ErrorCode::CBKD411_ZONED_BAD_SIGN, "bad").with_record(10));
1050
1051        assert_eq!(reporter.summary().records_with_errors, 10);
1052    }
1053
1054    #[test]
1055    fn test_records_with_errors_not_incremented_for_warnings() {
1056        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1057        reporter.report_warning(
1058            Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blank").with_record(5),
1059        );
1060        assert_eq!(reporter.summary().records_with_errors, 0);
1061    }
1062
1063    // -----------------------------------------------------------------------
1064    // ErrorReport metadata tests
1065    // -----------------------------------------------------------------------
1066
1067    #[test]
1068    fn test_corruption_warning_adds_metadata() {
1069        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1070        reporter.report_warning(Error::new(
1071            ErrorCode::CBKF104_RDW_SUSPECT_ASCII,
1072            "ASCII transfer",
1073        ));
1074        assert_eq!(reporter.summary().corruption_warnings, 1);
1075    }
1076
1077    #[test]
1078    fn test_non_corruption_warning_no_corruption_count() {
1079        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1080        reporter.report_warning(Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blanks"));
1081        assert_eq!(reporter.summary().corruption_warnings, 0);
1082    }
1083
1084    // -----------------------------------------------------------------------
1085    // ErrorMode and ErrorSeverity tests
1086    // -----------------------------------------------------------------------
1087
1088    #[test]
1089    fn test_error_mode_equality() {
1090        assert_eq!(ErrorMode::Strict, ErrorMode::Strict);
1091        assert_eq!(ErrorMode::Lenient, ErrorMode::Lenient);
1092        assert_ne!(ErrorMode::Strict, ErrorMode::Lenient);
1093    }
1094
1095    #[test]
1096    fn test_error_severity_equality_and_hash() {
1097        use std::collections::HashSet;
1098        let mut set = HashSet::new();
1099        set.insert(ErrorSeverity::Info);
1100        set.insert(ErrorSeverity::Warning);
1101        set.insert(ErrorSeverity::Error);
1102        set.insert(ErrorSeverity::Fatal);
1103        set.insert(ErrorSeverity::Info); // duplicate
1104        assert_eq!(set.len(), 4);
1105    }
1106
1107    // -----------------------------------------------------------------------
1108    // Report generation tests
1109    // -----------------------------------------------------------------------
1110
1111    #[test]
1112    fn test_generate_report_includes_corruption_section() {
1113        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1114        reporter.report_warning(Error::new(ErrorCode::CBKF104_RDW_SUSPECT_ASCII, "ASCII"));
1115        reporter.report_warning(Error::new(ErrorCode::CBKC301_INVALID_EBCDIC_BYTE, "EBCDIC"));
1116
1117        let report = reporter.generate_report();
1118        assert!(report.contains("Transfer corruption warnings: 2"));
1119    }
1120
1121    #[test]
1122    fn test_generate_report_no_corruption_section_when_zero() {
1123        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1124        reporter.report_warning(Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blank"));
1125
1126        let report = reporter.generate_report();
1127        assert!(!report.contains("Transfer corruption warnings"));
1128    }
1129
1130    #[test]
1131    fn test_error_summary_has_errors_with_fatal_only() {
1132        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1133        // Parse errors are Fatal severity
1134        let _ = reporter.report_error(Error::new(ErrorCode::CBKP001_SYNTAX, "syntax"));
1135        assert!(reporter.summary().has_errors());
1136        assert_eq!(reporter.summary().error_count(), 1);
1137    }
1138
1139    // -----------------------------------------------------------------------
1140    // ErrorCode JSON serialization tests (through reporter context)
1141    // -----------------------------------------------------------------------
1142
1143    #[test]
1144    fn test_error_code_json_roundtrip_in_summary() {
1145        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, None);
1146        let _ = reporter.report_error(Error::new(
1147            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1148            "nibble",
1149        ));
1150
1151        // Serialize error code from summary
1152        for (code, count) in &reporter.summary().error_codes {
1153            let json = serde_json::to_string(code).unwrap();
1154            let rt: ErrorCode = serde_json::from_str(&json).unwrap();
1155            assert_eq!(*code, rt);
1156            assert_eq!(*count, 1);
1157        }
1158    }
1159
1160    #[test]
1161    fn test_error_code_json_serialization_format() {
1162        // Verify codes serialize as quoted variant names (stable API contract)
1163        let test_cases = [
1164            (ErrorCode::CBKP001_SYNTAX, r#""CBKP001_SYNTAX""#),
1165            (
1166                ErrorCode::CBKS121_COUNTER_NOT_FOUND,
1167                r#""CBKS121_COUNTER_NOT_FOUND""#,
1168            ),
1169            (
1170                ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1171                r#""CBKD401_COMP3_INVALID_NIBBLE""#,
1172            ),
1173            (
1174                ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
1175                r#""CBKE501_JSON_TYPE_MISMATCH""#,
1176            ),
1177            (
1178                ErrorCode::CBKR211_RDW_RESERVED_NONZERO,
1179                r#""CBKR211_RDW_RESERVED_NONZERO""#,
1180            ),
1181            (
1182                ErrorCode::CBKF102_RECORD_LENGTH_INVALID,
1183                r#""CBKF102_RECORD_LENGTH_INVALID""#,
1184            ),
1185        ];
1186        for (code, expected_json) in test_cases {
1187            let json = serde_json::to_string(&code).unwrap();
1188            assert_eq!(json, expected_json, "serialization mismatch for {code}");
1189        }
1190    }
1191
1192    // -----------------------------------------------------------------------
1193    // Max errors edge cases
1194    // -----------------------------------------------------------------------
1195
1196    #[test]
1197    fn test_max_errors_zero_stops_immediately() {
1198        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, Some(0));
1199        let err = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "first");
1200        // max_errors=0 means 0 < 0 is false, so first error should fail
1201        assert!(reporter.report_error(err).is_err());
1202    }
1203
1204    #[test]
1205    fn test_strict_mode_stops_on_every_data_error() {
1206        let data_codes = [
1207            ErrorCode::CBKD401_COMP3_INVALID_NIBBLE,
1208            ErrorCode::CBKD411_ZONED_BAD_SIGN,
1209            ErrorCode::CBKD410_ZONED_OVERFLOW,
1210            ErrorCode::CBKE501_JSON_TYPE_MISMATCH,
1211        ];
1212        for code in data_codes {
1213            let mut reporter = ErrorReporter::new(ErrorMode::Strict, None);
1214            let result = reporter.report_error(Error::new(code, "data error"));
1215            assert!(result.is_err(), "strict mode should stop on {code}");
1216        }
1217    }
1218
1219    #[test]
1220    fn test_warning_does_not_count_as_error() {
1221        let mut reporter = ErrorReporter::new(ErrorMode::Lenient, Some(1));
1222        // Report many warnings - they shouldn't count against max_errors
1223        for _ in 0..10 {
1224            reporter.report_warning(Error::new(ErrorCode::CBKD412_ZONED_BLANK_IS_ZERO, "blank"));
1225        }
1226        assert_eq!(reporter.error_count(), 0);
1227        assert_eq!(reporter.warning_count(), 10);
1228        // Next real error should still succeed (first error under limit)
1229        let err = Error::new(ErrorCode::CBKD401_COMP3_INVALID_NIBBLE, "first real error");
1230        assert!(reporter.report_error(err).is_ok());
1231    }
1232}