use thiserror::Error;
fn position_suffix(position: Option<usize>) -> String {
position
.map(|p| format!(" at position {p}"))
.unwrap_or_default()
}
#[cfg(feature = "parser")]
fn format_location(position: usize, input: &str) -> String {
format!(
" at position {} near `{}`",
position,
get_context(input, position)
)
}
#[cfg(feature = "parser")]
fn get_context(input: &str, position: usize) -> String {
let chars: Vec<char> = input.chars().collect();
let len = chars.len();
let start = position.saturating_sub(20);
let end = (position + 20).min(len);
let mut snippet = String::new();
if start > 0 {
snippet.push_str("...");
}
let text: String = chars[start..end].iter().collect();
snippet.push_str(&text);
if end < len {
snippet.push_str("...");
}
snippet
}
#[derive(Debug, Clone, PartialEq, Error)]
#[non_exhaustive]
pub enum DnfError {
#[error(
"Type mismatch for field '{field}'{}: expected {expected}, got {actual}",
position_suffix(*position)
)]
TypeMismatch {
field: Box<str>,
expected: Box<str>,
actual: Box<str>,
position: Option<usize>,
},
#[error("Invalid operator '{operator}' for field '{field}'")]
InvalidOp {
field: Box<str>,
operator: Box<str>,
},
#[error("Unknown field '{field_name}'{}", position_suffix(*position))]
UnknownField {
field_name: Box<str>,
position: Option<usize>,
},
#[error("Map target (AtKey/Keys/Values) used with non-map field '{field_name}' (kind: {field_kind})")]
InvalidMapTarget {
field_name: Box<str>,
field_kind: crate::FieldKind,
},
#[error("Custom operator '{operator_name}' is not registered in the operator registry")]
UnregisteredCustomOp {
operator_name: Box<str>,
},
#[cfg(feature = "parser")]
#[error("Expected {expected}, found {found}{}", format_location(*position, input))]
UnexpectedToken {
expected: String,
found: String,
position: usize,
input: String,
},
#[cfg(feature = "parser")]
#[error("Invalid number '{value}'{}", format_location(*position, input))]
InvalidNumber {
value: String,
position: usize,
input: String,
},
#[cfg(feature = "parser")]
#[error("Unterminated string{}", format_location(*position, input))]
UnterminatedString {
position: usize,
input: String,
},
#[cfg(feature = "parser")]
#[error("Query string is empty")]
EmptyQuery,
#[cfg(feature = "parser")]
#[error("Unexpected end of input{}", format_location(*position, input))]
UnexpectedEof {
position: usize,
input: String,
},
#[cfg(feature = "parser")]
#[error("Invalid escape sequence '{escape}'{}", format_location(*position, input))]
InvalidEscape {
escape: String,
position: usize,
input: String,
},
}
#[cfg(test)]
#[cfg(feature = "parser")]
mod parser_error_tests {
use super::*;
struct ErrorFormatCase {
desc: &'static str,
error: DnfError,
expected_substrings: Vec<&'static str>,
}
#[test]
fn test_error_format() {
let long_query = "a".repeat(50) + " > 18 AND invalid_token";
let cases = vec![
ErrorFormatCase {
desc: "unexpected token",
error: DnfError::UnexpectedToken {
expected: "operator".to_string(),
found: "identifier 'foo'".to_string(),
position: 10,
input: "age > 18 AND foo country == \"US\"".to_string(),
},
expected_substrings: vec![
"at position 10",
"near `",
"Expected operator",
"found identifier 'foo'",
],
},
ErrorFormatCase {
desc: "invalid number",
error: DnfError::InvalidNumber {
value: "123abc".to_string(),
position: 6,
input: "age > 123abc".to_string(),
},
expected_substrings: vec!["Invalid number '123abc'", "at position 6"],
},
ErrorFormatCase {
desc: "unterminated string",
error: DnfError::UnterminatedString {
position: 9,
input: r#"name == "unclosed string"#.to_string(),
},
expected_substrings: vec!["Unterminated string", "at position 9"],
},
ErrorFormatCase {
desc: "invalid escape",
error: DnfError::InvalidEscape {
escape: "\\x".to_string(),
position: 15,
input: r#"name == "test\xfail""#.to_string(),
},
expected_substrings: vec!["Invalid escape sequence '\\x'", "at position 15"],
},
ErrorFormatCase {
desc: "context truncated with ellipsis",
error: DnfError::UnexpectedToken {
expected: "value".to_string(),
found: "identifier".to_string(),
position: 60,
input: long_query,
},
expected_substrings: vec!["...", "at position 60"],
},
ErrorFormatCase {
desc: "empty query",
error: DnfError::EmptyQuery,
expected_substrings: vec!["Query string is empty"],
},
ErrorFormatCase {
desc: "unexpected EOF",
error: DnfError::UnexpectedEof {
position: 8,
input: "age > 18".to_string(),
},
expected_substrings: vec!["Unexpected end of input", "at position 8"],
},
];
for case in cases {
let msg = case.error.to_string();
for substring in &case.expected_substrings {
assert!(
msg.contains(substring),
"case '{}': expected substring '{}' in message, got: {}",
case.desc,
substring,
msg
);
}
}
}
#[test]
fn test_get_context() {
type Validator = Box<dyn Fn(&str) -> bool>;
let cases: Vec<(&'static str, &'static str, usize, Validator)> = vec![
(
"short input fits without ellipses",
"age > 18",
5,
Box::new(|c: &str| c == "age > 18"),
),
(
"position at start has no leading ellipsis",
"this is a long query string",
0,
Box::new(|c: &str| !c.starts_with("...")),
),
(
"position at end has no trailing ellipsis",
"this is a long query string",
"this is a long query string".len() - 1,
Box::new(|c: &str| !c.ends_with("...")),
),
];
for (desc, input, position, validator) in cases {
let context = get_context(input, position);
assert!(
validator(&context),
"case '{}': context did not satisfy validator, got: '{}'",
desc,
context
);
}
}
}