Skip to main content

nyx_scanner/
errors.rs

1//! Error types used throughout the scanner.
2//!
3//! [`NyxError`] wraps I/O, TOML parse, SQLite, tree-sitter, and connection-pool
4//! errors into a single enum. [`NyxResult<T>`] is the standard return type alias.
5//!
6//! [`ConfigError`] and [`ConfigErrorKind`] carry structured config-validation
7//! diagnostics (section, field, message, kind) so callers can format them
8//! consistently without ad-hoc string matching.
9
10use serde::Serialize;
11use serde::de::StdError;
12use std::fmt;
13use std::sync::PoisonError;
14use thiserror::Error;
15
16pub type NyxResult<T, E = NyxError> = Result<T, E>;
17
18// ─── Config validation ──────────────────────────────────────────────────────
19
20/// A single config validation error with structured metadata.
21#[derive(Debug, Clone, Serialize)]
22pub struct ConfigError {
23    pub section: String,
24    pub field: String,
25    pub message: String,
26    pub kind: ConfigErrorKind,
27}
28
29impl fmt::Display for ConfigError {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        write!(f, "[{}.{}] {}", self.section, self.field, self.message)
32    }
33}
34
35/// Category of config validation error.
36#[derive(Debug, Clone, Serialize)]
37pub enum ConfigErrorKind {
38    OutOfRange,
39    InvalidValue,
40    EmptyRequired,
41    Conflict,
42}
43
44#[derive(Debug, Error)]
45pub enum NyxError {
46    #[error("I/O error: {0}")]
47    Io(#[from] std::io::Error),
48
49    #[error("TOML parse error: {0}")]
50    Toml(#[from] toml::de::Error),
51
52    #[error("SQLite error: {0}")]
53    Sql(#[from] rusqlite::Error),
54
55    #[error("tree-sitter error: {0}")]
56    TreeSitter(#[from] tree_sitter::LanguageError),
57
58    #[error("connection-pool error: {0}")]
59    Pool(#[from] r2d2::Error),
60
61    #[error("time error: {0}")]
62    Time(#[from] std::time::SystemTimeError),
63
64    #[error("poisoned lock: {0}")]
65    Poison(String),
66
67    #[error(transparent)]
68    Other(#[from] Box<dyn StdError + Send + Sync + 'static>),
69
70    #[error("{0}")]
71    Msg(String),
72
73    #[error("config validation failed:\n{}", .0.iter().map(|e| format!("  - {e}")).collect::<Vec<_>>().join("\n"))]
74    ConfigValidation(Vec<ConfigError>),
75}
76
77impl<T> From<PoisonError<T>> for NyxError
78where
79    T: fmt::Debug,
80{
81    fn from(err: PoisonError<T>) -> Self {
82        NyxError::Poison(err.to_string())
83    }
84}
85
86impl From<&str> for NyxError {
87    fn from(s: &str) -> Self {
88        NyxError::Msg(s.to_owned())
89    }
90}
91
92impl From<String> for NyxError {
93    fn from(s: String) -> Self {
94        NyxError::Msg(s)
95    }
96}
97
98impl From<Box<dyn std::error::Error>> for NyxError {
99    fn from(err: Box<dyn std::error::Error>) -> Self {
100        NyxError::Msg(err.to_string())
101    }
102}
103
104#[test]
105fn io_conversion_retains_message() {
106    let e = std::io::Error::other("boom!");
107    let n: NyxError = e.into();
108    assert!(matches!(n, NyxError::Io(_)));
109    assert!(n.to_string().contains("boom"));
110}
111
112#[test]
113fn poison_conversion_maps_correct_variant() {
114    let lock = std::sync::Arc::new(std::sync::Mutex::new(()));
115
116    {
117        let lock2 = std::sync::Arc::clone(&lock);
118        std::thread::spawn(move || {
119            let _guard = lock2.lock().unwrap();
120            panic!("intentional – poison the mutex");
121        })
122        .join()
123        .ok();
124    }
125
126    let poison = lock.lock().unwrap_err();
127    let nyx: NyxError = poison.into();
128
129    assert!(matches!(nyx, NyxError::Poison(_)));
130}
131
132#[test]
133fn simple_string_into_msg() {
134    let nyx: NyxError = "plain msg".into();
135    assert!(matches!(nyx, NyxError::Msg(s) if s == "plain msg"));
136}
137
138#[test]
139fn string_owned_into_msg() {
140    let s = String::from("owned message");
141    let nyx: NyxError = s.into();
142    assert!(matches!(nyx, NyxError::Msg(ref m) if m == "owned message"));
143    assert!(nyx.to_string().contains("owned message"));
144}
145
146#[test]
147fn box_dyn_error_into_msg() {
148    let boxed: Box<dyn std::error::Error> = Box::new(std::io::Error::other("inner error"));
149    let nyx: NyxError = boxed.into();
150    // The From<Box<dyn std::error::Error>> impl wraps as Msg
151    assert!(matches!(nyx, NyxError::Msg(_)));
152    assert!(nyx.to_string().contains("inner error"));
153}
154
155#[test]
156fn config_error_display_includes_section_field_and_message() {
157    let err = ConfigError {
158        section: "server".to_string(),
159        field: "port".to_string(),
160        message: "must be non-zero".to_string(),
161        kind: ConfigErrorKind::OutOfRange,
162    };
163    let s = err.to_string();
164    assert!(s.contains("server"), "should mention section: {s}");
165    assert!(s.contains("port"), "should mention field: {s}");
166    assert!(
167        s.contains("must be non-zero"),
168        "should mention message: {s}"
169    );
170}
171
172#[test]
173fn config_error_kind_debug_names() {
174    let kinds = [
175        ConfigErrorKind::OutOfRange,
176        ConfigErrorKind::InvalidValue,
177        ConfigErrorKind::EmptyRequired,
178        ConfigErrorKind::Conflict,
179    ];
180    let names = ["OutOfRange", "InvalidValue", "EmptyRequired", "Conflict"];
181    for (kind, name) in kinds.iter().zip(names.iter()) {
182        assert!(format!("{kind:?}").contains(name));
183    }
184}
185
186#[test]
187fn nyx_error_config_validation_display_lists_all_errors() {
188    let errs = vec![
189        ConfigError {
190            section: "scanner".to_string(),
191            field: "threads".to_string(),
192            message: "must be > 0".to_string(),
193            kind: ConfigErrorKind::OutOfRange,
194        },
195        ConfigError {
196            section: "output".to_string(),
197            field: "format".to_string(),
198            message: "unrecognised value".to_string(),
199            kind: ConfigErrorKind::InvalidValue,
200        },
201    ];
202    let nyx = NyxError::ConfigValidation(errs);
203    let s = nyx.to_string();
204    assert!(s.contains("scanner"), "should list first error: {s}");
205    assert!(s.contains("output"), "should list second error: {s}");
206    assert!(s.contains("must be > 0"), "should include message: {s}");
207}
208
209#[test]
210fn nyx_result_ok_variant_propagates_value() {
211    let value = 42;
212    let result: NyxResult<u32> = Ok(value);
213    match result {
214        Ok(actual) => assert_eq!(actual, value),
215        Err(err) => panic!("expected Ok result, got {err}"),
216    }
217}
218
219#[test]
220fn nyx_result_err_variant_contains_error() {
221    let message = "oops".to_string();
222    let result: NyxResult<u32> = Err(NyxError::Msg(message.clone()));
223    match result {
224        Ok(value) => panic!("expected Err result, got Ok({value})"),
225        Err(err) => assert!(err.to_string().contains(&message)),
226    }
227}