1use std::fmt;
4use std::io;
5use std::path::PathBuf;
6use thiserror::Error;
7
8pub type Result<T> = std::result::Result<T, Error>;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ErrorSeverity {
13 Warning,
14 Error,
15 Critical,
16}
17
18impl fmt::Display for ErrorSeverity {
19 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20 match self {
21 Self::Warning => write!(f, "WARNING"),
22 Self::Error => write!(f, "ERROR"),
23 Self::Critical => write!(f, "CRITICAL"),
24 }
25 }
26}
27
28#[derive(Debug, Error)]
29pub enum Error {
30 #[error("Configuration error: {0}")]
31 Config(#[from] ConfigError),
32
33 #[error("File error: {0}")]
34 File(#[from] FileError),
35
36 #[error("SQL log parser error: {0}")]
37 Parser(#[from] ParserError),
38
39 #[error("Export error: {0}")]
40 Export(#[from] ExportError),
41
42 #[error("IO error: {0}")]
43 Io(#[from] io::Error),
44
45 #[error("Interrupted by user")]
46 Interrupted,
47}
48
49impl Error {
50 #[must_use]
51 pub fn is_fatal(&self) -> bool {
52 match self {
53 Error::Config(_) | Error::Io(_) | Error::Interrupted => true,
54 Error::File(e) => matches!(
55 e,
56 FileError::AlreadyExists { .. } | FileError::CreateDirectoryFailed { .. }
57 ),
58 Error::Parser(e) => matches!(e, ParserError::ReadDirFailed { .. }),
59 Error::Export(e) => matches!(e, ExportError::Fatal { .. }),
60 }
61 }
62
63 #[must_use]
64 pub fn severity(&self) -> ErrorSeverity {
65 match self {
66 Error::Config(_) | Error::Io(_) | Error::Interrupted => ErrorSeverity::Critical,
67 Error::File(e) => match e {
68 FileError::WriteFailed { .. } => ErrorSeverity::Error,
69 FileError::AlreadyExists { .. } | FileError::CreateDirectoryFailed { .. } => {
70 ErrorSeverity::Critical
71 }
72 },
73 Error::Parser(_) => ErrorSeverity::Warning,
74 Error::Export(e) => match e {
75 ExportError::WriteFailed { .. } => ErrorSeverity::Error,
76 ExportError::Fatal { .. } => ErrorSeverity::Critical,
77 },
78 }
79 }
80
81 #[must_use]
82 pub fn suggestion(&self) -> &str {
83 match self {
84 Error::Config(e) => match e {
85 ConfigError::NotFound(_) => {
86 "Create a config file with 'sqllog2db init' or check the file path."
87 }
88 ConfigError::ParseFailed { .. } => "Check TOML syntax in the configuration file.",
89 ConfigError::InvalidLogLevel { .. } => {
90 "Valid log levels: error, warn, info, debug, trace."
91 }
92 ConfigError::InvalidValue { .. } => {
93 "Check the field value in the configuration file."
94 }
95 ConfigError::NoExporters => {
96 "Enable at least one exporter: [exporter.parquet] or [exporter.csv]."
97 }
98 },
99 Error::File(e) => match e {
100 FileError::AlreadyExists { .. } => {
101 "Use --force to overwrite, or choose a different output path."
102 }
103 FileError::WriteFailed { .. } => "Check disk space and file permissions.",
104 FileError::CreateDirectoryFailed { .. } => "Check parent directory permissions.",
105 },
106 Error::Parser(e) => match e {
107 ParserError::PathNotFound { .. } => {
108 "Verify the log file exists at the specified path."
109 }
110 ParserError::InvalidPath { .. } => "Check the path format or try an absolute path.",
111 ParserError::ReadDirFailed { .. } => "Check directory permissions.",
112 ParserError::NoFilesFound { .. } => {
113 "Verify the glob/path entries exist; ensure patterns match .log files in the current directory."
114 }
115 },
116 Error::Export(e) => match e {
117 ExportError::WriteFailed { .. } => {
118 "Check disk space and output directory permissions."
119 }
120 ExportError::Fatal { .. } => "Check the output file and export configuration.",
121 },
122 Error::Io(_) => "Check filesystem permissions and disk space.",
123 Error::Interrupted => "Run was interrupted by user.",
124 }
125 }
126}
127
128#[derive(Debug, Error)]
129pub enum ConfigError {
130 #[error("Configuration file not found: {0}")]
131 NotFound(PathBuf),
132
133 #[error("Failed to parse configuration file {path}: {reason}")]
134 ParseFailed { path: PathBuf, reason: String },
135
136 #[error("Invalid log level '{level}', valid values: {}", valid_levels.join(", "))]
137 InvalidLogLevel {
138 level: String,
139 valid_levels: Vec<String>,
140 },
141
142 #[error("Invalid configuration value {field} = '{value}': {reason}")]
143 InvalidValue {
144 field: String,
145 value: String,
146 reason: String,
147 },
148
149 #[error("At least one exporter must be configured (parquet/csv)")]
150 NoExporters,
151}
152
153#[derive(Debug, Error)]
154pub enum FileError {
155 #[error("File already exists: {path} (set overwrite=true to replace)")]
156 AlreadyExists { path: PathBuf },
157
158 #[error("Failed to write file {path}: {reason}")]
159 WriteFailed { path: PathBuf, reason: String },
160
161 #[error("Failed to create directory {path}: {reason}")]
162 CreateDirectoryFailed { path: PathBuf, reason: String },
163}
164
165#[derive(Debug, Error)]
166pub enum ParserError {
167 #[error("Path not found: {}", path.display())]
168 PathNotFound { path: PathBuf },
169
170 #[error("Invalid path {}: {reason}{}", path.display(), line_number.map_or_else(String::new, |n| format!(" (line {n})")))]
171 InvalidPath {
172 path: PathBuf,
173 reason: String,
174 line_number: Option<u64>,
175 },
176
177 #[error("Failed to read directory {}: {reason}", path.display())]
178 ReadDirFailed { path: PathBuf, reason: String },
179
180 #[error("No log files found matching inputs: {inputs:?}")]
181 NoFilesFound { inputs: Vec<String> },
182}
183
184#[derive(Debug, Error)]
185pub enum ExportError {
186 #[error("Write failed {path}: {reason}")]
188 WriteFailed { path: PathBuf, reason: String },
189
190 #[error("Export failed: {reason}")]
192 Fatal { reason: String },
193}
194
195#[cfg(test)]
196#[path = "../tests/unit/error.rs"]
197mod tests;
198
199use std::collections::HashMap;
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203pub enum ErrorKind {
204 EncodingError,
205 FieldMissing,
206 ParseFailed,
207}
208
209impl ErrorKind {
210 #[must_use]
211 pub fn kind_display(self) -> &'static str {
212 match self {
213 Self::EncodingError => "encoding_error",
214 Self::FieldMissing => "field_missing",
215 Self::ParseFailed => "parse_failed",
216 }
217 }
218}
219
220#[derive(Debug, Clone)]
222pub struct ParseErrorRecord {
223 pub line_number: u64,
224 pub raw_truncated: String,
225 pub kind: ErrorKind,
226}
227
228#[derive(Debug, Default, Clone)]
230pub struct ErrorStats {
231 pub total_errors: usize,
232 pub parse_errors: usize,
233 pub export_errors: usize,
234 pub fatal_error: Option<String>,
235 pub by_type: HashMap<ErrorKind, u64>,
236 pub filtered_out: u64,
237 pub parse_error_records: Vec<ParseErrorRecord>,
238 pub records_exported: usize, }
240
241impl ErrorStats {
242 #[must_use]
243 pub fn has_errors(&self) -> bool {
244 self.total_errors > 0
245 }
246
247 #[must_use]
248 pub fn has_fatal(&self) -> bool {
249 self.fatal_error.is_some()
250 }
251
252 pub fn add_parse_error(&mut self) {
253 self.total_errors += 1;
254 self.parse_errors += 1;
255 }
256
257 pub fn add_export_error(&mut self) {
258 self.total_errors += 1;
259 self.export_errors += 1;
260 }
261
262 pub fn set_fatal(&mut self, msg: String) {
263 self.fatal_error = Some(msg);
264 }
265
266 pub fn merge(&mut self, other: &ErrorStats) {
267 const MAX_RECORDS: usize = 10_000;
268 self.total_errors += other.total_errors;
269 self.parse_errors += other.parse_errors;
270 self.export_errors += other.export_errors;
271 if self.fatal_error.is_none() && other.fatal_error.is_some() {
272 self.fatal_error.clone_from(&other.fatal_error);
273 }
274 for (kind, count) in &other.by_type {
275 *self.by_type.entry(*kind).or_insert(0) += count;
276 }
277 self.filtered_out += other.filtered_out;
278 self.records_exported += other.records_exported;
279 let remaining_cap = MAX_RECORDS.saturating_sub(self.parse_error_records.len());
280 if remaining_cap > 0 {
281 self.parse_error_records.extend(
282 other
283 .parse_error_records
284 .iter()
285 .take(remaining_cap)
286 .cloned(),
287 );
288 }
289 }
290}