use std::io;
use thiserror::Error;
pub type Result<T> = std::result::Result<T, NomlError>;
#[derive(Error, Debug)]
pub enum NomlError {
#[error("Parse error at line {line}, column {column}: {message}")]
Parse {
message: String,
line: usize,
column: usize,
snippet: Option<String>,
},
#[error("Validation error: {message}")]
Validation {
message: String,
path: Option<String>,
},
#[error("Key '{key}' not found")]
KeyNotFound {
key: String,
available: Vec<String>,
},
#[error("Type error: cannot convert '{value}' to {expected_type}")]
Type {
value: String,
expected_type: String,
actual_type: String,
},
#[error("File error for '{path}': {source}")]
Io {
path: String,
#[source]
source: io::Error,
},
#[error("Interpolation error: {message}")]
Interpolation {
message: String,
expression: String,
context: Option<String>,
},
#[error("Environment variable '{var}' is not set")]
EnvVar {
var: String,
has_default: bool,
},
#[error("Import error: failed to import '{path}': {reason}")]
Import {
path: String,
reason: String,
from: Option<String>,
},
#[error("Schema error at '{path}': {message}")]
Schema {
path: String,
message: String,
expected: Option<String>,
},
#[error("Circular reference detected: {chain}")]
CircularReference {
chain: String,
},
#[error("Internal error: {message}")]
Internal {
message: String,
context: Option<String>,
},
}
impl NomlError {
pub fn parse(message: impl Into<String>, line: usize, column: usize) -> Self {
Self::Parse {
message: message.into(),
line,
column,
snippet: None,
}
}
pub fn parse_with_snippet(
message: impl Into<String>,
line: usize,
column: usize,
snippet: impl Into<String>,
) -> Self {
Self::Parse {
message: message.into(),
line,
column,
snippet: Some(snippet.into()),
}
}
pub fn validation(message: impl Into<String>) -> Self {
Self::Validation {
message: message.into(),
path: None,
}
}
pub fn validation_at(message: impl Into<String>, path: impl Into<String>) -> Self {
Self::Validation {
message: message.into(),
path: Some(path.into()),
}
}
pub fn key_not_found(key: impl Into<String>) -> Self {
Self::KeyNotFound {
key: key.into(),
available: Vec::new(),
}
}
pub fn key_not_found_with_suggestions(key: impl Into<String>, available: Vec<String>) -> Self {
Self::KeyNotFound {
key: key.into(),
available,
}
}
pub fn type_error(
value: impl Into<String>,
expected: impl Into<String>,
actual: impl Into<String>,
) -> Self {
Self::Type {
value: value.into(),
expected_type: expected.into(),
actual_type: actual.into(),
}
}
pub fn io(path: impl Into<String>, error: io::Error) -> Self {
Self::Io {
path: path.into(),
source: error,
}
}
pub fn interpolation(message: impl Into<String>, expression: impl Into<String>) -> Self {
Self::Interpolation {
message: message.into(),
expression: expression.into(),
context: None,
}
}
pub fn env_var(var: impl Into<String>, has_default: bool) -> Self {
Self::EnvVar {
var: var.into(),
has_default,
}
}
pub fn import(path: impl Into<String>, reason: impl Into<String>) -> Self {
Self::Import {
path: path.into(),
reason: reason.into(),
from: None,
}
}
pub fn schema(path: impl Into<String>, message: impl Into<String>) -> Self {
Self::Schema {
path: path.into(),
message: message.into(),
expected: None,
}
}
pub fn circular_reference(chain: impl Into<String>) -> Self {
Self::CircularReference {
chain: chain.into(),
}
}
pub fn internal(message: impl Into<String>) -> Self {
Self::Internal {
message: message.into(),
context: None,
}
}
pub fn parse_with_suggestion(
message: impl Into<String>,
line: usize,
column: usize,
suggestion: impl Into<String>,
) -> Self {
let message = message.into();
let suggestion = suggestion.into();
Self::Parse {
message: format!("{message}. {suggestion}"),
line,
column,
snippet: None,
}
}
pub fn unexpected_token(
found: impl Into<String>,
expected: impl Into<String>,
line: usize,
column: usize,
) -> Self {
let found = found.into();
let expected = expected.into();
Self::Parse {
message: format!("Expected {expected}, but found {found}"),
line,
column,
snippet: None,
}
}
pub fn unknown_function(name: impl Into<String>, line: usize, column: usize) -> Self {
let name = name.into();
let suggestion = match name.as_str() {
"ENV" | "Env" => "Did you mean 'env()'?",
"include" | "INCLUDE" => "Did you mean 'include \"filename\"'?",
"size" | "SIZE" => "Did you mean '@size()'?",
"duration" | "DURATION" => "Did you mean '@duration()'?",
"date" | "DATE" => "Did you mean '@date()'?",
_ => "Available functions: env(), @size(), @duration(), @date()",
};
Self::Parse {
message: format!("Unknown function '{name}'. {suggestion}"),
line,
column,
snippet: None,
}
}
pub fn unknown_native_type(type_name: impl Into<String>, line: usize, column: usize) -> Self {
let type_name = type_name.into();
let suggestion = match type_name.as_str() {
"Size" | "SIZE" => "Did you mean '@size()'?",
"Duration" | "DURATION" => "Did you mean '@duration()'?",
"Date" | "DATE" => "Did you mean '@date()'?",
"url" | "URL" => "Did you mean '@url()'?",
_ => "Available native types: @size(), @duration(), @date(), @url()",
};
Self::Parse {
message: format!("Unknown native type '@{type_name}'. {suggestion}"),
line,
column,
snippet: None,
}
}
pub fn malformed_key_path(path: impl Into<String>, line: usize, column: usize) -> Self {
let path = path.into();
Self::Parse {
message: format!(
"Malformed key path '{path}'. Keys should be identifiers separated by dots (e.g., 'server.host' or 'database.port')"
),
line,
column,
snippet: None,
}
}
pub fn is_recoverable(&self) -> bool {
match self {
NomlError::Parse { .. } => false,
NomlError::Validation { .. } => true,
NomlError::KeyNotFound { .. } => true,
NomlError::Type { .. } => true,
NomlError::Io { source, .. } => match source.kind() {
io::ErrorKind::NotFound => true,
io::ErrorKind::PermissionDenied => false,
_ => true,
},
NomlError::Interpolation { .. } => true,
NomlError::EnvVar { has_default, .. } => *has_default,
NomlError::Import { .. } => true,
NomlError::Schema { .. } => true,
NomlError::CircularReference { .. } => false,
NomlError::Internal { .. } => false,
}
}
pub fn category(&self) -> &'static str {
match self {
NomlError::Parse { .. } => "parse",
NomlError::Validation { .. } => "validation",
NomlError::KeyNotFound { .. } => "key_access",
NomlError::Type { .. } => "type_conversion",
NomlError::Io { .. } => "io",
NomlError::Interpolation { .. } => "interpolation",
NomlError::EnvVar { .. } => "environment",
NomlError::Import { .. } => "import",
NomlError::Schema { .. } => "schema",
NomlError::CircularReference { .. } => "circular_reference",
NomlError::Internal { .. } => "internal",
}
}
pub fn user_message(&self) -> String {
match self {
NomlError::Parse {
message,
line,
column,
snippet,
} => {
let mut msg = format!("Syntax error on line {line}, column {column}: {message}");
if let Some(snippet) = snippet {
msg.push_str(&format!("\n\n{snippet}"));
}
msg.push_str("\n\nTip: Check for missing quotes, brackets, or commas.");
msg
}
NomlError::KeyNotFound { key, available } => {
let mut msg = format!("The key '{key}' doesn't exist.");
if !available.is_empty() {
msg.push_str("\n\nDid you mean one of these?");
for suggestion in available {
msg.push_str(&format!("\n - {suggestion}"));
}
}
msg
}
NomlError::EnvVar { var, has_default } => {
let mut msg = format!("Environment variable '{var}' is not set.");
if !has_default {
msg.push_str(&format!("\n\nTip: Set the environment variable or provide a default value: env(\"{var}\", \"default_value\")"));
}
msg
}
_ => self.to_string(),
}
}
}
impl From<io::Error> for NomlError {
fn from(error: io::Error) -> Self {
Self::Io {
path: "<unknown>".to_string(),
source: error,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_creation_and_display() {
let err = NomlError::parse("Invalid syntax", 10, 5);
assert_eq!(
err.to_string(),
"Parse error at line 10, column 5: Invalid syntax"
);
}
#[test]
fn error_categories() {
let parse_err = NomlError::parse("test", 1, 1);
assert_eq!(parse_err.category(), "parse");
assert!(!parse_err.is_recoverable());
let key_err = NomlError::key_not_found("test.key");
assert_eq!(key_err.category(), "key_access");
assert!(key_err.is_recoverable());
}
#[test]
fn user_friendly_messages() {
let err = NomlError::key_not_found_with_suggestions(
"unknown_key",
vec!["known_key".to_string(), "other_key".to_string()],
);
let msg = err.user_message();
assert!(msg.contains("unknown_key"));
assert!(msg.contains("known_key"));
assert!(msg.contains("other_key"));
}
}