1use std::fmt;
42use std::process::ExitCode;
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
49#[repr(u8)]
50pub enum ErrorCategory {
51 Internal = 1,
56
57 Usage = 2,
62
63 NotFound = 3,
67
68 InvalidQuery = 4,
72
73 Network = 5,
78
79 Timeout = 6,
83
84 Integrity = 7,
88}
89
90impl ErrorCategory {
91 #[must_use]
93 pub const fn exit_code(self) -> u8 {
94 self as u8
95 }
96
97 #[must_use]
99 pub fn as_exit_code(self) -> ExitCode {
100 ExitCode::from(self.exit_code())
101 }
102
103 #[must_use]
105 pub const fn description(self) -> &'static str {
106 match self {
107 Self::Internal => "internal error",
108 Self::Usage => "usage error",
109 Self::NotFound => "not found",
110 Self::InvalidQuery => "invalid query",
111 Self::Network => "network error",
112 Self::Timeout => "timeout",
113 Self::Integrity => "integrity error",
114 }
115 }
116
117 #[must_use]
122 pub fn infer_from_message(msg: &str) -> Self {
123 let msg_lower = msg.to_lowercase();
124
125 if msg_lower.contains("timeout") || msg_lower.contains("timed out") {
127 return Self::Timeout;
128 }
129
130 if msg_lower.contains("network")
132 || msg_lower.contains("connection")
133 || msg_lower.contains("dns")
134 || msg_lower.contains("http")
135 || msg_lower.contains("fetch")
136 || msg_lower.contains("unreachable")
137 {
138 return Self::Network;
139 }
140
141 if msg_lower.contains("not found")
143 || msg_lower.contains("no such")
144 || msg_lower.contains("does not exist")
145 || msg_lower.contains("unknown source")
146 || msg_lower.contains("source not found")
147 {
148 return Self::NotFound;
149 }
150
151 if msg_lower.contains("query")
153 || msg_lower.contains("invalid search")
154 || msg_lower.contains("parse error")
155 {
156 return Self::InvalidQuery;
157 }
158
159 if msg_lower.contains("corrupt")
161 || msg_lower.contains("integrity")
162 || msg_lower.contains("invalid index")
163 || msg_lower.contains("checksum")
164 {
165 return Self::Integrity;
166 }
167
168 if msg_lower.contains("invalid argument")
170 || msg_lower.contains("missing required")
171 || msg_lower.contains("invalid value")
172 || msg_lower.contains("cannot use")
173 {
174 return Self::Usage;
175 }
176
177 Self::Internal
179 }
180}
181
182impl fmt::Display for ErrorCategory {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 write!(f, "{}", self.description())
185 }
186}
187
188#[derive(Debug)]
210pub struct CliError {
211 pub category: ErrorCategory,
213 pub source: anyhow::Error,
215}
216
217impl CliError {
218 pub fn new(category: ErrorCategory, source: impl Into<anyhow::Error>) -> Self {
220 Self {
221 category,
222 source: source.into(),
223 }
224 }
225
226 pub fn inferred(source: impl Into<anyhow::Error>) -> Self {
228 let source = source.into();
229 let category = ErrorCategory::infer_from_message(&source.to_string());
230 Self { category, source }
231 }
232
233 pub fn internal(source: impl Into<anyhow::Error>) -> Self {
235 Self::new(ErrorCategory::Internal, source)
236 }
237
238 pub fn usage(source: impl Into<anyhow::Error>) -> Self {
240 Self::new(ErrorCategory::Usage, source)
241 }
242
243 pub fn not_found(source: impl Into<anyhow::Error>) -> Self {
245 Self::new(ErrorCategory::NotFound, source)
246 }
247
248 pub fn invalid_query(source: impl Into<anyhow::Error>) -> Self {
250 Self::new(ErrorCategory::InvalidQuery, source)
251 }
252
253 pub fn network(source: impl Into<anyhow::Error>) -> Self {
255 Self::new(ErrorCategory::Network, source)
256 }
257
258 pub fn timeout(source: impl Into<anyhow::Error>) -> Self {
260 Self::new(ErrorCategory::Timeout, source)
261 }
262
263 pub fn integrity(source: impl Into<anyhow::Error>) -> Self {
265 Self::new(ErrorCategory::Integrity, source)
266 }
267
268 #[must_use]
270 pub const fn exit_code(&self) -> u8 {
271 self.category.exit_code()
272 }
273
274 #[must_use]
276 pub fn as_exit_code(&self) -> ExitCode {
277 self.category.as_exit_code()
278 }
279}
280
281impl fmt::Display for CliError {
282 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283 write!(f, "{}", self.source)
284 }
285}
286
287impl std::error::Error for CliError {
288 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
289 Some(self.source.as_ref())
290 }
291}
292
293pub trait IntoCliError {
295 fn into_cli_error(self) -> CliError;
297
298 fn with_category(self, category: ErrorCategory) -> CliError;
300}
301
302impl<E: Into<anyhow::Error>> IntoCliError for E {
303 fn into_cli_error(self) -> CliError {
304 CliError::inferred(self)
305 }
306
307 fn with_category(self, category: ErrorCategory) -> CliError {
308 CliError::new(category, self)
309 }
310}
311
312#[must_use]
317pub fn exit_code_from_error(err: &anyhow::Error) -> u8 {
318 if let Some(cli_err) = err.downcast_ref::<CliError>() {
320 return cli_err.exit_code();
321 }
322
323 ErrorCategory::infer_from_message(&err.to_string()).exit_code()
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use anyhow::anyhow;
331
332 mod error_category {
333 use super::*;
334
335 #[test]
336 fn test_exit_codes() {
337 assert_eq!(ErrorCategory::Internal.exit_code(), 1);
338 assert_eq!(ErrorCategory::Usage.exit_code(), 2);
339 assert_eq!(ErrorCategory::NotFound.exit_code(), 3);
340 assert_eq!(ErrorCategory::InvalidQuery.exit_code(), 4);
341 assert_eq!(ErrorCategory::Network.exit_code(), 5);
342 assert_eq!(ErrorCategory::Timeout.exit_code(), 6);
343 assert_eq!(ErrorCategory::Integrity.exit_code(), 7);
344 }
345
346 #[test]
347 fn test_infer_network() {
348 assert_eq!(
349 ErrorCategory::infer_from_message("Connection refused"),
350 ErrorCategory::Network
351 );
352 assert_eq!(
353 ErrorCategory::infer_from_message("HTTP 500 error"),
354 ErrorCategory::Network
355 );
356 assert_eq!(
357 ErrorCategory::infer_from_message("Failed to fetch URL"),
358 ErrorCategory::Network
359 );
360 }
361
362 #[test]
363 fn test_infer_timeout() {
364 assert_eq!(
365 ErrorCategory::infer_from_message("Operation timed out"),
366 ErrorCategory::Timeout
367 );
368 assert_eq!(
369 ErrorCategory::infer_from_message("Request timeout after 30s"),
370 ErrorCategory::Timeout
371 );
372 }
373
374 #[test]
375 fn test_infer_not_found() {
376 assert_eq!(
377 ErrorCategory::infer_from_message("Source not found: react"),
378 ErrorCategory::NotFound
379 );
380 assert_eq!(
381 ErrorCategory::infer_from_message("No such file or directory"),
382 ErrorCategory::NotFound
383 );
384 assert_eq!(
385 ErrorCategory::infer_from_message("Unknown source 'test'"),
386 ErrorCategory::NotFound
387 );
388 }
389
390 #[test]
391 fn test_infer_query() {
392 assert_eq!(
393 ErrorCategory::infer_from_message("Invalid query syntax"),
394 ErrorCategory::InvalidQuery
395 );
396 assert_eq!(
397 ErrorCategory::infer_from_message("Query parse error at position 5"),
398 ErrorCategory::InvalidQuery
399 );
400 }
401
402 #[test]
403 fn test_infer_integrity() {
404 assert_eq!(
405 ErrorCategory::infer_from_message("Index corrupted"),
406 ErrorCategory::Integrity
407 );
408 assert_eq!(
409 ErrorCategory::infer_from_message("Checksum mismatch"),
410 ErrorCategory::Integrity
411 );
412 }
413
414 #[test]
415 fn test_infer_usage() {
416 assert_eq!(
417 ErrorCategory::infer_from_message("Invalid argument: --foo"),
418 ErrorCategory::Usage
419 );
420 assert_eq!(
421 ErrorCategory::infer_from_message("Missing required field"),
422 ErrorCategory::Usage
423 );
424 }
425
426 #[test]
427 fn test_infer_default() {
428 assert_eq!(
429 ErrorCategory::infer_from_message("Something went wrong"),
430 ErrorCategory::Internal
431 );
432 }
433 }
434
435 mod cli_error {
436 use super::*;
437
438 #[test]
439 fn test_new() {
440 let err = CliError::new(ErrorCategory::NotFound, anyhow!("Source not found"));
441 assert_eq!(err.category, ErrorCategory::NotFound);
442 assert_eq!(err.exit_code(), 3);
443 }
444
445 #[test]
446 fn test_inferred() {
447 let err = CliError::inferred(anyhow!("Connection refused"));
448 assert_eq!(err.category, ErrorCategory::Network);
449 }
450
451 #[test]
452 fn test_convenience_constructors() {
453 assert_eq!(
454 CliError::internal(anyhow!("err")).category,
455 ErrorCategory::Internal
456 );
457 assert_eq!(
458 CliError::usage(anyhow!("err")).category,
459 ErrorCategory::Usage
460 );
461 assert_eq!(
462 CliError::not_found(anyhow!("err")).category,
463 ErrorCategory::NotFound
464 );
465 assert_eq!(
466 CliError::invalid_query(anyhow!("err")).category,
467 ErrorCategory::InvalidQuery
468 );
469 assert_eq!(
470 CliError::network(anyhow!("err")).category,
471 ErrorCategory::Network
472 );
473 assert_eq!(
474 CliError::timeout(anyhow!("err")).category,
475 ErrorCategory::Timeout
476 );
477 assert_eq!(
478 CliError::integrity(anyhow!("err")).category,
479 ErrorCategory::Integrity
480 );
481 }
482
483 #[test]
484 fn test_display() {
485 let err = CliError::not_found(anyhow!("Source 'react' not found"));
486 assert_eq!(err.to_string(), "Source 'react' not found");
487 }
488 }
489
490 mod exit_code_from_error {
491 use super::*;
492
493 #[test]
494 fn test_cli_error() {
495 let cli_err = CliError::not_found(anyhow!("Not found"));
496 let err: anyhow::Error = cli_err.into();
497 assert_eq!(exit_code_from_error(&err), 3);
498 }
499
500 #[test]
501 fn test_regular_error() {
502 let err = anyhow!("Operation timed out");
505 assert_eq!(exit_code_from_error(&err), 6);
506 }
507 }
508
509 mod into_cli_error {
510 use super::*;
511
512 #[test]
513 fn test_into_cli_error() {
514 let err = anyhow!("Source not found").into_cli_error();
515 assert_eq!(err.category, ErrorCategory::NotFound);
516 }
517
518 #[test]
519 fn test_with_category() {
520 let err = anyhow!("Something failed").with_category(ErrorCategory::Timeout);
521 assert_eq!(err.category, ErrorCategory::Timeout);
522 }
523 }
524}