Skip to main content

dm_database_sqllog2db/
error.rs

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/// Error severity for fatal/non-fatal classification.
10#[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/// 解析错误的分类枚举。
28#[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/// 单条解析错误的详细记录。
47#[derive(Debug, Clone)]
48pub struct ParseErrorRecord {
49    pub line_number: u64,
50    pub raw_truncated: String,
51    pub kind: ErrorKind,
52}
53
54/// 将原始字符串安全截断到前 120 个字符(UTF-8 字符边界安全)。
55#[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/// 根据原始内容启发式分类解析错误类型。
65#[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/// Accumulated error statistics for a processing run.
77#[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, // 累计成功导出/处理的记录数(D-09,watch 模式累计统计用)
87}
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    /// 文件写入失败(CSV、错误日志等所有文件型导出器通用)
304    #[error("Write failed {path}: {reason}")]
305    WriteFailed { path: PathBuf, reason: String },
306
307    /// `SQLite` 操作失败
308    #[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    // ===== ConfigError 全 5 个变体 =====
362
363    #[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    // ===== FileError 全 3 个变体 =====
442
443    #[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    // ===== ExportError 全 2 个变体 =====
506
507    #[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    // ===== Error::Io 和 Error::Interrupted =====
551
552    #[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    // ===== ParserError 3 个变体(不含已覆盖的 NoFilesFound)=====
585
586    #[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    // ===== ErrorStats =====
643
644    #[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    // ===== ErrorSeverity Display =====
722
723    #[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    // ===== ErrorKind 分类 =====
743
744    #[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    // ===== truncate_to_120_chars =====
764
765    #[test]
766    fn test_truncate_to_120_chars() {
767        // ASCII 长度 200 → 输出长度 120
768        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        // "中" 重复 150 次(每字符 3 字节)→ 输出 char_count == 120 且 UTF-8 有效
773        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        // 空串 → 空串
783        let empty = truncate_to_120_chars("");
784        assert!(empty.is_empty(), "空串截断后应为空串");
785
786        // 100 字符 → 原样返回
787        let short = "b".repeat(100);
788        let result = truncate_to_120_chars(&short);
789        assert_eq!(result, short, "100 字符不超过上限,应原样返回");
790    }
791
792    // ===== ErrorStats by_type merge =====
793
794    #[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}