use miette::Diagnostic;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
pub enum Error {
#[error("policy violation: {reason}")]
#[diagnostic(code(dns::policy), help("{hint}"))]
PolicyViolation { reason: String, hint: String },
#[error("API error: {message}")]
#[diagnostic(
code(dns::api),
help(
"Check the Technitium server logs for more details.\n\
Common causes: invalid zone name, record conflict, insufficient permissions."
)
)]
Api { message: String },
#[error("HTTP {status}: {body}")]
#[diagnostic(
code(dns::http),
help(
"Verify the server is running and TECHNITIUM_BASE_URL is correct.\n\
Use RUST_LOG=debug for full request details."
)
)]
Http { status: u16, body: String },
#[error("network error: {0}")]
#[diagnostic(
code(dns::network),
help(
"Check that the server is reachable at the configured base URL.\n\
If using TLS, verify the certificate is trusted."
)
)]
Network(#[source] reqwest::Error),
#[error("invalid JSON response from server")]
#[diagnostic(
code(dns::invalid_json),
help(
"The server returned a response that isn't valid JSON.\n\
Verify the base URL points to the API, not a proxy or redirect."
)
)]
InvalidJson(#[source] reqwest::Error),
#[error("parse error: {context}")]
#[diagnostic(
code(dns::parse),
help(
"The API response had an unexpected structure. This may indicate a \
version mismatch between this client and the Technitium server."
)
)]
Parse { context: String },
#[error("config error: {context}")]
#[diagnostic(
code(dns::config),
help(
"Check the config file syntax and field names.\n\
Run `dns config print` to inspect the parsed result, or\n\
`dns config init` to regenerate a starter template."
)
)]
Config { context: String },
#[error("invalid MIME type")]
#[diagnostic(code(dns::mime))]
Mime(#[source] reqwest::Error),
#[error("operation not supported by {vendor}: {feature}")]
#[diagnostic(
code(dns::unsupported),
help("This vendor does not support this operation.")
)]
Unsupported {
vendor: &'static str,
feature: &'static str,
},
#[error("forbidden: {message}")]
#[diagnostic(
code(dns::forbidden),
help(
"The API key does not have sufficient permissions.\n\
Check that the token has the access level required for this operation."
)
)]
Forbidden { message: String },
#[error("{context}")]
#[diagnostic(code(dns::io), help("Check that the file exists and is readable."))]
Io {
context: String,
#[source]
source: std::io::Error,
},
#[error("operation cancelled by user")]
#[diagnostic(
code(dns::cancelled),
help("The operation was interrupted before completion. No changes were made.")
)]
UserCancelled,
#[error("MCP error: {context}")]
#[diagnostic(
code(dns::mcp),
help(
"Check that the MCP transport (stdio) is wired up correctly and \
that the configured DNS servers are reachable."
)
)]
Mcp { context: String },
}
impl Error {
pub fn is_transient(&self) -> bool {
if let Self::Network(e) = self {
return e.is_timeout() || e.is_connect();
}
false
}
pub fn is_api_error(&self) -> bool {
matches!(self, Self::Api { .. })
}
pub fn exit_code(&self) -> i32 {
match self {
Self::PolicyViolation { .. } => 6,
Self::Api { .. } => 2,
Self::Http { .. } => 3,
Self::Network(_) => 4,
Self::Io { .. } => 5,
Self::Unsupported { .. } => 7,
Self::Forbidden { .. } => 8,
Self::UserCancelled => 130,
Self::Mcp { .. } => 1,
_ => 1,
}
}
pub fn policy_violation(reason: impl Into<String>, hint: impl Into<String>) -> Self {
Self::PolicyViolation {
reason: reason.into(),
hint: hint.into(),
}
}
pub fn api(message: impl Into<String>) -> Self {
Self::Api {
message: message.into(),
}
}
pub fn parse(context: impl Into<String>) -> Self {
Self::Parse {
context: context.into(),
}
}
pub fn config(context: impl Into<String>) -> Self {
Self::Config {
context: context.into(),
}
}
pub fn io(context: impl Into<String>, source: std::io::Error) -> Self {
Self::Io {
context: context.into(),
source,
}
}
pub fn unsupported(vendor: &'static str, feature: &'static str) -> Self {
Self::Unsupported { vendor, feature }
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::Forbidden {
message: message.into(),
}
}
pub fn cancelled() -> Self {
Self::UserCancelled
}
pub fn mcp(context: impl Into<String>) -> Self {
Self::Mcp {
context: context.into(),
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
#[cfg(test)]
mod tests {
use super::*;
use rstest::{fixture, rstest};
#[fixture]
fn api_error() -> Error {
Error::api("zone not found")
}
#[fixture]
fn io_error() -> Error {
Error::io(
"reading zone file 'example.zone'",
std::io::Error::from(std::io::ErrorKind::NotFound),
)
}
#[rstest]
fn api_error_display_includes_message(api_error: Error) {
assert_eq!(api_error.to_string(), "API error: zone not found");
}
#[rstest]
fn http_error_display_includes_status() {
let e = Error::Http {
status: 403,
body: r#"{"detail":"forbidden"}"#.into(),
};
assert!(e.to_string().contains("403"));
}
#[rstest]
fn parse_error_display_includes_context() {
let e = Error::parse("could not parse list_records for 'example.com'");
assert!(e.to_string().contains("example.com"));
}
#[rstest]
fn io_error_display_includes_context(io_error: Error) {
assert!(io_error.to_string().contains("example.zone"));
}
#[rstest]
fn api_error_has_diagnostic_code(api_error: Error) {
let code = api_error.code().expect("should have a code");
assert_eq!(code.to_string(), "dns::api");
}
#[rstest]
#[case::http(Error::Http { status: 500, body: "".into() }, "dns::http")]
#[case::parse(Error::Parse { context: "x".into() }, "dns::parse")]
#[case::io(Error::Io { context: "x".into(), source: std::io::Error::from(std::io::ErrorKind::NotFound) }, "dns::io")]
fn diagnostic_codes_are_correct(#[case] e: Error, #[case] expected: &str) {
let code = e.code().expect("should have a code");
assert_eq!(code.to_string(), expected);
}
#[rstest]
fn api_error_has_help_text(api_error: Error) {
assert!(api_error.help().is_some());
}
#[rstest]
fn io_error_has_help_text(io_error: Error) {
let help = io_error.help().expect("should have help");
assert!(help.to_string().contains("readable"));
}
#[rstest]
fn api_error_is_api_error(api_error: Error) {
assert!(api_error.is_api_error());
}
#[rstest]
#[case(Error::Http { status: 500, body: "".into() })]
#[case(Error::Parse { context: "bad".into() })]
#[case(Error::Io { context: "x".into(), source: std::io::Error::from(std::io::ErrorKind::NotFound) })]
fn non_api_errors_are_not_api_errors(#[case] e: Error) {
assert!(!e.is_api_error());
}
#[rstest]
#[case::api(Error::Api { message: "x".into() }, 2)]
#[case::http(Error::Http { status: 500, body: "".into() }, 3)]
#[case::parse(Error::Parse { context: "x".into() }, 1)]
#[case::io(Error::Io { context: "x".into(), source: std::io::Error::from(std::io::ErrorKind::NotFound) }, 5)]
#[case::cancelled(Error::UserCancelled, 130)]
#[case::mcp(Error::Mcp { context: "transport".into() }, 1)]
fn exit_code_by_variant(#[case] e: Error, #[case] expected: i32) {
assert_eq!(e.exit_code(), expected);
}
#[rstest]
fn api_constructor_sets_message() {
let e = Error::api("access denied");
assert!(matches!(e, Error::Api { ref message } if message == "access denied"));
}
#[rstest]
fn parse_constructor_sets_context() {
let e = Error::parse("bad response shape");
assert!(matches!(e, Error::Parse { ref context } if context == "bad response shape"));
}
#[rstest]
fn io_constructor_sets_context(io_error: Error) {
assert!(
matches!(io_error, Error::Io { ref context, .. } if context.contains("example.zone"))
);
}
#[rstest]
fn cancelled_constructor_returns_user_cancelled_variant() {
assert!(matches!(Error::cancelled(), Error::UserCancelled));
}
#[rstest]
fn mcp_constructor_sets_context() {
let e = Error::mcp("transport closed");
assert!(matches!(e, Error::Mcp { ref context } if context == "transport closed"));
}
#[rstest]
fn cancelled_has_diagnostic_code() {
let e = Error::UserCancelled;
let code = e.code().expect("should have a code");
assert_eq!(code.to_string(), "dns::cancelled");
}
#[rstest]
fn mcp_has_diagnostic_code() {
let e = Error::mcp("x");
let code = e.code().expect("should have a code");
assert_eq!(code.to_string(), "dns::mcp");
}
}