use std::borrow::Cow;
use thiserror::Error;
const MAX_ERROR_INPUT_BYTES: usize = 80;
pub(crate) fn truncate_error_text(text: &str) -> Cow<'_, str> {
if text.len() <= MAX_ERROR_INPUT_BYTES {
return Cow::Borrowed(text);
}
let mut end = MAX_ERROR_INPUT_BYTES;
while !text.is_char_boundary(end) {
end -= 1;
}
Cow::Owned(format!("{}…", &text[..end]))
}
#[derive(Debug, Error)]
pub(crate) enum ErrorRepr {
#[error("errors must be 'replace', 'ignore', or 'preserve', got '{got}'")]
InvalidErrorMode {
got: String,
},
#[error("form must be 'NFC', 'NFD', 'NFKC', or 'NFKD', got '{got}'")]
InvalidNormForm {
got: String,
},
#[error("normalize must be 'NFC', 'NFD', 'NFKC', or 'NFKD', got '{got}'")]
InvalidPipelineNormForm {
got: String,
},
#[error("emoji_style must be 'cldr' or 'none', got '{got}'")]
InvalidEmojiStyle {
got: String,
},
#[error("platform must be 'universal', 'windows', or 'posix', got '{got}'")]
InvalidPlatform {
got: String,
},
#[error("target_script must be 'latin' or 'cyrillic', got '{got}'")]
InvalidTargetScript {
got: String,
},
#[error("scheme must be 'default', 'strict_iso9', or 'gost7034', got '{got}'")]
InvalidScheme {
got: String,
},
#[error("url component must be 'path', 'segment', 'query', or 'form', got '{got}'")]
InvalidUrlComponent {
got: String,
},
#[error("reverse language must be 'el', 'ru', or 'uk', got '{got}'")]
InvalidReverseLang {
got: String,
},
#[error(
"unknown language code '{got}'{suggestion}; expected 'auto', a BCP-47 \
alias (nb, nn, da), or one of: {valid}"
)]
UnknownLang {
got: String,
suggestion: String,
valid: String,
},
#[error("strict_iso9 and gost7034 are mutually exclusive")]
MutuallyExclusiveBare,
#[error("strict_iso9 and gost7034 are mutually exclusive")]
MutuallyExclusivePipeline,
#[error("batch too large ({len} items); maximum is {max} items")]
BatchTooLarge {
len: usize,
max: usize,
},
#[error(
"registered replacements expanded the input to {size} bytes, exceeding the {max} byte limit"
)]
ReplacementOutputTooLarge {
size: usize,
max: usize,
},
#[error(
"{op}: registration tables are sealed (seal_registrations() was called); \
register/remove/clear are not permitted after sealing"
)]
Sealed {
op: String,
},
#[error(
"register_lang(): maximum of {max} registered languages reached; \
re-registering an existing code is still allowed"
)]
RegisterLangLimit {
max: usize,
},
#[error(
"register_lang(): mapping keys must be exactly one Unicode character; \
invalid keys: {keys}"
)]
RegisterLangBadKeys {
keys: String,
},
#[error(
"register_replacements(): table would exceed the maximum of {max} entries \
(projected size: {projected}); call clear_replacements() first"
)]
RegisterReplacementsLimit {
max: usize,
projected: usize,
},
#[error(
"context dictionary for {lang} not found; context-aware transliteration needs \
the prebuilt dictionaries: run `bash scripts/bootstrap_dicts.sh` (from a \
source checkout) and set the DISARM_DICT_DIR environment variable to the \
output directory (see docs/user-guide/abjad-transliteration.md)"
)]
ContextDictNotFound {
lang: String,
},
#[error(
"context dictionary for {lang} is corrupt and could not be loaded: {reason}; \
rebuild it with `bash scripts/bootstrap_dicts.sh` (from a source checkout)"
)]
ContextDictCorrupt {
lang: String,
reason: String,
},
#[error("regex_pattern is too long ({len} bytes); maximum is {max} bytes")]
RegexTooLong {
len: usize,
max: usize,
},
#[error("invalid regex_pattern {pattern:?}: {source}")]
RegexCompile {
pattern: String,
source: regex::Error,
},
#[error(
"UniqueSlugifier exceeded {max} attempts for '{}'",
truncate_error_text(text)
)]
UniqueSlugAttemptsExceeded {
max: u64,
text: String,
},
#[error(
"max_length={max_length} is too small to generate a unique slug with separator {separator:?}: \
need at least {min_unique_len} bytes for the separator plus one counter digit"
)]
UniqueSlugMaxLengthTooSmall {
max_length: usize,
separator: String,
min_unique_len: usize,
},
#[error("unknown encoding: '{got}'{suggestion}")]
UnknownEncoding {
got: String,
suggestion: String,
},
#[error("auto-detected encoding '{got}' is not supported")]
UnsupportedAutoEncoding {
got: String,
},
#[error(
"encoding detection confidence {confidence:.2} is below the required \
minimum {min_confidence:.2} (best guess: '{guess}'); \
provide an explicit encoding instead"
)]
EncodingConfidenceTooLow {
confidence: f64,
min_confidence: f64,
guess: String,
},
#[error("min_confidence must be between 0.0 and 1.0, got {min_confidence}")]
MinConfidenceOutOfRange {
min_confidence: f64,
},
#[error("reverse transliteration not supported for lang '{lang}'; available: {available}")]
ReverseUnsupportedLang {
lang: String,
available: String,
},
#[error("no transliteration for {ch:?} (U+{:04X}) at byte offset {byte_offset}", *ch as u32)]
Untranslatable {
ch: char,
byte_offset: usize,
},
#[error(
"decoding as '{encoding}' replaced malformed or invalid byte sequences \
(lossy); pass strict=False to accept the lossy result and inspect had_errors"
)]
LossyDecode {
encoding: String,
},
#[error("'lang' and 'target' are mutually exclusive")]
LangTargetExclusive,
#[error("'context' and 'target' are mutually exclusive")]
ContextTargetExclusive,
#[error(
"'tones' cannot be used with 'context' — context-aware \
transliteration does not produce toned pinyin"
)]
TonesWithContext,
#[error(
"errors='strict' cannot be used with 'context' — strict mode is \
only available for context-free transliteration"
)]
StrictWithContext,
#[error("forward-only parameters ({names}) cannot be used with 'target'")]
ForwardOnlyWithTarget {
names: String,
},
#[error("max_length must be non-negative, got {got}")]
NegativeMaxLength {
got: i64,
},
#[error("max_graphemes must be non-negative, got {got}")]
NegativeMaxGraphemes {
got: i64,
},
#[error(
"replacement must not contain a character this call neutralizes \
(found U+{codepoint:04X})"
)]
InvalidLogReplacement {
codepoint: u32,
},
}
impl ErrorRepr {
#[allow(dead_code)]
pub(crate) fn code(&self) -> &'static str {
match self {
ErrorRepr::InvalidErrorMode { .. } => "invalid_error_mode",
ErrorRepr::InvalidNormForm { .. } => "invalid_norm_form",
ErrorRepr::InvalidPipelineNormForm { .. } => "invalid_pipeline_norm_form",
ErrorRepr::InvalidEmojiStyle { .. } => "invalid_emoji_style",
ErrorRepr::InvalidPlatform { .. } => "invalid_platform",
ErrorRepr::InvalidTargetScript { .. } => "invalid_target_script",
ErrorRepr::InvalidScheme { .. } => "invalid_scheme",
ErrorRepr::InvalidUrlComponent { .. } => "invalid_url_component",
ErrorRepr::InvalidReverseLang { .. } => "invalid_reverse_lang",
ErrorRepr::InvalidLogReplacement { .. } => "invalid_log_replacement",
ErrorRepr::UnknownLang { .. } => "unknown_lang",
ErrorRepr::MutuallyExclusiveBare | ErrorRepr::MutuallyExclusivePipeline => {
"mutually_exclusive"
}
ErrorRepr::BatchTooLarge { .. } => "batch_too_large",
ErrorRepr::ReplacementOutputTooLarge { .. } => "replacement_output_too_large",
ErrorRepr::Sealed { .. } => "sealed",
ErrorRepr::RegisterLangLimit { .. } => "register_lang_limit",
ErrorRepr::RegisterLangBadKeys { .. } => "register_lang_bad_keys",
ErrorRepr::RegisterReplacementsLimit { .. } => "register_replacements_limit",
ErrorRepr::ContextDictNotFound { .. } => "context_dict_not_found",
ErrorRepr::ContextDictCorrupt { .. } => "context_dict_corrupt",
ErrorRepr::RegexTooLong { .. } => "regex_too_long",
ErrorRepr::RegexCompile { .. } => "regex_compile",
ErrorRepr::UniqueSlugAttemptsExceeded { .. } => "unique_slug_attempts_exceeded",
ErrorRepr::UniqueSlugMaxLengthTooSmall { .. } => "unique_slug_max_length_too_small",
ErrorRepr::UnknownEncoding { .. } => "unknown_encoding",
ErrorRepr::UnsupportedAutoEncoding { .. } => "unsupported_auto_encoding",
ErrorRepr::EncodingConfidenceTooLow { .. } => "encoding_confidence_too_low",
ErrorRepr::MinConfidenceOutOfRange { .. } => "min_confidence_out_of_range",
ErrorRepr::ReverseUnsupportedLang { .. } => "reverse_unsupported_lang",
ErrorRepr::Untranslatable { .. } => "untranslatable",
ErrorRepr::LossyDecode { .. } => "lossy_decode",
ErrorRepr::LangTargetExclusive => "lang_target_exclusive",
ErrorRepr::ContextTargetExclusive => "context_target_exclusive",
ErrorRepr::TonesWithContext => "tones_with_context",
ErrorRepr::StrictWithContext => "strict_with_context",
ErrorRepr::ForwardOnlyWithTarget { .. } => "forward_only_with_target",
ErrorRepr::NegativeMaxLength { .. } => "negative_max_length",
ErrorRepr::NegativeMaxGraphemes { .. } => "negative_max_graphemes",
}
}
}
pub(crate) fn checked_max_length(value: i64) -> Result<usize, ErrorRepr> {
usize::try_from(value).map_err(|_| ErrorRepr::NegativeMaxLength { got: value })
}
pub(crate) fn checked_max_graphemes(value: i64) -> Result<usize, ErrorRepr> {
usize::try_from(value).map_err(|_| ErrorRepr::NegativeMaxGraphemes { got: value })
}
#[cfg(feature = "extension-module")]
impl From<ErrorRepr> for pyo3::PyErr {
fn from(err: ErrorRepr) -> Self {
let msg = err.to_string();
let cause: Option<pyo3::PyErr> = match &err {
ErrorRepr::RegexCompile { source, .. } => {
Some(pyo3::exceptions::PyValueError::new_err(source.to_string()))
}
ErrorRepr::ContextDictCorrupt { reason, .. } => {
Some(pyo3::exceptions::PyValueError::new_err(reason.clone()))
}
_ => None,
};
let err_py = match err {
ErrorRepr::InvalidErrorMode { .. }
| ErrorRepr::InvalidNormForm { .. }
| ErrorRepr::InvalidPipelineNormForm { .. }
| ErrorRepr::InvalidEmojiStyle { .. }
| ErrorRepr::InvalidPlatform { .. }
| ErrorRepr::InvalidTargetScript { .. }
| ErrorRepr::InvalidScheme { .. }
| ErrorRepr::InvalidUrlComponent { .. }
| ErrorRepr::InvalidReverseLang { .. }
| ErrorRepr::InvalidLogReplacement { .. }
| ErrorRepr::UnknownLang { .. }
| ErrorRepr::MutuallyExclusiveBare
| ErrorRepr::MutuallyExclusivePipeline
| ErrorRepr::RegisterLangBadKeys { .. }
| ErrorRepr::RegexCompile { .. }
| ErrorRepr::UniqueSlugMaxLengthTooSmall { .. }
| ErrorRepr::UnknownEncoding { .. }
| ErrorRepr::MinConfidenceOutOfRange { .. }
| ErrorRepr::LangTargetExclusive
| ErrorRepr::ContextTargetExclusive
| ErrorRepr::TonesWithContext
| ErrorRepr::StrictWithContext
| ErrorRepr::ForwardOnlyWithTarget { .. }
| ErrorRepr::NegativeMaxLength { .. }
| ErrorRepr::NegativeMaxGraphemes { .. } => crate::InvalidArgumentError::new_err(msg),
ErrorRepr::BatchTooLarge { .. }
| ErrorRepr::ReplacementOutputTooLarge { .. }
| ErrorRepr::RegisterLangLimit { .. }
| ErrorRepr::RegisterReplacementsLimit { .. }
| ErrorRepr::RegexTooLong { .. }
| ErrorRepr::UniqueSlugAttemptsExceeded { .. } => {
crate::ResourceLimitError::new_err(msg)
}
ErrorRepr::UnsupportedAutoEncoding { .. }
| ErrorRepr::ReverseUnsupportedLang { .. } => crate::UnsupportedError::new_err(msg),
ErrorRepr::Sealed { .. }
| ErrorRepr::ContextDictNotFound { .. }
| ErrorRepr::ContextDictCorrupt { .. }
| ErrorRepr::EncodingConfidenceTooLow { .. }
| ErrorRepr::Untranslatable { .. }
| ErrorRepr::LossyDecode { .. } => crate::DisarmError::new_err(msg),
};
if let Some(cause_err) = cause {
pyo3::Python::attach(|py| err_py.set_cause(py, Some(cause_err)));
}
err_py
}
}
#[derive(Debug)]
pub struct Error(ErrorRepr);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorKind {
InvalidArgument,
ResourceLimit,
Unsupported,
Other,
}
impl Error {
pub fn kind(&self) -> ErrorKind {
match &self.0 {
ErrorRepr::InvalidErrorMode { .. }
| ErrorRepr::InvalidNormForm { .. }
| ErrorRepr::InvalidPipelineNormForm { .. }
| ErrorRepr::InvalidEmojiStyle { .. }
| ErrorRepr::InvalidPlatform { .. }
| ErrorRepr::InvalidTargetScript { .. }
| ErrorRepr::InvalidScheme { .. }
| ErrorRepr::InvalidUrlComponent { .. }
| ErrorRepr::InvalidReverseLang { .. }
| ErrorRepr::InvalidLogReplacement { .. }
| ErrorRepr::UnknownLang { .. }
| ErrorRepr::MutuallyExclusiveBare
| ErrorRepr::MutuallyExclusivePipeline
| ErrorRepr::RegisterLangBadKeys { .. }
| ErrorRepr::RegexCompile { .. }
| ErrorRepr::UniqueSlugMaxLengthTooSmall { .. }
| ErrorRepr::UnknownEncoding { .. }
| ErrorRepr::MinConfidenceOutOfRange { .. }
| ErrorRepr::LangTargetExclusive
| ErrorRepr::ContextTargetExclusive
| ErrorRepr::TonesWithContext
| ErrorRepr::StrictWithContext
| ErrorRepr::ForwardOnlyWithTarget { .. }
| ErrorRepr::NegativeMaxLength { .. }
| ErrorRepr::NegativeMaxGraphemes { .. } => ErrorKind::InvalidArgument,
ErrorRepr::BatchTooLarge { .. }
| ErrorRepr::ReplacementOutputTooLarge { .. }
| ErrorRepr::RegisterLangLimit { .. }
| ErrorRepr::RegisterReplacementsLimit { .. }
| ErrorRepr::RegexTooLong { .. }
| ErrorRepr::UniqueSlugAttemptsExceeded { .. } => ErrorKind::ResourceLimit,
ErrorRepr::UnsupportedAutoEncoding { .. }
| ErrorRepr::ReverseUnsupportedLang { .. } => ErrorKind::Unsupported,
ErrorRepr::Sealed { .. }
| ErrorRepr::ContextDictNotFound { .. }
| ErrorRepr::ContextDictCorrupt { .. }
| ErrorRepr::EncodingConfidenceTooLow { .. }
| ErrorRepr::Untranslatable { .. }
| ErrorRepr::LossyDecode { .. } => ErrorKind::Other,
}
}
pub fn code(&self) -> &'static str {
self.0.code()
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
None
}
}
impl From<ErrorRepr> for Error {
fn from(repr: ErrorRepr) -> Self {
Error(repr)
}
}
#[cfg(feature = "extension-module")]
impl From<Error> for pyo3::PyErr {
fn from(err: Error) -> Self {
err.0.into()
}
}
#[cfg(test)]
mod tests {
use super::{truncate_error_text, Error, ErrorKind, ErrorRepr, MAX_ERROR_INPUT_BYTES};
#[test]
fn truncate_error_text_bounds_long_input() {
let long = "a".repeat(500);
let out = truncate_error_text(&long);
assert!(out.len() < long.len());
assert!(out.starts_with("aaaa"));
assert!(out.ends_with('…'));
assert!(out.len() <= MAX_ERROR_INPUT_BYTES + '…'.len_utf8());
}
#[test]
fn truncate_error_text_passes_short_input_through() {
assert_eq!(truncate_error_text("short"), "short");
}
#[test]
fn opaque_error_kind_partition_and_delegation() {
let cases = [
(
ErrorRepr::InvalidTargetScript { got: "x".into() },
ErrorKind::InvalidArgument,
"invalid_target_script",
),
(
ErrorRepr::BatchTooLarge { len: 2, max: 1 },
ErrorKind::ResourceLimit,
"batch_too_large",
),
(
ErrorRepr::ReverseUnsupportedLang {
lang: "zz".into(),
available: "ru".into(),
},
ErrorKind::Unsupported,
"reverse_unsupported_lang",
),
(
ErrorRepr::Untranslatable {
ch: '\u{1F600}',
byte_offset: 0,
},
ErrorKind::Other,
"untranslatable",
),
];
for (repr, kind, code) in cases {
let display = repr.to_string();
let err = Error::from(repr);
assert_eq!(err.kind(), kind, "kind for {code}");
assert_eq!(err.code(), code);
assert_eq!(err.to_string(), display, "Display delegates to repr");
assert!(std::error::Error::source(&err).is_none());
}
}
#[test]
fn truncate_error_text_cuts_on_char_boundary() {
let s = "é".repeat(100); let out = truncate_error_text(&s);
assert!(out.ends_with('…'));
assert!(out.chars().count() > 1);
}
#[test]
fn unique_slug_error_display_is_truncated() {
let err = ErrorRepr::UniqueSlugAttemptsExceeded {
max: 5,
text: "x".repeat(500),
};
let msg = err.to_string();
assert!(msg.starts_with("UniqueSlugifier exceeded 5 attempts for 'xxxx"));
assert!(msg.contains('…'));
assert!(
msg.len() < 200,
"error message should be bounded, got {} bytes",
msg.len()
);
}
#[test]
fn codes_are_nonempty_snake_case() {
#[allow(clippy::invalid_regex)]
let invalid_regex_err = regex::Regex::new("[").unwrap_err();
let samples = [
ErrorRepr::InvalidErrorMode { got: "x".into() },
ErrorRepr::InvalidNormForm { got: "x".into() },
ErrorRepr::InvalidPipelineNormForm { got: "x".into() },
ErrorRepr::InvalidEmojiStyle { got: "x".into() },
ErrorRepr::InvalidPlatform { got: "x".into() },
ErrorRepr::InvalidTargetScript { got: "x".into() },
ErrorRepr::InvalidLogReplacement { codepoint: 0x0A },
ErrorRepr::UnknownLang {
got: "x".into(),
suggestion: String::new(),
valid: "a, b".into(),
},
ErrorRepr::MutuallyExclusiveBare,
ErrorRepr::MutuallyExclusivePipeline,
ErrorRepr::BatchTooLarge { len: 2, max: 1 },
ErrorRepr::ReplacementOutputTooLarge { size: 2, max: 1 },
ErrorRepr::Sealed {
op: "register".into(),
},
ErrorRepr::RegisterLangLimit { max: 1 },
ErrorRepr::RegisterLangBadKeys {
keys: "\"x\"".into(),
},
ErrorRepr::RegisterReplacementsLimit {
max: 1,
projected: 2,
},
ErrorRepr::ContextDictNotFound {
lang: "Arabic".into(),
},
ErrorRepr::ContextDictCorrupt {
lang: "Arabic".into(),
reason: "bad".into(),
},
ErrorRepr::RegexTooLong { len: 2, max: 1 },
ErrorRepr::RegexCompile {
pattern: "[".into(),
source: invalid_regex_err,
},
ErrorRepr::UniqueSlugAttemptsExceeded {
max: 1,
text: "x".into(),
},
ErrorRepr::UniqueSlugMaxLengthTooSmall {
max_length: 1,
separator: "-".into(),
min_unique_len: 2,
},
ErrorRepr::UnknownEncoding {
got: "x".into(),
suggestion: String::new(),
},
ErrorRepr::UnsupportedAutoEncoding { got: "x".into() },
ErrorRepr::EncodingConfidenceTooLow {
confidence: 0.5,
min_confidence: 0.9,
guess: "UTF-8".into(),
},
ErrorRepr::ReverseUnsupportedLang {
lang: "de".into(),
available: "ru, uk".into(),
},
ErrorRepr::Untranslatable {
ch: '😀',
byte_offset: 3,
},
ErrorRepr::LossyDecode {
encoding: "Shift_JIS".into(),
},
];
for e in &samples {
let code = e.code();
assert!(!code.is_empty());
assert!(
code.bytes().all(|b| b.is_ascii_lowercase() || b == b'_'),
"code {code:?} is not lower snake_case"
);
}
}
#[test]
fn messages_follow_house_style() {
const ALLOWED_UPPERCASE: &[&str] = &["UniqueSlugifier"];
fn allows_double_quote(e: &ErrorRepr) -> bool {
matches!(
e,
ErrorRepr::RegexCompile { .. }
| ErrorRepr::RegisterLangBadKeys { .. }
| ErrorRepr::UniqueSlugMaxLengthTooSmall { .. }
)
}
const MARKER: &str = "zzvaluezz";
#[allow(clippy::invalid_regex)]
let regex_err = regex::Regex::new("[").unwrap_err();
let samples: Vec<(ErrorRepr, bool)> = vec![
(ErrorRepr::InvalidErrorMode { got: MARKER.into() }, true),
(ErrorRepr::InvalidNormForm { got: MARKER.into() }, true),
(
ErrorRepr::InvalidPipelineNormForm { got: MARKER.into() },
true,
),
(ErrorRepr::InvalidEmojiStyle { got: MARKER.into() }, true),
(ErrorRepr::InvalidPlatform { got: MARKER.into() }, true),
(ErrorRepr::InvalidTargetScript { got: MARKER.into() }, true),
(
ErrorRepr::UnknownLang {
got: MARKER.into(),
suggestion: String::new(),
valid: "a, b".into(),
},
true,
),
(ErrorRepr::MutuallyExclusiveBare, false),
(ErrorRepr::MutuallyExclusivePipeline, false),
(ErrorRepr::BatchTooLarge { len: 2, max: 1 }, false),
(
ErrorRepr::ReplacementOutputTooLarge { size: 2, max: 1 },
false,
),
(ErrorRepr::Sealed { op: MARKER.into() }, true),
(ErrorRepr::RegisterLangLimit { max: 1 }, false),
(
ErrorRepr::RegisterLangBadKeys {
keys: format!("{MARKER:?}"),
},
true,
),
(
ErrorRepr::RegisterReplacementsLimit {
max: 1,
projected: 2,
},
false,
),
(
ErrorRepr::ContextDictNotFound {
lang: MARKER.into(),
},
true,
),
(
ErrorRepr::ContextDictCorrupt {
lang: MARKER.into(),
reason: "bad".into(),
},
true,
),
(ErrorRepr::RegexTooLong { len: 2, max: 1 }, false),
(
ErrorRepr::RegexCompile {
pattern: MARKER.into(),
source: regex_err,
},
true,
),
(
ErrorRepr::UniqueSlugAttemptsExceeded {
max: 1,
text: MARKER.into(),
},
true,
),
(
ErrorRepr::UniqueSlugMaxLengthTooSmall {
max_length: 1,
separator: MARKER.into(),
min_unique_len: 2,
},
true,
),
(
ErrorRepr::UnknownEncoding {
got: MARKER.into(),
suggestion: String::new(),
},
true,
),
(
ErrorRepr::UnsupportedAutoEncoding { got: MARKER.into() },
true,
),
(
ErrorRepr::EncodingConfidenceTooLow {
confidence: 0.5,
min_confidence: 0.9,
guess: MARKER.into(),
},
true,
),
(
ErrorRepr::MinConfidenceOutOfRange {
min_confidence: 1.5,
},
false,
),
(
ErrorRepr::ReverseUnsupportedLang {
lang: MARKER.into(),
available: "ru".into(),
},
true,
),
(
ErrorRepr::Untranslatable {
ch: '😀',
byte_offset: 3,
},
false,
),
(
ErrorRepr::LossyDecode {
encoding: MARKER.into(),
},
true,
),
];
for (err, echoes_marker) in &samples {
let msg = err.to_string();
let code = err.code();
assert!(!msg.is_empty(), "empty message for {code}");
let first = msg.chars().next().unwrap();
let upper_ok = ALLOWED_UPPERCASE.iter().any(|p| msg.starts_with(p));
assert!(
!first.is_alphabetic() || first.is_lowercase() || upper_ok,
"{code}: message should start lowercase (or a known identifier): {msg:?}"
);
if !allows_double_quote(err) {
assert!(
!msg.contains('"'),
"{code}: use single quotes, not double quotes: {msg:?}"
);
}
if *echoes_marker {
assert!(
msg.contains(MARKER),
"{code}: message must echo the offending value: {msg:?}"
);
}
}
}
#[test]
fn invalid_value_messages_offer_a_remedy() {
let samples = [
ErrorRepr::InvalidErrorMode { got: "x".into() },
ErrorRepr::InvalidNormForm { got: "x".into() },
ErrorRepr::InvalidPipelineNormForm { got: "x".into() },
ErrorRepr::InvalidEmojiStyle { got: "x".into() },
ErrorRepr::InvalidPlatform { got: "x".into() },
ErrorRepr::InvalidTargetScript { got: "x".into() },
ErrorRepr::UnknownLang {
got: "x".into(),
suggestion: String::new(),
valid: "a, b".into(),
},
ErrorRepr::MinConfidenceOutOfRange {
min_confidence: 1.5,
},
];
for e in &samples {
let msg = e.to_string();
assert!(
msg.contains("must be") || msg.contains("expected") || msg.contains("one of"),
"{}: should name the valid options: {msg:?}",
e.code()
);
}
}
#[test]
fn mutually_exclusive_variants_share_text_and_code() {
let a = ErrorRepr::MutuallyExclusiveBare;
let b = ErrorRepr::MutuallyExclusivePipeline;
assert_eq!(a.to_string(), b.to_string());
assert_eq!(a.code(), b.code());
assert_eq!(
a.to_string(),
"strict_iso9 and gost7034 are mutually exclusive"
);
}
}