1use miette::Diagnostic;
2use thiserror::Error;
3
4#[derive(Debug, Error, Diagnostic)]
6pub enum Error {
7 #[error("policy violation: {reason}")]
9 #[diagnostic(code(dns::policy), help("{hint}"))]
10 PolicyViolation { reason: String, hint: String },
11
12 #[error("API error: {message}")]
14 #[diagnostic(
15 code(dns::api),
16 help(
17 "Check the Technitium server logs for more details.\n\
18 Common causes: invalid zone name, record conflict, insufficient permissions."
19 )
20 )]
21 Api { message: String },
22
23 #[error("HTTP {status}: {body}")]
25 #[diagnostic(
26 code(dns::http),
27 help(
28 "Verify the server is running and TECHNITIUM_BASE_URL is correct.\n\
29 Use RUST_LOG=debug for full request details."
30 )
31 )]
32 Http { status: u16, body: String },
33
34 #[error("network error: {0}")]
36 #[diagnostic(
37 code(dns::network),
38 help(
39 "Check that the server is reachable at the configured base URL.\n\
40 If using TLS, verify the certificate is trusted."
41 )
42 )]
43 Network(#[source] reqwest::Error),
44
45 #[error("invalid JSON response from server")]
47 #[diagnostic(
48 code(dns::invalid_json),
49 help(
50 "The server returned a response that isn't valid JSON.\n\
51 Verify the base URL points to the API, not a proxy or redirect."
52 )
53 )]
54 InvalidJson(#[source] reqwest::Error),
55
56 #[error("parse error: {context}")]
58 #[diagnostic(
59 code(dns::parse),
60 help(
61 "The API response had an unexpected structure. This may indicate a \
62 version mismatch between this client and the Technitium server."
63 )
64 )]
65 Parse { context: String },
66
67 #[error("config error: {context}")]
69 #[diagnostic(
70 code(dns::config),
71 help(
72 "Check the config file syntax and field names.\n\
73 Run `dns config print` to inspect the parsed result, or\n\
74 `dns config init` to regenerate a starter template."
75 )
76 )]
77 Config { context: String },
78
79 #[error("invalid MIME type")]
81 #[diagnostic(code(dns::mime))]
82 Mime(#[source] reqwest::Error),
83
84 #[error("operation not supported by {vendor}: {feature}")]
86 #[diagnostic(
87 code(dns::unsupported),
88 help("This vendor does not support this operation.")
89 )]
90 Unsupported {
91 vendor: &'static str,
92 feature: &'static str,
93 },
94
95 #[error("forbidden: {message}")]
97 #[diagnostic(
98 code(dns::forbidden),
99 help(
100 "The API key does not have sufficient permissions.\n\
101 Check that the token has the access level required for this operation."
102 )
103 )]
104 Forbidden { message: String },
105
106 #[error("{context}")]
108 #[diagnostic(code(dns::io), help("Check that the file exists and is readable."))]
109 Io {
110 context: String,
111 #[source]
112 source: std::io::Error,
113 },
114
115 #[error("operation cancelled by user")]
117 #[diagnostic(
118 code(dns::cancelled),
119 help("The operation was interrupted before completion. No changes were made.")
120 )]
121 UserCancelled,
122
123 #[error("MCP error: {context}")]
125 #[diagnostic(
126 code(dns::mcp),
127 help(
128 "Check that the MCP transport (stdio) is wired up correctly and \
129 that the configured DNS servers are reachable."
130 )
131 )]
132 Mcp { context: String },
133}
134
135impl Error {
136 pub fn is_transient(&self) -> bool {
138 if let Self::Network(e) = self {
139 return e.is_timeout() || e.is_connect();
140 }
141 false
142 }
143
144 pub fn is_api_error(&self) -> bool {
146 matches!(self, Self::Api { .. })
147 }
148
149 pub fn exit_code(&self) -> i32 {
151 match self {
152 Self::PolicyViolation { .. } => 6,
153 Self::Api { .. } => 2,
154 Self::Http { .. } => 3,
155 Self::Network(_) => 4,
156 Self::Io { .. } => 5,
157 Self::Unsupported { .. } => 7,
158 Self::Forbidden { .. } => 8,
159 Self::UserCancelled => 130,
160 Self::Mcp { .. } => 1,
161 _ => 1,
162 }
163 }
164
165 pub fn policy_violation(reason: impl Into<String>, hint: impl Into<String>) -> Self {
168 Self::PolicyViolation {
169 reason: reason.into(),
170 hint: hint.into(),
171 }
172 }
173
174 pub fn api(message: impl Into<String>) -> Self {
175 Self::Api {
176 message: message.into(),
177 }
178 }
179
180 pub fn parse(context: impl Into<String>) -> Self {
181 Self::Parse {
182 context: context.into(),
183 }
184 }
185
186 pub fn config(context: impl Into<String>) -> Self {
187 Self::Config {
188 context: context.into(),
189 }
190 }
191
192 pub fn io(context: impl Into<String>, source: std::io::Error) -> Self {
193 Self::Io {
194 context: context.into(),
195 source,
196 }
197 }
198
199 pub fn unsupported(vendor: &'static str, feature: &'static str) -> Self {
200 Self::Unsupported { vendor, feature }
201 }
202
203 pub fn forbidden(message: impl Into<String>) -> Self {
204 Self::Forbidden {
205 message: message.into(),
206 }
207 }
208
209 pub fn cancelled() -> Self {
210 Self::UserCancelled
211 }
212
213 pub fn mcp(context: impl Into<String>) -> Self {
214 Self::Mcp {
215 context: context.into(),
216 }
217 }
218}
219
220pub type Result<T> = std::result::Result<T, Error>;
222
223#[cfg(test)]
226mod tests {
227 use super::*;
228 use rstest::{fixture, rstest};
229
230 #[fixture]
231 fn api_error() -> Error {
232 Error::api("zone not found")
233 }
234
235 #[fixture]
236 fn io_error() -> Error {
237 Error::io(
238 "reading zone file 'example.zone'",
239 std::io::Error::from(std::io::ErrorKind::NotFound),
240 )
241 }
242
243 #[rstest]
246 fn api_error_display_includes_message(api_error: Error) {
247 assert_eq!(api_error.to_string(), "API error: zone not found");
248 }
249
250 #[rstest]
251 fn http_error_display_includes_status() {
252 let e = Error::Http {
253 status: 403,
254 body: r#"{"detail":"forbidden"}"#.into(),
255 };
256 assert!(e.to_string().contains("403"));
257 }
258
259 #[rstest]
260 fn parse_error_display_includes_context() {
261 let e = Error::parse("could not parse list_records for 'example.com'");
262 assert!(e.to_string().contains("example.com"));
263 }
264
265 #[rstest]
266 fn io_error_display_includes_context(io_error: Error) {
267 assert!(io_error.to_string().contains("example.zone"));
268 }
269
270 #[rstest]
273 fn api_error_has_diagnostic_code(api_error: Error) {
274 let code = api_error.code().expect("should have a code");
275 assert_eq!(code.to_string(), "dns::api");
276 }
277
278 #[rstest]
279 #[case::http(Error::Http { status: 500, body: "".into() }, "dns::http")]
280 #[case::parse(Error::Parse { context: "x".into() }, "dns::parse")]
281 #[case::io(Error::Io { context: "x".into(), source: std::io::Error::from(std::io::ErrorKind::NotFound) }, "dns::io")]
282 fn diagnostic_codes_are_correct(#[case] e: Error, #[case] expected: &str) {
283 let code = e.code().expect("should have a code");
284 assert_eq!(code.to_string(), expected);
285 }
286
287 #[rstest]
290 fn api_error_has_help_text(api_error: Error) {
291 assert!(api_error.help().is_some());
292 }
293
294 #[rstest]
295 fn io_error_has_help_text(io_error: Error) {
296 let help = io_error.help().expect("should have help");
297 assert!(help.to_string().contains("readable"));
298 }
299
300 #[rstest]
303 fn api_error_is_api_error(api_error: Error) {
304 assert!(api_error.is_api_error());
305 }
306
307 #[rstest]
308 #[case(Error::Http { status: 500, body: "".into() })]
309 #[case(Error::Parse { context: "bad".into() })]
310 #[case(Error::Io { context: "x".into(), source: std::io::Error::from(std::io::ErrorKind::NotFound) })]
311 fn non_api_errors_are_not_api_errors(#[case] e: Error) {
312 assert!(!e.is_api_error());
313 }
314
315 #[rstest]
318 #[case::api(Error::Api { message: "x".into() }, 2)]
319 #[case::http(Error::Http { status: 500, body: "".into() }, 3)]
320 #[case::parse(Error::Parse { context: "x".into() }, 1)]
321 #[case::io(Error::Io { context: "x".into(), source: std::io::Error::from(std::io::ErrorKind::NotFound) }, 5)]
322 #[case::cancelled(Error::UserCancelled, 130)]
323 #[case::mcp(Error::Mcp { context: "transport".into() }, 1)]
324 fn exit_code_by_variant(#[case] e: Error, #[case] expected: i32) {
325 assert_eq!(e.exit_code(), expected);
326 }
327
328 #[rstest]
331 fn api_constructor_sets_message() {
332 let e = Error::api("access denied");
333 assert!(matches!(e, Error::Api { ref message } if message == "access denied"));
334 }
335
336 #[rstest]
337 fn parse_constructor_sets_context() {
338 let e = Error::parse("bad response shape");
339 assert!(matches!(e, Error::Parse { ref context } if context == "bad response shape"));
340 }
341
342 #[rstest]
343 fn io_constructor_sets_context(io_error: Error) {
344 assert!(
345 matches!(io_error, Error::Io { ref context, .. } if context.contains("example.zone"))
346 );
347 }
348
349 #[rstest]
350 fn cancelled_constructor_returns_user_cancelled_variant() {
351 assert!(matches!(Error::cancelled(), Error::UserCancelled));
352 }
353
354 #[rstest]
355 fn mcp_constructor_sets_context() {
356 let e = Error::mcp("transport closed");
357 assert!(matches!(e, Error::Mcp { ref context } if context == "transport closed"));
358 }
359
360 #[rstest]
361 fn cancelled_has_diagnostic_code() {
362 let e = Error::UserCancelled;
363 let code = e.code().expect("should have a code");
364 assert_eq!(code.to_string(), "dns::cancelled");
365 }
366
367 #[rstest]
368 fn mcp_has_diagnostic_code() {
369 let e = Error::mcp("x");
370 let code = e.code().expect("should have a code");
371 assert_eq!(code.to_string(), "dns::mcp");
372 }
373}