1use 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#[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#[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 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}