Skip to main content

dnslib/core/
error.rs

1use miette::Diagnostic;
2use thiserror::Error;
3
4/// All errors that can be produced by this crate.
5#[derive(Debug, Error, Diagnostic)]
6pub enum Error {
7    /// An operation was blocked by the active server policy (read-only or zone restriction).
8    #[error("policy violation: {reason}")]
9    #[diagnostic(code(dns::policy), help("{hint}"))]
10    PolicyViolation { reason: String, hint: String },
11
12    /// The Technitium API returned `{"status":"error","errorMessage":"..."}`.
13    #[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    /// The server returned a non-2xx HTTP status with no API-level error body.
24    #[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    /// A network-level failure — connection refused, timeout, DNS resolution, etc.
35    #[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    /// The HTTP response body could not be decoded as JSON.
46    #[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    /// A well-formed API response could not be parsed into the expected shape.
57    #[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    /// The config file could not be read or is structurally invalid.
68    #[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    /// A MIME type string was rejected by reqwest (should never happen in practice).
80    #[error("invalid MIME type")]
81    #[diagnostic(code(dns::mime))]
82    Mime(#[source] reqwest::Error),
83
84    /// An operation not supported by this vendor backend.
85    #[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    /// The API token lacks the required permissions (HTTP 403).
96    #[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    /// An I/O error — typically reading a zone file from disk.
107    #[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    /// The user cancelled an interactive operation (Ctrl-C, Esc, etc.).
116    #[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    /// An MCP transport or server-startup failure.
124    #[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    /// True for transient failures the user might retry (network, timeout).
137    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    /// True when the server explicitly rejected the request.
145    pub fn is_api_error(&self) -> bool {
146        matches!(self, Self::Api { .. })
147    }
148
149    /// Suggested process exit code for CLI use.
150    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    // ── Constructors ──────────────────────────────────────────────────────────
166
167    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
220/// Convenience alias used throughout the crate.
221pub type Result<T> = std::result::Result<T, Error>;
222
223// ─── Tests ────────────────────────────────────────────────────────────────────
224
225#[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    // ── Display format ────────────────────────────────────────────────────────
244
245    #[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    // ── Diagnostic codes ─────────────────────────────────────────────────────
271
272    #[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    // ── Help text ─────────────────────────────────────────────────────────────
288
289    #[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    // ── is_api_error ──────────────────────────────────────────────────────────
301
302    #[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    // ── exit_code ─────────────────────────────────────────────────────────────
316
317    #[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    // ── constructors ──────────────────────────────────────────────────────────
329
330    #[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}