use std::collections::HashMap;
use std::fmt;
use indexmap::IndexMap;
use rustrails_support::inflector::humanize;
use serde::Serialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub enum ErrorType {
Blank,
Invalid,
TooShort,
TooLong,
WrongLength,
NotANumber,
NotAnInteger,
GreaterThan,
LessThan,
EqualTo,
OtherThan,
GreaterThanOrEqualTo,
LessThanOrEqualTo,
Taken,
Accepted,
Confirmation,
Empty,
Exclusion,
Inclusion,
Custom(String),
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Error {
pub attribute: String,
pub error_type: ErrorType,
pub message: String,
pub details: HashMap<String, Value>,
}
impl Error {
pub fn new(attribute: impl Into<String>, message: impl Into<String>) -> Self {
Self::new_with_type(attribute, ErrorType::Invalid, message)
}
pub fn new_with_type(
attribute: impl Into<String>,
error_type: ErrorType,
message: impl Into<String>,
) -> Self {
Self::new_with_details(attribute, error_type, message, HashMap::new())
}
pub fn new_with_default_details(
attribute: impl Into<String>,
message: impl Into<String>,
details: HashMap<String, Value>,
) -> Self {
Self::new_with_details(attribute, ErrorType::Invalid, message, details)
}
pub fn new_with_details(
attribute: impl Into<String>,
error_type: ErrorType,
message: impl Into<String>,
details: HashMap<String, Value>,
) -> Self {
Self {
attribute: attribute.into(),
error_type,
message: message.into(),
details,
}
}
pub fn full_message(&self) -> String {
if self.attribute == "base" {
self.message.clone()
} else {
format!(
"{} {}",
humanize_error_attribute(&self.attribute),
self.message
)
}
}
pub fn matches(
&self,
attribute: Option<&str>,
error_type: Option<&ErrorType>,
details: Option<&HashMap<String, Value>>,
) -> bool {
if let Some(attribute) = attribute
&& self.attribute != attribute
{
return false;
}
if let Some(error_type) = error_type
&& &self.error_type != error_type
{
return false;
}
if let Some(details) = details
&& !details
.iter()
.all(|(key, value)| self.details.get(key) == Some(value))
{
return false;
}
true
}
}
fn humanize_error_attribute(attribute: &str) -> String {
humanize(&attribute.replace('.', "_"))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct Errors {
errors: Vec<Error>,
}
impl Errors {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add(
&mut self,
attribute: &str,
error_type: ErrorType,
message: impl Into<String>,
) -> Error {
self.add_with_details(attribute, error_type, message, HashMap::new())
}
pub fn add_message(&mut self, attribute: &str, message: impl Into<String>) -> Error {
self.add(attribute, ErrorType::Invalid, message)
}
pub fn add_with_details(
&mut self,
attribute: &str,
error_type: ErrorType,
message: impl Into<String>,
details: HashMap<String, Value>,
) -> Error {
let error = Error::new_with_details(attribute, error_type, message, details);
self.errors.push(error.clone());
error
}
pub fn delete(&mut self, attribute: &str) -> Vec<String> {
let mut removed = Vec::new();
self.errors.retain(|error| {
if error.attribute == attribute {
removed.push(error.message.clone());
false
} else {
true
}
});
removed
}
pub fn clear(&mut self) {
self.errors.clear();
}
#[must_use]
pub fn on(&self, attribute: &str) -> Vec<&Error> {
self.where_attr(attribute)
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.errors.is_empty()
}
#[must_use]
pub fn count(&self) -> usize {
self.errors.len()
}
#[must_use]
pub fn any(&self) -> bool {
!self.is_empty()
}
#[must_use]
pub fn added(
&self,
attribute: &str,
error_type: &ErrorType,
details: Option<&HashMap<String, Value>>,
) -> bool {
self.errors
.iter()
.any(|error| error.matches(Some(attribute), Some(error_type), details))
}
#[must_use]
pub fn of_kind(&self, attribute: &str, error_type: &ErrorType) -> bool {
self.added(attribute, error_type, None)
}
#[must_use]
pub fn full_messages(&self) -> Vec<String> {
self.errors.iter().map(Error::full_message).collect()
}
#[must_use]
pub fn full_messages_for(&self, attribute: &str) -> Vec<String> {
self.where_attr(attribute)
.into_iter()
.map(Error::full_message)
.collect()
}
#[must_use]
pub fn messages_for(&self, attribute: &str) -> Vec<String> {
self.where_attr(attribute)
.into_iter()
.map(|error| error.message.clone())
.collect()
}
#[must_use]
pub fn attributes(&self) -> Vec<&str> {
let mut attributes = Vec::new();
for error in &self.errors {
let attribute = error.attribute.as_str();
if !attributes.contains(&attribute) {
attributes.push(attribute);
}
}
attributes
}
#[must_use]
pub fn details(&self) -> &[Error] {
&self.errors
}
#[must_use]
pub fn has_key(&self, attribute: &str) -> bool {
self.errors.iter().any(|error| error.attribute == attribute)
}
#[must_use]
pub fn to_hash(&self) -> HashMap<String, Vec<String>> {
self.grouped_messages()
.into_iter()
.map(|(attribute, messages)| (attribute, messages.to_vec()))
.collect()
}
#[must_use]
pub fn as_json(&self) -> Value {
self.json_from_grouped_messages(false)
}
#[must_use]
pub fn as_json_with_full_messages(&self) -> Value {
self.json_from_grouped_messages(true)
}
#[must_use]
pub fn group_by_attribute(&self) -> IndexMap<String, Vec<Error>> {
let mut grouped = IndexMap::new();
for error in &self.errors {
grouped
.entry(error.attribute.clone())
.or_insert_with(Vec::new)
.push(error.clone());
}
grouped
}
pub fn copy_from(&mut self, other: &Self) {
self.merge(other);
}
pub fn merge(&mut self, other: &Self) {
if std::ptr::eq(self as *const Self, other as *const Self) {
return;
}
self.errors.extend(other.errors.iter().cloned());
}
#[must_use]
pub fn where_attr(&self, attribute: &str) -> Vec<&Error> {
self.errors
.iter()
.filter(|error| error.attribute == attribute)
.collect()
}
#[must_use]
pub fn where_type(&self, error_type: &ErrorType) -> Vec<&Error> {
self.errors
.iter()
.filter(|error| &error.error_type == error_type)
.collect()
}
#[must_use]
pub fn where_attr_type(&self, attribute: &str, error_type: &ErrorType) -> Vec<&Error> {
self.errors
.iter()
.filter(|error| error.attribute == attribute && &error.error_type == error_type)
.collect()
}
fn grouped_messages(&self) -> IndexMap<String, Vec<String>> {
let mut grouped = IndexMap::new();
for error in &self.errors {
grouped
.entry(error.attribute.clone())
.or_insert_with(Vec::new)
.push(error.message.clone());
}
grouped
}
fn json_from_grouped_messages(&self, full_messages: bool) -> Value {
let mut object = serde_json::Map::new();
for (attribute, errors) in self.group_by_attribute() {
let values = errors
.into_iter()
.map(|error| {
if full_messages {
Value::String(error.full_message())
} else {
Value::String(error.message)
}
})
.collect();
object.insert(attribute, Value::Array(values));
}
Value::Object(object)
}
}
impl fmt::Display for Errors {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.full_messages().join(", "))
}
}
impl<'a> IntoIterator for &'a Errors {
type Item = &'a Error;
type IntoIter = std::slice::Iter<'a, Error>;
fn into_iter(self) -> Self::IntoIter {
self.errors.iter()
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::json;
use super::{Error, ErrorType, Errors};
#[test]
fn adds_errors_and_reports_count() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(errors.count(), 2);
assert!(errors.any());
assert!(!errors.is_empty());
}
#[test]
fn add_with_details_preserves_structured_metadata() {
let mut errors = Errors::new();
let mut details = HashMap::new();
details.insert("count".to_owned(), json!(5));
errors.add_with_details("name", ErrorType::TooShort, "is too short", details);
assert_eq!(errors.details().len(), 1);
assert_eq!(errors.details()[0].details.get("count"), Some(&json!(5)));
}
#[test]
fn delete_removes_only_matching_attribute() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("email", ErrorType::Invalid, "is invalid");
errors.delete("name");
assert_eq!(errors.count(), 1);
assert_eq!(errors.messages_for("email"), vec!["is invalid".to_owned()]);
assert!(errors.on("name").is_empty());
}
#[test]
fn clear_empties_collection() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.clear();
assert!(errors.is_empty());
assert_eq!(errors.count(), 0);
}
#[test]
fn full_messages_humanize_attributes_and_preserve_order() {
let mut errors = Errors::new();
errors.add("first_name", ErrorType::Blank, "can't be blank");
errors.add(
"base",
ErrorType::Custom("state".to_owned()),
"record is invalid",
);
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.full_messages(),
vec![
"First name can't be blank".to_owned(),
"record is invalid".to_owned(),
"Email is invalid".to_owned(),
]
);
}
#[test]
fn full_messages_expand_nested_attributes_with_spaces() {
let mut errors = Errors::new();
errors.add("replies.name", ErrorType::Blank, "can't be blank");
assert_eq!(
errors.full_messages(),
vec!["Replies name can't be blank".to_owned()]
);
}
#[test]
fn on_and_messages_for_return_attribute_specific_entries() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("email", ErrorType::Invalid, "is invalid");
let on_name = errors.on("name");
assert_eq!(on_name.len(), 2);
assert_eq!(on_name[0].message, "can't be blank");
assert_eq!(
errors.messages_for("name"),
vec!["can't be blank".to_owned(), "is too short".to_owned()]
);
assert_eq!(
errors.full_messages_for("name"),
vec![
"Name can't be blank".to_owned(),
"Name is too short".to_owned()
]
);
}
#[test]
fn attributes_returns_unique_names_in_first_seen_order() {
let mut errors = Errors::new();
errors.add("email", ErrorType::Invalid, "is invalid");
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Taken, "has already been taken");
assert_eq!(errors.attributes(), vec!["email", "name"]);
}
#[test]
fn has_key_and_where_filters_match_expected_errors() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("email", ErrorType::Blank, "can't be blank");
assert!(errors.has_key("name"));
assert!(!errors.has_key("age"));
assert_eq!(errors.where_attr("name").len(), 2);
assert_eq!(errors.where_type(&ErrorType::Blank).len(), 2);
assert_eq!(errors.where_attr_type("name", &ErrorType::Blank).len(), 1);
}
#[test]
fn to_hash_groups_messages_per_attribute() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("email", ErrorType::Invalid, "is invalid");
let grouped = errors.to_hash();
assert_eq!(
grouped.get("name"),
Some(&vec![
"can't be blank".to_owned(),
"is too short".to_owned()
])
);
assert_eq!(grouped.get("email"), Some(&vec!["is invalid".to_owned()]));
}
#[test]
fn as_json_matches_grouped_messages() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.as_json(),
json!({
"name": ["can't be blank"],
"email": ["is invalid"],
})
);
}
#[test]
fn display_joins_full_messages() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(errors.to_string(), "Name can't be blank, Email is invalid");
}
#[test]
fn iterating_by_reference_yields_errors_in_insertion_order() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
let collected = (&errors)
.into_iter()
.map(|error| error.attribute.as_str())
.collect::<Vec<_>>();
assert_eq!(collected, vec!["name", "email"]);
}
#[test]
fn empty_errors_render_as_empty_collections() {
let errors = Errors::new();
assert_eq!(errors.to_string(), "");
assert!(errors.to_hash().is_empty());
assert_eq!(errors.as_json(), json!({}));
}
#[test]
fn deleting_missing_attribute_is_a_noop() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.delete("email");
assert_eq!(errors.count(), 1);
assert_eq!(
errors.messages_for("name"),
vec!["can't be blank".to_owned()]
);
}
#[test]
fn queries_for_missing_attributes_return_empty_results() {
let errors = Errors::new();
assert!(errors.on("name").is_empty());
assert!(errors.where_attr("name").is_empty());
assert!(errors.full_messages_for("name").is_empty());
assert!(errors.messages_for("name").is_empty());
}
#[test]
fn base_errors_keep_raw_full_messages() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
assert_eq!(
errors.full_messages_for("base"),
vec!["record is invalid".to_owned()]
);
}
#[test]
fn custom_error_types_can_be_filtered() {
let mut errors = Errors::new();
let custom = ErrorType::Custom("state".to_owned());
errors.add("status", custom.clone(), "is unsupported");
let matches = errors.where_type(&custom);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].attribute, "status");
}
#[test]
fn details_preserve_insertion_order() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
let details = errors.details();
assert_eq!(details[0].attribute, "name");
assert_eq!(details[1].attribute, "email");
}
#[test]
fn has_key_detects_base_errors() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
assert!(errors.has_key("base"));
assert!(!errors.has_key("name"));
}
#[test]
fn where_attr_type_returns_empty_when_no_match_exists() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
assert!(
errors
.where_attr_type("name", &ErrorType::Invalid)
.is_empty()
);
}
#[test]
fn full_messages_for_returns_only_matching_attribute() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.full_messages_for("email"),
vec!["Email is invalid".to_owned()]
);
}
#[test]
fn attributes_remain_unique_after_deleting_and_readding() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
errors.delete("name");
errors.add("name", ErrorType::TooShort, "is too short");
assert_eq!(errors.attributes(), vec!["email", "name"]);
}
#[test]
fn any_is_false_for_new_collection() {
let errors = Errors::new();
assert!(!errors.any());
assert!(errors.is_empty());
}
#[test]
fn count_tracks_base_and_attribute_errors() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
assert_eq!(errors.count(), 3);
}
#[test]
fn add_with_details_preserves_nested_metadata_structures() {
let mut errors = Errors::new();
let mut details = HashMap::new();
details.insert("range".to_owned(), json!({ "min": 2, "max": 5 }));
details.insert("source".to_owned(), json!(["validation", "length"]));
errors.add_with_details("name", ErrorType::TooShort, "is too short", details);
assert_eq!(
errors.details()[0].details.get("range"),
Some(&json!({ "min": 2, "max": 5 }))
);
assert_eq!(
errors.details()[0].details.get("source"),
Some(&json!(["validation", "length"]))
);
}
#[test]
fn full_messages_humanize_multiword_attributes() {
let mut errors = Errors::new();
errors.add("line_items_count", ErrorType::TooLong, "is too large");
assert_eq!(
errors.full_messages(),
vec!["Line items count is too large".to_owned()]
);
}
#[test]
fn full_messages_for_base_returns_only_raw_messages() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
errors.add(
"base",
ErrorType::Custom("state".to_owned()),
"cannot transition",
);
errors.add("name", ErrorType::Blank, "can't be blank");
assert_eq!(
errors.full_messages_for("base"),
vec![
"record is invalid".to_owned(),
"cannot transition".to_owned()
]
);
}
#[test]
fn messages_for_base_returns_raw_messages_in_order() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
errors.add(
"base",
ErrorType::Custom("state".to_owned()),
"cannot transition",
);
assert_eq!(
errors.messages_for("base"),
vec![
"record is invalid".to_owned(),
"cannot transition".to_owned()
]
);
}
#[test]
fn on_returns_matching_errors_in_insertion_order() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("name", ErrorType::Taken, "has already been taken");
let messages = errors
.on("name")
.into_iter()
.map(|error| error.message.as_str())
.collect::<Vec<_>>();
assert_eq!(
messages,
vec!["can't be blank", "is too short", "has already been taken"]
);
}
#[test]
fn where_type_returns_matching_errors_in_insertion_order() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
errors.add("title", ErrorType::Blank, "can't be blank");
let attributes = errors
.where_type(&ErrorType::Blank)
.into_iter()
.map(|error| error.attribute.as_str())
.collect::<Vec<_>>();
assert_eq!(attributes, vec!["name", "title"]);
}
#[test]
fn where_attr_type_matches_custom_error_types() {
let mut errors = Errors::new();
let state = ErrorType::Custom("state".to_owned());
errors.add("status", state.clone(), "is unsupported");
errors.add("status", ErrorType::Invalid, "is invalid");
let matches = errors.where_attr_type("status", &state);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].message, "is unsupported");
}
#[test]
fn delete_removes_only_base_errors() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
errors.add("name", ErrorType::Blank, "can't be blank");
errors.delete("base");
assert!(errors.full_messages_for("base").is_empty());
assert_eq!(
errors.full_messages(),
vec!["Name can't be blank".to_owned()]
);
}
#[test]
fn clear_removes_attributes_and_details() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
errors.add("name", ErrorType::Blank, "can't be blank");
errors.clear();
assert!(errors.attributes().is_empty());
assert!(errors.details().is_empty());
assert!(errors.full_messages().is_empty());
}
#[test]
fn to_hash_preserves_message_order_for_each_attribute() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("name", ErrorType::Taken, "has already been taken");
assert_eq!(
errors.to_hash().get("name"),
Some(&vec![
"can't be blank".to_owned(),
"is too short".to_owned(),
"has already been taken".to_owned(),
])
);
}
#[test]
fn as_json_includes_base_messages() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
errors.add("name", ErrorType::Blank, "can't be blank");
assert_eq!(
errors.as_json(),
json!({
"base": ["record is invalid"],
"name": ["can't be blank"],
})
);
}
#[test]
fn details_expose_message_and_metadata() {
let mut errors = Errors::new();
let mut details = HashMap::new();
details.insert("count".to_owned(), json!(3));
errors.add_with_details("name", ErrorType::TooShort, "is too short", details);
assert_eq!(errors.details()[0].message, "is too short");
assert_eq!(errors.details()[0].details.get("count"), Some(&json!(3)));
}
#[test]
fn attributes_include_base_in_first_seen_position() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add(
"base",
ErrorType::Custom("state".to_owned()),
"cannot transition",
);
assert_eq!(errors.attributes(), vec!["base", "name"]);
}
#[test]
fn has_key_is_case_sensitive() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
assert!(errors.has_key("name"));
assert!(!errors.has_key("Name"));
}
#[test]
fn iterating_empty_errors_returns_none() {
let errors = Errors::new();
assert_eq!((&errors).into_iter().next(), None);
}
#[test]
fn deleting_last_error_leaves_collection_empty() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.delete("name");
assert!(errors.is_empty());
assert_eq!(errors.count(), 0);
}
#[test]
fn full_messages_reflect_delete_and_readd_cycles() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.delete("name");
errors.add("name", ErrorType::TooShort, "is too short");
assert_eq!(errors.full_messages(), vec!["Name is too short".to_owned()]);
}
#[test]
fn where_attr_returns_base_entries() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
errors.add(
"base",
ErrorType::Custom("state".to_owned()),
"cannot transition",
);
errors.add("name", ErrorType::Blank, "can't be blank");
let messages = errors
.where_attr("base")
.into_iter()
.map(|error| error.message.as_str())
.collect::<Vec<_>>();
assert_eq!(messages, vec!["record is invalid", "cannot transition"]);
}
#[test]
fn full_messages_for_missing_attribute_stays_empty_with_base_errors_present() {
let mut errors = Errors::new();
errors.add("base", ErrorType::Invalid, "record is invalid");
assert!(errors.full_messages_for("email").is_empty());
}
#[test]
fn details_are_empty_for_new_collection() {
let errors = Errors::new();
assert!(errors.details().is_empty());
}
}
#[cfg(test)]
mod rails_port_tests {
use std::collections::HashMap;
use serde_json::json;
use super::{Error, ErrorType, Errors};
macro_rules! rails_ignored_test {
($name:ident, $reason:literal) => {
#[test]
#[ignore = $reason]
fn $name() {
let _ = $reason;
}
};
}
#[test]
fn rails_clear_errors() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.clear();
assert!(errors.is_empty());
assert_eq!(errors.count(), 0);
}
#[test]
fn rails_attribute_names_returns_the_error_attributes() {
let mut errors = Errors::new();
errors.add("foo", ErrorType::Invalid, "omg");
errors.add("baz", ErrorType::Invalid, "zomg");
assert_eq!(errors.attributes(), vec!["foo", "baz"]);
}
#[test]
fn rails_attribute_names_only_returns_unique_attribute_names() {
let mut errors = Errors::new();
errors.add("foo", ErrorType::Invalid, "omg");
errors.add("foo", ErrorType::Custom("alt".to_owned()), "zomg");
assert_eq!(errors.attributes(), vec!["foo"]);
}
#[test]
fn rails_detecting_whether_there_are_errors_with_empty_blank_include() {
let mut errors = Errors::new();
assert!(errors.is_empty());
assert!(!errors.any());
assert!(!errors.has_key("foo"));
errors.add("foo", ErrorType::Invalid, "new error");
assert!(!errors.is_empty());
assert!(errors.any());
assert!(errors.has_key("foo"));
}
#[test]
fn rails_add_with_type_as_string() {
let mut errors = Errors::new();
errors.add(
"name",
ErrorType::Custom("custom msg".to_owned()),
"custom msg",
);
assert_eq!(errors.messages_for("name"), vec!["custom msg".to_owned()]);
}
#[test]
fn rails_add_an_error_message_on_a_specific_attribute_with_a_defined_type() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "cannot be blank");
assert_eq!(
errors.messages_for("name"),
vec!["cannot be blank".to_owned()]
);
assert_eq!(errors.where_type(&ErrorType::Blank).len(), 1);
}
#[test]
fn rails_size_calculates_the_number_of_error_messages() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(errors.count(), 2);
}
#[test]
fn rails_to_a_returns_the_list_of_errors_with_complete_messages_containing_the_attribute_names()
{
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.full_messages(),
vec![
"Name can't be blank".to_owned(),
"Email is invalid".to_owned()
],
);
}
#[test]
fn rails_to_hash_returns_the_error_messages_hash() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.to_hash(),
HashMap::from([
(
"name".to_owned(),
vec!["can't be blank".to_owned(), "is too short".to_owned()],
),
("email".to_owned(), vec!["is invalid".to_owned()]),
]),
);
}
#[test]
fn rails_messages_for_contains_all_the_error_messages_for_the_given_attribute() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.messages_for("name"),
vec!["can't be blank".to_owned(), "is too short".to_owned()],
);
}
#[test]
fn rails_full_messages_for_contains_all_the_error_messages_for_the_given_attribute() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.full_messages_for("name"),
vec![
"Name can't be blank".to_owned(),
"Name is too short".to_owned(),
],
);
}
#[test]
fn rails_full_messages_for_returns_an_empty_list_in_case_there_are_no_errors_for_the_given_attribute()
{
let errors = Errors::new();
assert!(errors.full_messages_for("name").is_empty());
}
#[test]
fn rails_full_message_returns_the_given_message_when_attribute_is_base() {
let error = Error {
attribute: "base".to_owned(),
error_type: ErrorType::Invalid,
message: "press the button".to_owned(),
details: HashMap::new(),
};
assert_eq!(error.full_message(), "press the button");
}
#[test]
fn rails_full_message_returns_the_given_message_with_the_attribute_name_included() {
let error = Error {
attribute: "name".to_owned(),
error_type: ErrorType::Blank,
message: "can't be blank".to_owned(),
details: HashMap::new(),
};
assert_eq!(error.full_message(), "Name can't be blank");
}
#[test]
fn rails_details_returns_added_error_detail() {
let mut errors = Errors::new();
let mut details = HashMap::new();
details.insert("count".to_owned(), json!(25));
errors.add_with_details("name", ErrorType::TooShort, "is too short", details);
assert_eq!(errors.details()[0].details.get("count"), Some(&json!(25)));
}
#[test]
fn rails_equality_by_base_attribute_type_and_options() {
let first = Error {
attribute: "name".to_owned(),
error_type: ErrorType::TooShort,
message: "is too short".to_owned(),
details: HashMap::from([("count".to_owned(), json!(5))]),
};
let second = Error {
attribute: "name".to_owned(),
error_type: ErrorType::TooShort,
message: "is too short".to_owned(),
details: HashMap::from([("count".to_owned(), json!(5))]),
};
assert_eq!(first, second);
}
#[test]
fn rails_inequality() {
let first = Error {
attribute: "name".to_owned(),
error_type: ErrorType::TooShort,
message: "is too short".to_owned(),
details: HashMap::from([("count".to_owned(), json!(5))]),
};
let second = Error {
attribute: "title".to_owned(),
error_type: ErrorType::TooShort,
message: "is too short".to_owned(),
details: HashMap::from([("count".to_owned(), json!(5))]),
};
assert_ne!(first, second);
}
#[test]
fn rails_initialize_without_type() {
let error = Error::new("name", "is invalid");
assert_eq!(error.attribute, "name");
assert_eq!(error.error_type, ErrorType::Invalid);
assert_eq!(error.message, "is invalid");
assert!(error.details.is_empty());
}
#[test]
fn rails_initialize_without_type_but_with_options() {
let error = Error::new_with_default_details(
"name",
"is invalid",
HashMap::from([("count".to_string(), json!(2))]),
);
assert_eq!(error.error_type, ErrorType::Invalid);
assert_eq!(error.details.get("count"), Some(&json!(2)));
}
#[test]
fn rails_match_handles_mixed_condition() {
let error = Error::new_with_details(
"name",
ErrorType::TooShort,
"is too short",
HashMap::from([("count".to_string(), json!(5))]),
);
let details = HashMap::from([("count".to_string(), json!(5))]);
assert!(error.matches(Some("name"), Some(&ErrorType::TooShort), Some(&details)));
assert!(!error.matches(Some("email"), Some(&ErrorType::TooShort), Some(&details)));
}
#[test]
fn rails_match_handles_attribute_match() {
let error = Error::new("name", "can't be blank");
assert!(error.matches(Some("name"), None, None));
assert!(!error.matches(Some("email"), None, None));
}
#[test]
fn rails_match_handles_error_type_match() {
let error = Error::new_with_type("name", ErrorType::Blank, "can't be blank");
assert!(error.matches(None, Some(&ErrorType::Blank), None));
assert!(!error.matches(None, Some(&ErrorType::Invalid), None));
}
#[test]
fn rails_match_handles_extra_options_match() {
let error = Error::new_with_details(
"name",
ErrorType::TooShort,
"is too short",
HashMap::from([
("count".to_string(), json!(5)),
("minimum".to_string(), json!(2)),
]),
);
assert!(error.matches(
None,
None,
Some(&HashMap::from([("count".to_string(), json!(5))])),
));
assert!(!error.matches(
None,
None,
Some(&HashMap::from([("count".to_string(), json!(7))])),
));
}
#[test]
fn rails_add_creates_an_error_object_and_returns_it() {
let mut errors = Errors::new();
let error = errors.add("name", ErrorType::Blank, "can't be blank");
assert_eq!(error.attribute, "name");
assert_eq!(error.error_type, ErrorType::Blank);
assert_eq!(errors.details(), &[error]);
}
#[test]
fn rails_add_with_type_as_nil() {
let mut errors = Errors::new();
let error = errors.add_message("name", "is invalid");
assert_eq!(error.error_type, ErrorType::Invalid);
assert_eq!(errors.messages_for("name"), vec!["is invalid".to_string()]);
}
#[test]
#[ignore = "Proc-evaluated messages are Ruby-specific"]
fn rails_add_with_type_as_proc() {}
#[test]
fn rails_added_predicates() {
let mut errors = Errors::new();
errors.add_with_details(
"name",
ErrorType::TooShort,
"is too short",
HashMap::from([("count".to_string(), json!(5))]),
);
assert!(errors.added(
"name",
&ErrorType::TooShort,
Some(&HashMap::from([("count".to_string(), json!(5))])),
));
assert!(!errors.added(
"name",
&ErrorType::TooShort,
Some(&HashMap::from([("count".to_string(), json!(7))])),
));
}
#[test]
fn rails_of_kind_predicates() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
assert!(errors.of_kind("name", &ErrorType::Blank));
assert!(!errors.of_kind("name", &ErrorType::Invalid));
}
#[test]
fn rails_as_json_with_full_messages_option() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("base", ErrorType::Invalid, "record is invalid");
assert_eq!(
errors.as_json_with_full_messages(),
json!({
"name": ["Name can't be blank"],
"base": ["record is invalid"],
}),
);
}
#[test]
fn rails_generate_message_works_without_i18n_scope() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
assert_eq!(
errors.full_messages(),
vec!["Name can't be blank".to_owned()],
);
}
#[test]
fn rails_group_by_attribute() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("email", ErrorType::Invalid, "is invalid");
let grouped = errors.group_by_attribute();
assert_eq!(grouped["name"].len(), 2);
assert_eq!(grouped["email"].len(), 1);
}
#[test]
fn rails_dup_duplicates_details() {
let mut errors = Errors::new();
errors.add_with_details(
"name",
ErrorType::TooShort,
"is too short",
HashMap::from([("count".to_string(), json!(5))]),
);
let duplicated = errors.clone();
assert_eq!(duplicated, errors);
assert_eq!(
duplicated.details()[0].details.get("count"),
Some(&json!(5))
);
}
#[test]
fn rails_delete_returns_the_deleted_messages() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
errors.add("name", ErrorType::TooShort, "is too short");
errors.add("email", ErrorType::Invalid, "is invalid");
assert_eq!(
errors.delete("name"),
vec!["can't be blank".to_string(), "is too short".to_string()]
);
assert_eq!(errors.messages_for("email"), vec!["is invalid".to_string()]);
}
#[test]
fn rails_copy_errors() {
let mut source = Errors::new();
source.add("name", ErrorType::Blank, "can't be blank");
let mut target = Errors::new();
target.copy_from(&source);
assert_eq!(target, source);
}
#[test]
fn rails_merge_errors() {
let mut left = Errors::new();
left.add("name", ErrorType::Blank, "can't be blank");
let mut right = Errors::new();
right.add("email", ErrorType::Invalid, "is invalid");
left.merge(&right);
assert_eq!(left.count(), 2);
assert_eq!(left.attributes(), vec!["name", "email"]);
}
#[test]
fn rails_merge_does_not_import_errors_when_merging_with_self() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
let snapshot = errors.clone();
errors.merge(&snapshot);
assert_eq!(errors.count(), 2);
let before_self_merge = errors.clone();
errors.merge(&before_self_merge);
assert_eq!(errors.count(), 4);
}
#[test]
fn rails_errors_are_marshalable() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
let _ = errors.clone();
}
#[test]
#[ignore = "Rails YAML compatibility is outside rustrails-model scope"]
fn rails_errors_are_compatible_with_yaml_dumped_from_rails_6() {}
#[test]
fn rails_inspect() {
let mut errors = Errors::new();
errors.add("name", ErrorType::Blank, "can't be blank");
let inspect = format!("{errors:?}");
assert!(inspect.contains("Errors"));
assert!(inspect.contains("name"));
assert!(inspect.contains("can't be blank"));
}
}