1use std::collections::HashMap;
2use std::fmt;
3use std::io;
4use std::path::PathBuf;
5use thiserror::Error;
6
7pub type Result<T> = std::result::Result<T, Error>;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ErrorSeverity {
12 Warning,
13 Error,
14 Critical,
15}
16
17impl fmt::Display for ErrorSeverity {
18 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
19 match self {
20 Self::Warning => write!(f, "WARNING"),
21 Self::Error => write!(f, "ERROR"),
22 Self::Critical => write!(f, "CRITICAL"),
23 }
24 }
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum ErrorKind {
30 EncodingError,
31 FieldMissing,
32 ParseFailed,
33}
34
35impl ErrorKind {
36 #[must_use]
37 pub fn kind_display(self) -> &'static str {
38 match self {
39 Self::EncodingError => "encoding_error",
40 Self::FieldMissing => "field_missing",
41 Self::ParseFailed => "parse_failed",
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct ParseErrorRecord {
49 pub line_number: u64,
50 pub raw_truncated: String,
51 pub kind: ErrorKind,
52}
53
54#[must_use]
56pub fn truncate_to_120_chars(raw: &str) -> String {
57 let end = raw
58 .char_indices()
59 .nth(120)
60 .map_or(raw.len(), |(index, _)| index);
61 raw[..end].to_string()
62}
63
64#[must_use]
66pub fn classify_error_kind(raw: &str) -> ErrorKind {
67 if raw.contains('\u{FFFD}') {
68 ErrorKind::EncodingError
69 } else if raw.starts_with("(EP[") {
70 ErrorKind::FieldMissing
71 } else {
72 ErrorKind::ParseFailed
73 }
74}
75
76#[derive(Debug, Default, Clone)]
78pub struct ErrorStats {
79 pub total_errors: usize,
80 pub parse_errors: usize,
81 pub export_errors: usize,
82 pub fatal_error: Option<String>,
83 pub by_type: HashMap<ErrorKind, u64>,
84 pub filtered_out: u64,
85 pub parse_error_records: Vec<ParseErrorRecord>,
86 pub records_exported: usize, }
88
89impl ErrorStats {
90 #[must_use]
91 pub fn has_errors(&self) -> bool {
92 self.total_errors > 0
93 }
94
95 #[must_use]
96 pub fn has_fatal(&self) -> bool {
97 self.fatal_error.is_some()
98 }
99
100 pub fn add_parse_error(&mut self) {
101 self.total_errors += 1;
102 self.parse_errors += 1;
103 }
104
105 pub fn add_parse_error_with_kind(&mut self, kind: ErrorKind) {
106 self.add_parse_error();
107 *self.by_type.entry(kind).or_insert(0) += 1;
108 }
109
110 pub fn add_export_error(&mut self) {
111 self.total_errors += 1;
112 self.export_errors += 1;
113 }
114
115 pub fn set_fatal(&mut self, msg: String) {
116 self.fatal_error = Some(msg);
117 }
118
119 pub fn merge(&mut self, other: &ErrorStats) {
120 self.total_errors += other.total_errors;
121 self.parse_errors += other.parse_errors;
122 self.export_errors += other.export_errors;
123 if self.fatal_error.is_none() && other.fatal_error.is_some() {
124 self.fatal_error.clone_from(&other.fatal_error);
125 }
126 for (kind, count) in &other.by_type {
127 *self.by_type.entry(*kind).or_insert(0) += count;
128 }
129 self.filtered_out += other.filtered_out;
130 self.records_exported += other.records_exported;
131 const MAX_RECORDS: usize = 10_000;
132 let remaining_cap = MAX_RECORDS.saturating_sub(self.parse_error_records.len());
133 if remaining_cap > 0 {
134 self.parse_error_records.extend(
135 other
136 .parse_error_records
137 .iter()
138 .take(remaining_cap)
139 .cloned(),
140 );
141 }
142 }
143}
144
145#[derive(Debug, Error)]
146pub enum Error {
147 #[error("Configuration error: {0}")]
148 Config(#[from] ConfigError),
149
150 #[error("File error: {0}")]
151 File(#[from] FileError),
152
153 #[error("SQL log parser error: {0}")]
154 Parser(#[from] ParserError),
155
156 #[error("Export error: {0}")]
157 Export(#[from] ExportError),
158
159 #[error("IO error: {0}")]
160 Io(#[from] io::Error),
161
162 #[error("Interrupted by user")]
163 Interrupted,
164}
165
166impl Error {
167 #[must_use]
168 pub fn is_fatal(&self) -> bool {
169 match self {
170 Error::Config(_) | Error::Io(_) | Error::Interrupted => true,
171 Error::File(e) => matches!(
172 e,
173 FileError::AlreadyExists { .. } | FileError::CreateDirectoryFailed { .. }
174 ),
175 Error::Parser(e) => matches!(e, ParserError::ReadDirFailed { .. }),
176 Error::Export(e) => matches!(e, ExportError::DatabaseFailed { .. }),
177 }
178 }
179
180 #[must_use]
181 pub fn severity(&self) -> ErrorSeverity {
182 match self {
183 Error::Config(_) | Error::Io(_) | Error::Interrupted => ErrorSeverity::Critical,
184 Error::File(e) => match e {
185 FileError::WriteFailed { .. } => ErrorSeverity::Error,
186 FileError::AlreadyExists { .. } | FileError::CreateDirectoryFailed { .. } => {
187 ErrorSeverity::Critical
188 }
189 },
190 Error::Parser(_) => ErrorSeverity::Warning,
191 Error::Export(e) => match e {
192 ExportError::WriteFailed { .. } => ErrorSeverity::Error,
193 ExportError::DatabaseFailed { .. } => ErrorSeverity::Critical,
194 },
195 }
196 }
197
198 #[must_use]
199 pub fn suggestion(&self) -> &str {
200 match self {
201 Error::Config(e) => match e {
202 ConfigError::NotFound(_) => {
203 "Create a config file with 'sqllog2db init' or check the file path."
204 }
205 ConfigError::ParseFailed { .. } => "Check TOML syntax in the configuration file.",
206 ConfigError::InvalidLogLevel { .. } => {
207 "Valid log levels: error, warn, info, debug, trace."
208 }
209 ConfigError::InvalidValue { .. } => {
210 "Check the field value in the configuration file."
211 }
212 ConfigError::NoExporters => "Enable at least one exporter: [csv] or [sqlite].",
213 },
214 Error::File(e) => match e {
215 FileError::AlreadyExists { .. } => {
216 "Use --force to overwrite, or choose a different output path."
217 }
218 FileError::WriteFailed { .. } => "Check disk space and file permissions.",
219 FileError::CreateDirectoryFailed { .. } => "Check parent directory permissions.",
220 },
221 Error::Parser(e) => match e {
222 ParserError::PathNotFound { .. } => {
223 "Verify the log file exists at the specified path."
224 }
225 ParserError::InvalidPath { .. } => "Check the path format or try an absolute path.",
226 ParserError::ReadDirFailed { .. } => "Check directory permissions.",
227 ParserError::NoFilesFound { .. } => {
228 "Verify the glob/path entries exist; ensure patterns match .log files in the current directory."
229 }
230 },
231 Error::Export(e) => match e {
232 ExportError::WriteFailed { .. } => {
233 "Check disk space and output directory permissions."
234 }
235 ExportError::DatabaseFailed { .. } => {
236 "Verify the SQLite database file is accessible."
237 }
238 },
239 Error::Io(_) => "Check filesystem permissions and disk space.",
240 Error::Interrupted => "Run was interrupted by user.",
241 }
242 }
243}
244
245#[derive(Debug, Error)]
246pub enum ConfigError {
247 #[error("Configuration file not found: {0}")]
248 NotFound(PathBuf),
249
250 #[error("Failed to parse configuration file {path}: {reason}")]
251 ParseFailed { path: PathBuf, reason: String },
252
253 #[error("Invalid log level '{level}', valid values: {}", valid_levels.join(", "))]
254 InvalidLogLevel {
255 level: String,
256 valid_levels: Vec<String>,
257 },
258
259 #[error("Invalid configuration value {field} = '{value}': {reason}")]
260 InvalidValue {
261 field: String,
262 value: String,
263 reason: String,
264 },
265
266 #[error("At least one exporter must be configured (csv/sqlite)")]
267 NoExporters,
268}
269
270#[derive(Debug, Error)]
271pub enum FileError {
272 #[error("File already exists: {path} (set overwrite=true to replace)")]
273 AlreadyExists { path: PathBuf },
274
275 #[error("Failed to write file {path}: {reason}")]
276 WriteFailed { path: PathBuf, reason: String },
277
278 #[error("Failed to create directory {path}: {reason}")]
279 CreateDirectoryFailed { path: PathBuf, reason: String },
280}
281
282#[derive(Debug, Error)]
283pub enum ParserError {
284 #[error("Path not found: {}", path.display())]
285 PathNotFound { path: PathBuf },
286
287 #[error("Invalid path {}: {reason}{}", path.display(), line_number.map_or_else(String::new, |n| format!(" (line {n})")))]
288 InvalidPath {
289 path: PathBuf,
290 reason: String,
291 line_number: Option<u64>,
292 },
293
294 #[error("Failed to read directory {}: {reason}", path.display())]
295 ReadDirFailed { path: PathBuf, reason: String },
296
297 #[error("No log files found matching inputs: {inputs:?}")]
298 NoFilesFound { inputs: Vec<String> },
299}
300
301#[derive(Debug, Error)]
302pub enum ExportError {
303 #[error("Write failed {path}: {reason}")]
305 WriteFailed { path: PathBuf, reason: String },
306
307 #[error("Database error: {reason}")]
309 DatabaseFailed { reason: String },
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
317 fn test_no_files_found_display_contains_inputs() {
318 let err = ParserError::NoFilesFound {
319 inputs: vec!["a.log".into(), "b/*.log".into()],
320 };
321 let display = format!("{err}");
322 assert!(
323 display.contains("a.log"),
324 "Display should contain 'a.log', got: {display}"
325 );
326 assert!(
327 display.contains("b/*.log"),
328 "Display should contain 'b/*.log', got: {display}"
329 );
330 }
331
332 #[test]
333 fn test_no_files_found_suggestion_non_empty() {
334 let err = Error::Parser(ParserError::NoFilesFound {
335 inputs: vec!["x".into()],
336 });
337 let suggestion = err.suggestion();
338 assert!(
339 !suggestion.is_empty(),
340 "suggestion() should not be empty for NoFilesFound"
341 );
342 assert!(
343 suggestion.contains("glob"),
344 "suggestion() should contain 'glob', got: {suggestion}"
345 );
346 }
347
348 #[test]
349 fn test_no_files_found_not_fatal() {
350 let err = Error::Parser(ParserError::NoFilesFound {
351 inputs: vec!["x".into()],
352 });
353 assert!(!err.is_fatal(), "NoFilesFound should not be fatal");
354 assert_eq!(
355 err.severity(),
356 ErrorSeverity::Warning,
357 "NoFilesFound should have Warning severity"
358 );
359 }
360
361 #[test]
364 fn test_config_not_found_is_fatal_critical_suggestion() {
365 let err = Error::Config(ConfigError::NotFound(PathBuf::from("/no/file")));
366 assert!(err.is_fatal(), "ConfigError::NotFound should be fatal");
367 assert_eq!(
368 err.severity(),
369 ErrorSeverity::Critical,
370 "ConfigError::NotFound should have Critical severity"
371 );
372 assert!(
373 err.suggestion().contains("init"),
374 "ConfigError::NotFound suggestion should contain 'init', got: {}",
375 err.suggestion()
376 );
377 }
378
379 #[test]
380 fn test_config_parse_failed_suggestion_mentions_toml() {
381 let err = Error::Config(ConfigError::ParseFailed {
382 path: PathBuf::from("/cfg.toml"),
383 reason: "unexpected key".into(),
384 });
385 assert!(err.is_fatal(), "ConfigError::ParseFailed should be fatal");
386 assert_eq!(
387 err.severity(),
388 ErrorSeverity::Critical,
389 "ConfigError::ParseFailed should have Critical severity"
390 );
391 assert!(
392 err.suggestion().contains("TOML"),
393 "ConfigError::ParseFailed suggestion should contain 'TOML', got: {}",
394 err.suggestion()
395 );
396 }
397
398 #[test]
399 fn test_config_invalid_log_level_suggestion() {
400 let err = Error::Config(ConfigError::InvalidLogLevel {
401 level: "verbose".into(),
402 valid_levels: vec!["info".into(), "debug".into()],
403 });
404 assert!(
405 err.is_fatal(),
406 "ConfigError::InvalidLogLevel should be fatal"
407 );
408 assert!(
409 err.suggestion().contains("log levels"),
410 "ConfigError::InvalidLogLevel suggestion should contain 'log levels', got: {}",
411 err.suggestion()
412 );
413 }
414
415 #[test]
416 fn test_config_invalid_value_suggestion() {
417 let err = Error::Config(ConfigError::InvalidValue {
418 field: "buffer_size".into(),
419 value: "-1".into(),
420 reason: "must be positive".into(),
421 });
422 assert!(err.is_fatal(), "ConfigError::InvalidValue should be fatal");
423 assert!(
424 err.suggestion().contains("field value"),
425 "ConfigError::InvalidValue suggestion should contain 'field value', got: {}",
426 err.suggestion()
427 );
428 }
429
430 #[test]
431 fn test_config_no_exporters_suggestion() {
432 let err = Error::Config(ConfigError::NoExporters);
433 assert!(err.is_fatal(), "ConfigError::NoExporters should be fatal");
434 assert!(
435 err.suggestion().contains("exporter"),
436 "ConfigError::NoExporters suggestion should contain 'exporter', got: {}",
437 err.suggestion()
438 );
439 }
440
441 #[test]
444 fn test_file_already_exists_is_fatal() {
445 let err = Error::File(FileError::AlreadyExists {
446 path: PathBuf::from("/out.csv"),
447 });
448 assert!(err.is_fatal(), "FileError::AlreadyExists should be fatal");
449 assert_eq!(
450 err.severity(),
451 ErrorSeverity::Critical,
452 "FileError::AlreadyExists should have Critical severity"
453 );
454 assert!(
455 err.suggestion().contains("--force"),
456 "FileError::AlreadyExists suggestion should contain '--force', got: {}",
457 err.suggestion()
458 );
459 }
460
461 #[test]
462 fn test_file_write_failed_not_fatal_error_severity() {
463 let err = Error::File(FileError::WriteFailed {
464 path: PathBuf::from("/out.csv"),
465 reason: "permission denied".into(),
466 });
467 assert!(
468 !err.is_fatal(),
469 "FileError::WriteFailed should not be fatal"
470 );
471 assert_eq!(
472 err.severity(),
473 ErrorSeverity::Error,
474 "FileError::WriteFailed should have Error severity"
475 );
476 assert!(
477 err.suggestion().contains("disk space"),
478 "FileError::WriteFailed suggestion should contain 'disk space', got: {}",
479 err.suggestion()
480 );
481 }
482
483 #[test]
484 fn test_file_create_directory_failed_is_fatal() {
485 let err = Error::File(FileError::CreateDirectoryFailed {
486 path: PathBuf::from("/no/dir"),
487 reason: "permission denied".into(),
488 });
489 assert!(
490 err.is_fatal(),
491 "FileError::CreateDirectoryFailed should be fatal"
492 );
493 assert_eq!(
494 err.severity(),
495 ErrorSeverity::Critical,
496 "FileError::CreateDirectoryFailed should have Critical severity"
497 );
498 assert!(
499 err.suggestion().contains("parent directory"),
500 "FileError::CreateDirectoryFailed suggestion should contain 'parent directory', got: {}",
501 err.suggestion()
502 );
503 }
504
505 #[test]
508 fn test_export_write_failed_not_fatal_error_severity() {
509 let err = Error::Export(ExportError::WriteFailed {
510 path: PathBuf::from("/out.db"),
511 reason: "disk full".into(),
512 });
513 assert!(
514 !err.is_fatal(),
515 "ExportError::WriteFailed should not be fatal"
516 );
517 assert_eq!(
518 err.severity(),
519 ErrorSeverity::Error,
520 "ExportError::WriteFailed should have Error severity"
521 );
522 assert!(
523 err.suggestion().contains("disk space"),
524 "ExportError::WriteFailed suggestion should contain 'disk space', got: {}",
525 err.suggestion()
526 );
527 }
528
529 #[test]
530 fn test_export_database_failed_is_fatal_critical() {
531 let err = Error::Export(ExportError::DatabaseFailed {
532 reason: "locked".into(),
533 });
534 assert!(
535 err.is_fatal(),
536 "ExportError::DatabaseFailed should be fatal"
537 );
538 assert_eq!(
539 err.severity(),
540 ErrorSeverity::Critical,
541 "ExportError::DatabaseFailed should have Critical severity"
542 );
543 assert!(
544 err.suggestion().contains("SQLite"),
545 "ExportError::DatabaseFailed suggestion should contain 'SQLite', got: {}",
546 err.suggestion()
547 );
548 }
549
550 #[test]
553 fn test_io_error_is_fatal_critical() {
554 let err = Error::Io(std::io::Error::other("test io error"));
555 assert!(err.is_fatal(), "Error::Io should be fatal");
556 assert_eq!(
557 err.severity(),
558 ErrorSeverity::Critical,
559 "Error::Io should have Critical severity"
560 );
561 assert!(
562 err.suggestion().contains("filesystem"),
563 "Error::Io suggestion should contain 'filesystem', got: {}",
564 err.suggestion()
565 );
566 }
567
568 #[test]
569 fn test_interrupted_is_fatal_critical() {
570 let err = Error::Interrupted;
571 assert!(err.is_fatal(), "Error::Interrupted should be fatal");
572 assert_eq!(
573 err.severity(),
574 ErrorSeverity::Critical,
575 "Error::Interrupted should have Critical severity"
576 );
577 assert!(
578 err.suggestion().contains("interrupted"),
579 "Error::Interrupted suggestion should contain 'interrupted', got: {}",
580 err.suggestion()
581 );
582 }
583
584 #[test]
587 fn test_parser_path_not_found_suggestion() {
588 let err = Error::Parser(ParserError::PathNotFound {
589 path: PathBuf::from("/missing.log"),
590 });
591 assert!(
592 !err.is_fatal(),
593 "ParserError::PathNotFound should not be fatal"
594 );
595 assert_eq!(
596 err.severity(),
597 ErrorSeverity::Warning,
598 "ParserError::PathNotFound should have Warning severity"
599 );
600 assert!(
601 err.suggestion().contains("log file exists"),
602 "ParserError::PathNotFound suggestion should contain 'log file exists', got: {}",
603 err.suggestion()
604 );
605 }
606
607 #[test]
608 fn test_parser_invalid_path_suggestion() {
609 let err = Error::Parser(ParserError::InvalidPath {
610 path: PathBuf::from("/bad\0path"),
611 reason: "null byte in path".into(),
612 line_number: Some(42),
613 });
614 assert!(
615 !err.is_fatal(),
616 "ParserError::InvalidPath should not be fatal"
617 );
618 assert!(
619 err.suggestion().contains("path format"),
620 "ParserError::InvalidPath suggestion should contain 'path format', got: {}",
621 err.suggestion()
622 );
623 }
624
625 #[test]
626 fn test_parser_read_dir_failed_is_fatal() {
627 let err = Error::Parser(ParserError::ReadDirFailed {
628 path: PathBuf::from("/locked/dir"),
629 reason: "permission denied".into(),
630 });
631 assert!(
632 err.is_fatal(),
633 "ParserError::ReadDirFailed should be fatal (only fatal Parser variant)"
634 );
635 assert!(
636 err.suggestion().contains("directory permissions"),
637 "ParserError::ReadDirFailed suggestion should contain 'directory permissions', got: {}",
638 err.suggestion()
639 );
640 }
641
642 #[test]
645 fn test_error_stats_add_and_count() {
646 let mut s = ErrorStats::default();
647 s.add_parse_error();
648 s.add_export_error();
649 assert_eq!(s.total_errors, 2);
650 assert_eq!(s.parse_errors, 1);
651 assert_eq!(s.export_errors, 1);
652 assert!(
653 s.has_errors(),
654 "has_errors should be true after adding errors"
655 );
656 assert!(
657 !s.has_fatal(),
658 "has_fatal should be false when no fatal set"
659 );
660 }
661
662 #[test]
663 fn test_error_stats_set_fatal_and_has_fatal() {
664 let mut s = ErrorStats::default();
665 assert!(!s.has_fatal());
666 s.set_fatal("catastrophic failure".into());
667 assert!(s.has_fatal(), "has_fatal should be true after set_fatal");
668 assert_eq!(s.fatal_error.as_deref(), Some("catastrophic failure"));
669 }
670
671 #[test]
672 fn test_error_stats_merge_first_fatal_wins() {
673 let mut a = ErrorStats::default();
674 a.set_fatal("first".into());
675 let mut b = ErrorStats::default();
676 b.set_fatal("second".into());
677 a.merge(&b);
678 assert_eq!(
679 a.fatal_error.as_deref(),
680 Some("first"),
681 "merge must not overwrite an existing fatal_error"
682 );
683 }
684
685 #[test]
686 fn test_error_stats_merge_propagates_fatal_when_base_has_none() {
687 let mut a = ErrorStats::default();
688 let mut b = ErrorStats::default();
689 b.set_fatal("only".into());
690 a.merge(&b);
691 assert_eq!(
692 a.fatal_error.as_deref(),
693 Some("only"),
694 "merge should propagate fatal_error when base has none"
695 );
696 }
697
698 #[test]
699 fn test_error_stats_merge_accumulates_counts() {
700 let mut a = ErrorStats::default();
701 a.add_parse_error();
702 a.add_parse_error();
703 let mut b = ErrorStats::default();
704 b.add_export_error();
705 a.merge(&b);
706 assert_eq!(a.total_errors, 3, "merge should sum total_errors");
707 assert_eq!(
708 a.parse_errors, 2,
709 "parse_errors should carry over from base"
710 );
711 assert_eq!(a.export_errors, 1, "export_errors should add from other");
712 }
713
714 #[test]
715 fn test_error_stats_default_has_no_errors() {
716 let s = ErrorStats::default();
717 assert!(!s.has_errors(), "default ErrorStats should have no errors");
718 assert!(!s.has_fatal(), "default ErrorStats should have no fatal");
719 }
720
721 #[test]
724 fn test_error_severity_display_strings() {
725 assert_eq!(
726 format!("{}", ErrorSeverity::Warning),
727 "WARNING",
728 "ErrorSeverity::Warning should display as 'WARNING'"
729 );
730 assert_eq!(
731 format!("{}", ErrorSeverity::Error),
732 "ERROR",
733 "ErrorSeverity::Error should display as 'ERROR'"
734 );
735 assert_eq!(
736 format!("{}", ErrorSeverity::Critical),
737 "CRITICAL",
738 "ErrorSeverity::Critical should display as 'CRITICAL'"
739 );
740 }
741
742 #[test]
745 fn test_classify_error_kind() {
746 assert_eq!(
747 classify_error_kind("含\u{FFFD}字符"),
748 ErrorKind::EncodingError,
749 "含 replacement char 应分类为 EncodingError"
750 );
751 assert_eq!(
752 classify_error_kind("(EP[ 不完整"),
753 ErrorKind::FieldMissing,
754 "以 (EP[ 开头应分类为 FieldMissing"
755 );
756 assert_eq!(
757 classify_error_kind("普通乱码"),
758 ErrorKind::ParseFailed,
759 "其他情况应分类为 ParseFailed"
760 );
761 }
762
763 #[test]
766 fn test_truncate_to_120_chars() {
767 let long_ascii = "a".repeat(200);
769 let truncated = truncate_to_120_chars(&long_ascii);
770 assert_eq!(truncated.len(), 120, "ASCII 200 字符应截断为 120 字节");
771
772 let long_chinese = "中".repeat(150);
774 let truncated_chinese = truncate_to_120_chars(&long_chinese);
775 let char_count = truncated_chinese.chars().count();
776 assert_eq!(char_count, 120, "中文应截断为 120 字符");
777 assert!(
778 std::str::from_utf8(truncated_chinese.as_bytes()).is_ok(),
779 "截断后应为合法 UTF-8"
780 );
781
782 let empty = truncate_to_120_chars("");
784 assert!(empty.is_empty(), "空串截断后应为空串");
785
786 let short = "b".repeat(100);
788 let result = truncate_to_120_chars(&short);
789 assert_eq!(result, short, "100 字符不超过上限,应原样返回");
790 }
791
792 #[test]
795 fn test_error_stats_by_type_merge() {
796 let mut a = ErrorStats::default();
797 *a.by_type.entry(ErrorKind::EncodingError).or_insert(0) += 2;
798
799 let mut b = ErrorStats::default();
800 *b.by_type.entry(ErrorKind::EncodingError).or_insert(0) += 3;
801 *b.by_type.entry(ErrorKind::FieldMissing).or_insert(0) += 1;
802
803 a.merge(&b);
804
805 assert_eq!(
806 a.by_type
807 .get(&ErrorKind::EncodingError)
808 .copied()
809 .unwrap_or(0),
810 5,
811 "merge 后 EncodingError 计数应为 5"
812 );
813 assert_eq!(
814 a.by_type
815 .get(&ErrorKind::FieldMissing)
816 .copied()
817 .unwrap_or(0),
818 1,
819 "merge 后 FieldMissing 计数应为 1"
820 );
821 }
822
823 #[test]
824 fn test_error_stats_merge_propagates_filtered_and_records() {
825 let mut a = ErrorStats {
826 filtered_out: 2,
827 parse_error_records: vec![ParseErrorRecord {
828 line_number: 1,
829 raw_truncated: "raw_a".into(),
830 kind: ErrorKind::ParseFailed,
831 }],
832 ..Default::default()
833 };
834
835 let b = ErrorStats {
836 filtered_out: 5,
837 parse_error_records: vec![
838 ParseErrorRecord {
839 line_number: 2,
840 raw_truncated: "raw_b1".into(),
841 kind: ErrorKind::EncodingError,
842 },
843 ParseErrorRecord {
844 line_number: 3,
845 raw_truncated: "raw_b2".into(),
846 kind: ErrorKind::FieldMissing,
847 },
848 ],
849 ..Default::default()
850 };
851
852 a.merge(&b);
853
854 assert_eq!(a.filtered_out, 7, "merge 后 filtered_out 应为 7");
855 assert_eq!(
856 a.parse_error_records.len(),
857 3,
858 "merge 后 parse_error_records 应有 3 条"
859 );
860 }
861}