Skip to main content

rustrails_model/
errors.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use indexmap::IndexMap;
5use rustrails_support::inflector::humanize;
6use serde::Serialize;
7use serde_json::Value;
8
9/// Machine-readable validation error kinds.
10#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
11pub enum ErrorType {
12    /// The attribute must be present but is blank.
13    Blank,
14    /// The attribute value is invalid.
15    Invalid,
16    /// The attribute is shorter than the minimum length.
17    TooShort,
18    /// The attribute is longer than the maximum length.
19    TooLong,
20    /// The attribute length does not match the required value.
21    WrongLength,
22    /// The attribute is not numeric.
23    NotANumber,
24    /// The attribute is numeric but not an integer.
25    NotAnInteger,
26    /// The attribute must be greater than another value.
27    GreaterThan,
28    /// The attribute must be less than another value.
29    LessThan,
30    /// The attribute must equal another value.
31    EqualTo,
32    /// The attribute must differ from another value.
33    OtherThan,
34    /// The attribute must be greater than or equal to another value.
35    GreaterThanOrEqualTo,
36    /// The attribute must be less than or equal to another value.
37    LessThanOrEqualTo,
38    /// The attribute must be unique.
39    Taken,
40    /// The attribute must be accepted.
41    Accepted,
42    /// The attribute confirmation does not match.
43    Confirmation,
44    /// The attribute must not be empty.
45    Empty,
46    /// The attribute is excluded from an allowed set.
47    Exclusion,
48    /// The attribute is not included in an allowed set.
49    Inclusion,
50    /// A custom application-defined error kind.
51    Custom(String),
52}
53
54/// A single validation error associated with a model attribute.
55#[derive(Debug, Clone, PartialEq, Serialize)]
56pub struct Error {
57    /// Name of the attribute that produced the error.
58    pub attribute: String,
59    /// Machine-readable error category.
60    pub error_type: ErrorType,
61    /// Human-readable error message.
62    pub message: String,
63    /// Extra structured metadata for the error.
64    pub details: HashMap<String, Value>,
65}
66
67impl Error {
68    /// Builds an invalid error with no extra details.
69    pub fn new(attribute: impl Into<String>, message: impl Into<String>) -> Self {
70        Self::new_with_type(attribute, ErrorType::Invalid, message)
71    }
72
73    /// Builds an error with an explicit type and no extra details.
74    pub fn new_with_type(
75        attribute: impl Into<String>,
76        error_type: ErrorType,
77        message: impl Into<String>,
78    ) -> Self {
79        Self::new_with_details(attribute, error_type, message, HashMap::new())
80    }
81
82    /// Builds an invalid error with structured details.
83    pub fn new_with_default_details(
84        attribute: impl Into<String>,
85        message: impl Into<String>,
86        details: HashMap<String, Value>,
87    ) -> Self {
88        Self::new_with_details(attribute, ErrorType::Invalid, message, details)
89    }
90
91    /// Builds an error with an explicit type and structured details.
92    pub fn new_with_details(
93        attribute: impl Into<String>,
94        error_type: ErrorType,
95        message: impl Into<String>,
96        details: HashMap<String, Value>,
97    ) -> Self {
98        Self {
99            attribute: attribute.into(),
100            error_type,
101            message: message.into(),
102            details,
103        }
104    }
105
106    pub fn full_message(&self) -> String {
107        if self.attribute == "base" {
108            self.message.clone()
109        } else {
110            format!(
111                "{} {}",
112                humanize_error_attribute(&self.attribute),
113                self.message
114            )
115        }
116    }
117
118    /// Returns true when every provided condition matches this error.
119    pub fn matches(
120        &self,
121        attribute: Option<&str>,
122        error_type: Option<&ErrorType>,
123        details: Option<&HashMap<String, Value>>,
124    ) -> bool {
125        if let Some(attribute) = attribute
126            && self.attribute != attribute
127        {
128            return false;
129        }
130
131        if let Some(error_type) = error_type
132            && &self.error_type != error_type
133        {
134            return false;
135        }
136
137        if let Some(details) = details
138            && !details
139                .iter()
140                .all(|(key, value)| self.details.get(key) == Some(value))
141        {
142            return false;
143        }
144
145        true
146    }
147}
148
149fn humanize_error_attribute(attribute: &str) -> String {
150    humanize(&attribute.replace('.', "_"))
151}
152
153/// Ordered collection of validation errors for a model instance.
154#[derive(Debug, Clone, Default, PartialEq, Serialize)]
155pub struct Errors {
156    errors: Vec<Error>,
157}
158
159impl Errors {
160    /// Creates an empty error collection.
161    #[must_use]
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Adds an error without structured details.
167    pub fn add(
168        &mut self,
169        attribute: &str,
170        error_type: ErrorType,
171        message: impl Into<String>,
172    ) -> Error {
173        self.add_with_details(attribute, error_type, message, HashMap::new())
174    }
175
176    /// Adds an invalid error with a plain string message.
177    pub fn add_message(&mut self, attribute: &str, message: impl Into<String>) -> Error {
178        self.add(attribute, ErrorType::Invalid, message)
179    }
180
181    /// Adds an error with structured details.
182    pub fn add_with_details(
183        &mut self,
184        attribute: &str,
185        error_type: ErrorType,
186        message: impl Into<String>,
187        details: HashMap<String, Value>,
188    ) -> Error {
189        let error = Error::new_with_details(attribute, error_type, message, details);
190        self.errors.push(error.clone());
191        error
192    }
193
194    /// Removes every error associated with the given attribute and returns their messages.
195    pub fn delete(&mut self, attribute: &str) -> Vec<String> {
196        let mut removed = Vec::new();
197        self.errors.retain(|error| {
198            if error.attribute == attribute {
199                removed.push(error.message.clone());
200                false
201            } else {
202                true
203            }
204        });
205        removed
206    }
207
208    /// Removes every error from the collection.
209    pub fn clear(&mut self) {
210        self.errors.clear();
211    }
212
213    /// Returns every error attached to the given attribute in insertion order.
214    #[must_use]
215    pub fn on(&self, attribute: &str) -> Vec<&Error> {
216        self.where_attr(attribute)
217    }
218
219    /// Returns `true` when no errors are present.
220    #[must_use]
221    pub fn is_empty(&self) -> bool {
222        self.errors.is_empty()
223    }
224
225    /// Returns the number of stored errors.
226    #[must_use]
227    pub fn count(&self) -> usize {
228        self.errors.len()
229    }
230
231    /// Returns `true` when at least one error is present.
232    #[must_use]
233    pub fn any(&self) -> bool {
234        !self.is_empty()
235    }
236
237    /// Returns `true` when an error matching the provided conditions exists.
238    #[must_use]
239    pub fn added(
240        &self,
241        attribute: &str,
242        error_type: &ErrorType,
243        details: Option<&HashMap<String, Value>>,
244    ) -> bool {
245        self.errors
246            .iter()
247            .any(|error| error.matches(Some(attribute), Some(error_type), details))
248    }
249
250    /// Returns `true` when an attribute has at least one error of the given kind.
251    #[must_use]
252    pub fn of_kind(&self, attribute: &str, error_type: &ErrorType) -> bool {
253        self.added(attribute, error_type, None)
254    }
255
256    /// Returns every error formatted as a human-readable full message.
257    #[must_use]
258    pub fn full_messages(&self) -> Vec<String> {
259        self.errors.iter().map(Error::full_message).collect()
260    }
261
262    /// Returns full messages for a single attribute in insertion order.
263    #[must_use]
264    pub fn full_messages_for(&self, attribute: &str) -> Vec<String> {
265        self.where_attr(attribute)
266            .into_iter()
267            .map(Error::full_message)
268            .collect()
269    }
270
271    /// Returns the raw message strings for a single attribute in insertion order.
272    #[must_use]
273    pub fn messages_for(&self, attribute: &str) -> Vec<String> {
274        self.where_attr(attribute)
275            .into_iter()
276            .map(|error| error.message.clone())
277            .collect()
278    }
279
280    /// Returns each attribute name with at least one error, preserving first-seen order.
281    #[must_use]
282    pub fn attributes(&self) -> Vec<&str> {
283        let mut attributes = Vec::new();
284        for error in &self.errors {
285            let attribute = error.attribute.as_str();
286            if !attributes.contains(&attribute) {
287                attributes.push(attribute);
288            }
289        }
290        attributes
291    }
292
293    /// Returns the stored error details in insertion order.
294    #[must_use]
295    pub fn details(&self) -> &[Error] {
296        &self.errors
297    }
298
299    /// Returns `true` when the given attribute has at least one error.
300    #[must_use]
301    pub fn has_key(&self, attribute: &str) -> bool {
302        self.errors.iter().any(|error| error.attribute == attribute)
303    }
304
305    /// Groups messages by attribute.
306    #[must_use]
307    pub fn to_hash(&self) -> HashMap<String, Vec<String>> {
308        self.grouped_messages()
309            .into_iter()
310            .map(|(attribute, messages)| (attribute, messages.to_vec()))
311            .collect()
312    }
313
314    /// Serializes grouped messages as a JSON object.
315    #[must_use]
316    pub fn as_json(&self) -> Value {
317        self.json_from_grouped_messages(false)
318    }
319
320    /// Serializes grouped full messages as a JSON object.
321    #[must_use]
322    pub fn as_json_with_full_messages(&self) -> Value {
323        self.json_from_grouped_messages(true)
324    }
325
326    /// Groups cloned errors by attribute in insertion order.
327    #[must_use]
328    pub fn group_by_attribute(&self) -> IndexMap<String, Vec<Error>> {
329        let mut grouped = IndexMap::new();
330        for error in &self.errors {
331            grouped
332                .entry(error.attribute.clone())
333                .or_insert_with(Vec::new)
334                .push(error.clone());
335        }
336        grouped
337    }
338
339    /// Appends cloned errors from another collection.
340    pub fn copy_from(&mut self, other: &Self) {
341        self.merge(other);
342    }
343
344    /// Appends cloned errors from another collection, ignoring self-merges.
345    pub fn merge(&mut self, other: &Self) {
346        if std::ptr::eq(self as *const Self, other as *const Self) {
347            return;
348        }
349        self.errors.extend(other.errors.iter().cloned());
350    }
351
352    /// Returns errors for a single attribute in insertion order.
353    #[must_use]
354    pub fn where_attr(&self, attribute: &str) -> Vec<&Error> {
355        self.errors
356            .iter()
357            .filter(|error| error.attribute == attribute)
358            .collect()
359    }
360
361    /// Returns errors of a single type in insertion order.
362    #[must_use]
363    pub fn where_type(&self, error_type: &ErrorType) -> Vec<&Error> {
364        self.errors
365            .iter()
366            .filter(|error| &error.error_type == error_type)
367            .collect()
368    }
369
370    /// Returns errors matching both an attribute and an error type in insertion order.
371    #[must_use]
372    pub fn where_attr_type(&self, attribute: &str, error_type: &ErrorType) -> Vec<&Error> {
373        self.errors
374            .iter()
375            .filter(|error| error.attribute == attribute && &error.error_type == error_type)
376            .collect()
377    }
378
379    fn grouped_messages(&self) -> IndexMap<String, Vec<String>> {
380        let mut grouped = IndexMap::new();
381        for error in &self.errors {
382            grouped
383                .entry(error.attribute.clone())
384                .or_insert_with(Vec::new)
385                .push(error.message.clone());
386        }
387        grouped
388    }
389
390    fn json_from_grouped_messages(&self, full_messages: bool) -> Value {
391        let mut object = serde_json::Map::new();
392        for (attribute, errors) in self.group_by_attribute() {
393            let values = errors
394                .into_iter()
395                .map(|error| {
396                    if full_messages {
397                        Value::String(error.full_message())
398                    } else {
399                        Value::String(error.message)
400                    }
401                })
402                .collect();
403            object.insert(attribute, Value::Array(values));
404        }
405        Value::Object(object)
406    }
407}
408
409impl fmt::Display for Errors {
410    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
411        formatter.write_str(&self.full_messages().join(", "))
412    }
413}
414
415impl<'a> IntoIterator for &'a Errors {
416    type Item = &'a Error;
417    type IntoIter = std::slice::Iter<'a, Error>;
418
419    fn into_iter(self) -> Self::IntoIter {
420        self.errors.iter()
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use std::collections::HashMap;
427
428    use serde_json::json;
429
430    use super::{Error, ErrorType, Errors};
431
432    #[test]
433    fn adds_errors_and_reports_count() {
434        let mut errors = Errors::new();
435
436        errors.add("name", ErrorType::Blank, "can't be blank");
437        errors.add("email", ErrorType::Invalid, "is invalid");
438
439        assert_eq!(errors.count(), 2);
440        assert!(errors.any());
441        assert!(!errors.is_empty());
442    }
443
444    #[test]
445    fn add_with_details_preserves_structured_metadata() {
446        let mut errors = Errors::new();
447        let mut details = HashMap::new();
448        details.insert("count".to_owned(), json!(5));
449
450        errors.add_with_details("name", ErrorType::TooShort, "is too short", details);
451
452        assert_eq!(errors.details().len(), 1);
453        assert_eq!(errors.details()[0].details.get("count"), Some(&json!(5)));
454    }
455
456    #[test]
457    fn delete_removes_only_matching_attribute() {
458        let mut errors = Errors::new();
459        errors.add("name", ErrorType::Blank, "can't be blank");
460        errors.add("name", ErrorType::TooShort, "is too short");
461        errors.add("email", ErrorType::Invalid, "is invalid");
462
463        errors.delete("name");
464
465        assert_eq!(errors.count(), 1);
466        assert_eq!(errors.messages_for("email"), vec!["is invalid".to_owned()]);
467        assert!(errors.on("name").is_empty());
468    }
469
470    #[test]
471    fn clear_empties_collection() {
472        let mut errors = Errors::new();
473        errors.add("name", ErrorType::Blank, "can't be blank");
474
475        errors.clear();
476
477        assert!(errors.is_empty());
478        assert_eq!(errors.count(), 0);
479    }
480
481    #[test]
482    fn full_messages_humanize_attributes_and_preserve_order() {
483        let mut errors = Errors::new();
484        errors.add("first_name", ErrorType::Blank, "can't be blank");
485        errors.add(
486            "base",
487            ErrorType::Custom("state".to_owned()),
488            "record is invalid",
489        );
490        errors.add("email", ErrorType::Invalid, "is invalid");
491
492        assert_eq!(
493            errors.full_messages(),
494            vec![
495                "First name can't be blank".to_owned(),
496                "record is invalid".to_owned(),
497                "Email is invalid".to_owned(),
498            ]
499        );
500    }
501
502    #[test]
503    fn full_messages_expand_nested_attributes_with_spaces() {
504        let mut errors = Errors::new();
505        errors.add("replies.name", ErrorType::Blank, "can't be blank");
506
507        assert_eq!(
508            errors.full_messages(),
509            vec!["Replies name can't be blank".to_owned()]
510        );
511    }
512
513    #[test]
514    fn on_and_messages_for_return_attribute_specific_entries() {
515        let mut errors = Errors::new();
516        errors.add("name", ErrorType::Blank, "can't be blank");
517        errors.add("name", ErrorType::TooShort, "is too short");
518        errors.add("email", ErrorType::Invalid, "is invalid");
519
520        let on_name = errors.on("name");
521
522        assert_eq!(on_name.len(), 2);
523        assert_eq!(on_name[0].message, "can't be blank");
524        assert_eq!(
525            errors.messages_for("name"),
526            vec!["can't be blank".to_owned(), "is too short".to_owned()]
527        );
528        assert_eq!(
529            errors.full_messages_for("name"),
530            vec![
531                "Name can't be blank".to_owned(),
532                "Name is too short".to_owned()
533            ]
534        );
535    }
536
537    #[test]
538    fn attributes_returns_unique_names_in_first_seen_order() {
539        let mut errors = Errors::new();
540        errors.add("email", ErrorType::Invalid, "is invalid");
541        errors.add("name", ErrorType::Blank, "can't be blank");
542        errors.add("email", ErrorType::Taken, "has already been taken");
543
544        assert_eq!(errors.attributes(), vec!["email", "name"]);
545    }
546
547    #[test]
548    fn has_key_and_where_filters_match_expected_errors() {
549        let mut errors = Errors::new();
550        errors.add("name", ErrorType::Blank, "can't be blank");
551        errors.add("name", ErrorType::TooShort, "is too short");
552        errors.add("email", ErrorType::Blank, "can't be blank");
553
554        assert!(errors.has_key("name"));
555        assert!(!errors.has_key("age"));
556        assert_eq!(errors.where_attr("name").len(), 2);
557        assert_eq!(errors.where_type(&ErrorType::Blank).len(), 2);
558        assert_eq!(errors.where_attr_type("name", &ErrorType::Blank).len(), 1);
559    }
560
561    #[test]
562    fn to_hash_groups_messages_per_attribute() {
563        let mut errors = Errors::new();
564        errors.add("name", ErrorType::Blank, "can't be blank");
565        errors.add("name", ErrorType::TooShort, "is too short");
566        errors.add("email", ErrorType::Invalid, "is invalid");
567
568        let grouped = errors.to_hash();
569
570        assert_eq!(
571            grouped.get("name"),
572            Some(&vec![
573                "can't be blank".to_owned(),
574                "is too short".to_owned()
575            ])
576        );
577        assert_eq!(grouped.get("email"), Some(&vec!["is invalid".to_owned()]));
578    }
579
580    #[test]
581    fn as_json_matches_grouped_messages() {
582        let mut errors = Errors::new();
583        errors.add("name", ErrorType::Blank, "can't be blank");
584        errors.add("email", ErrorType::Invalid, "is invalid");
585
586        assert_eq!(
587            errors.as_json(),
588            json!({
589                "name": ["can't be blank"],
590                "email": ["is invalid"],
591            })
592        );
593    }
594
595    #[test]
596    fn display_joins_full_messages() {
597        let mut errors = Errors::new();
598        errors.add("name", ErrorType::Blank, "can't be blank");
599        errors.add("email", ErrorType::Invalid, "is invalid");
600
601        assert_eq!(errors.to_string(), "Name can't be blank, Email is invalid");
602    }
603
604    #[test]
605    fn iterating_by_reference_yields_errors_in_insertion_order() {
606        let mut errors = Errors::new();
607        errors.add("name", ErrorType::Blank, "can't be blank");
608        errors.add("email", ErrorType::Invalid, "is invalid");
609
610        let collected = (&errors)
611            .into_iter()
612            .map(|error| error.attribute.as_str())
613            .collect::<Vec<_>>();
614
615        assert_eq!(collected, vec!["name", "email"]);
616    }
617    #[test]
618    fn empty_errors_render_as_empty_collections() {
619        let errors = Errors::new();
620
621        assert_eq!(errors.to_string(), "");
622        assert!(errors.to_hash().is_empty());
623        assert_eq!(errors.as_json(), json!({}));
624    }
625
626    #[test]
627    fn deleting_missing_attribute_is_a_noop() {
628        let mut errors = Errors::new();
629        errors.add("name", ErrorType::Blank, "can't be blank");
630
631        errors.delete("email");
632
633        assert_eq!(errors.count(), 1);
634        assert_eq!(
635            errors.messages_for("name"),
636            vec!["can't be blank".to_owned()]
637        );
638    }
639
640    #[test]
641    fn queries_for_missing_attributes_return_empty_results() {
642        let errors = Errors::new();
643
644        assert!(errors.on("name").is_empty());
645        assert!(errors.where_attr("name").is_empty());
646        assert!(errors.full_messages_for("name").is_empty());
647        assert!(errors.messages_for("name").is_empty());
648    }
649
650    #[test]
651    fn base_errors_keep_raw_full_messages() {
652        let mut errors = Errors::new();
653        errors.add("base", ErrorType::Invalid, "record is invalid");
654
655        assert_eq!(
656            errors.full_messages_for("base"),
657            vec!["record is invalid".to_owned()]
658        );
659    }
660
661    #[test]
662    fn custom_error_types_can_be_filtered() {
663        let mut errors = Errors::new();
664        let custom = ErrorType::Custom("state".to_owned());
665        errors.add("status", custom.clone(), "is unsupported");
666
667        let matches = errors.where_type(&custom);
668        assert_eq!(matches.len(), 1);
669        assert_eq!(matches[0].attribute, "status");
670    }
671
672    #[test]
673    fn details_preserve_insertion_order() {
674        let mut errors = Errors::new();
675        errors.add("name", ErrorType::Blank, "can't be blank");
676        errors.add("email", ErrorType::Invalid, "is invalid");
677
678        let details = errors.details();
679        assert_eq!(details[0].attribute, "name");
680        assert_eq!(details[1].attribute, "email");
681    }
682
683    #[test]
684    fn has_key_detects_base_errors() {
685        let mut errors = Errors::new();
686        errors.add("base", ErrorType::Invalid, "record is invalid");
687
688        assert!(errors.has_key("base"));
689        assert!(!errors.has_key("name"));
690    }
691
692    #[test]
693    fn where_attr_type_returns_empty_when_no_match_exists() {
694        let mut errors = Errors::new();
695        errors.add("name", ErrorType::Blank, "can't be blank");
696
697        assert!(
698            errors
699                .where_attr_type("name", &ErrorType::Invalid)
700                .is_empty()
701        );
702    }
703
704    #[test]
705    fn full_messages_for_returns_only_matching_attribute() {
706        let mut errors = Errors::new();
707        errors.add("name", ErrorType::Blank, "can't be blank");
708        errors.add("email", ErrorType::Invalid, "is invalid");
709
710        assert_eq!(
711            errors.full_messages_for("email"),
712            vec!["Email is invalid".to_owned()]
713        );
714    }
715
716    #[test]
717    fn attributes_remain_unique_after_deleting_and_readding() {
718        let mut errors = Errors::new();
719        errors.add("name", ErrorType::Blank, "can't be blank");
720        errors.add("email", ErrorType::Invalid, "is invalid");
721        errors.delete("name");
722        errors.add("name", ErrorType::TooShort, "is too short");
723
724        assert_eq!(errors.attributes(), vec!["email", "name"]);
725    }
726
727    #[test]
728    fn any_is_false_for_new_collection() {
729        let errors = Errors::new();
730
731        assert!(!errors.any());
732        assert!(errors.is_empty());
733    }
734
735    #[test]
736    fn count_tracks_base_and_attribute_errors() {
737        let mut errors = Errors::new();
738        errors.add("base", ErrorType::Invalid, "record is invalid");
739        errors.add("name", ErrorType::Blank, "can't be blank");
740        errors.add("name", ErrorType::TooShort, "is too short");
741
742        assert_eq!(errors.count(), 3);
743    }
744
745    #[test]
746    fn add_with_details_preserves_nested_metadata_structures() {
747        let mut errors = Errors::new();
748        let mut details = HashMap::new();
749        details.insert("range".to_owned(), json!({ "min": 2, "max": 5 }));
750        details.insert("source".to_owned(), json!(["validation", "length"]));
751
752        errors.add_with_details("name", ErrorType::TooShort, "is too short", details);
753
754        assert_eq!(
755            errors.details()[0].details.get("range"),
756            Some(&json!({ "min": 2, "max": 5 }))
757        );
758        assert_eq!(
759            errors.details()[0].details.get("source"),
760            Some(&json!(["validation", "length"]))
761        );
762    }
763
764    #[test]
765    fn full_messages_humanize_multiword_attributes() {
766        let mut errors = Errors::new();
767        errors.add("line_items_count", ErrorType::TooLong, "is too large");
768
769        assert_eq!(
770            errors.full_messages(),
771            vec!["Line items count is too large".to_owned()]
772        );
773    }
774
775    #[test]
776    fn full_messages_for_base_returns_only_raw_messages() {
777        let mut errors = Errors::new();
778        errors.add("base", ErrorType::Invalid, "record is invalid");
779        errors.add(
780            "base",
781            ErrorType::Custom("state".to_owned()),
782            "cannot transition",
783        );
784        errors.add("name", ErrorType::Blank, "can't be blank");
785
786        assert_eq!(
787            errors.full_messages_for("base"),
788            vec![
789                "record is invalid".to_owned(),
790                "cannot transition".to_owned()
791            ]
792        );
793    }
794
795    #[test]
796    fn messages_for_base_returns_raw_messages_in_order() {
797        let mut errors = Errors::new();
798        errors.add("base", ErrorType::Invalid, "record is invalid");
799        errors.add(
800            "base",
801            ErrorType::Custom("state".to_owned()),
802            "cannot transition",
803        );
804
805        assert_eq!(
806            errors.messages_for("base"),
807            vec![
808                "record is invalid".to_owned(),
809                "cannot transition".to_owned()
810            ]
811        );
812    }
813
814    #[test]
815    fn on_returns_matching_errors_in_insertion_order() {
816        let mut errors = Errors::new();
817        errors.add("name", ErrorType::Blank, "can't be blank");
818        errors.add("name", ErrorType::TooShort, "is too short");
819        errors.add("name", ErrorType::Taken, "has already been taken");
820
821        let messages = errors
822            .on("name")
823            .into_iter()
824            .map(|error| error.message.as_str())
825            .collect::<Vec<_>>();
826
827        assert_eq!(
828            messages,
829            vec!["can't be blank", "is too short", "has already been taken"]
830        );
831    }
832
833    #[test]
834    fn where_type_returns_matching_errors_in_insertion_order() {
835        let mut errors = Errors::new();
836        errors.add("name", ErrorType::Blank, "can't be blank");
837        errors.add("email", ErrorType::Invalid, "is invalid");
838        errors.add("title", ErrorType::Blank, "can't be blank");
839
840        let attributes = errors
841            .where_type(&ErrorType::Blank)
842            .into_iter()
843            .map(|error| error.attribute.as_str())
844            .collect::<Vec<_>>();
845
846        assert_eq!(attributes, vec!["name", "title"]);
847    }
848
849    #[test]
850    fn where_attr_type_matches_custom_error_types() {
851        let mut errors = Errors::new();
852        let state = ErrorType::Custom("state".to_owned());
853        errors.add("status", state.clone(), "is unsupported");
854        errors.add("status", ErrorType::Invalid, "is invalid");
855
856        let matches = errors.where_attr_type("status", &state);
857
858        assert_eq!(matches.len(), 1);
859        assert_eq!(matches[0].message, "is unsupported");
860    }
861
862    #[test]
863    fn delete_removes_only_base_errors() {
864        let mut errors = Errors::new();
865        errors.add("base", ErrorType::Invalid, "record is invalid");
866        errors.add("name", ErrorType::Blank, "can't be blank");
867
868        errors.delete("base");
869
870        assert!(errors.full_messages_for("base").is_empty());
871        assert_eq!(
872            errors.full_messages(),
873            vec!["Name can't be blank".to_owned()]
874        );
875    }
876
877    #[test]
878    fn clear_removes_attributes_and_details() {
879        let mut errors = Errors::new();
880        errors.add("base", ErrorType::Invalid, "record is invalid");
881        errors.add("name", ErrorType::Blank, "can't be blank");
882
883        errors.clear();
884
885        assert!(errors.attributes().is_empty());
886        assert!(errors.details().is_empty());
887        assert!(errors.full_messages().is_empty());
888    }
889
890    #[test]
891    fn to_hash_preserves_message_order_for_each_attribute() {
892        let mut errors = Errors::new();
893        errors.add("name", ErrorType::Blank, "can't be blank");
894        errors.add("name", ErrorType::TooShort, "is too short");
895        errors.add("name", ErrorType::Taken, "has already been taken");
896
897        assert_eq!(
898            errors.to_hash().get("name"),
899            Some(&vec![
900                "can't be blank".to_owned(),
901                "is too short".to_owned(),
902                "has already been taken".to_owned(),
903            ])
904        );
905    }
906
907    #[test]
908    fn as_json_includes_base_messages() {
909        let mut errors = Errors::new();
910        errors.add("base", ErrorType::Invalid, "record is invalid");
911        errors.add("name", ErrorType::Blank, "can't be blank");
912
913        assert_eq!(
914            errors.as_json(),
915            json!({
916                "base": ["record is invalid"],
917                "name": ["can't be blank"],
918            })
919        );
920    }
921
922    #[test]
923    fn details_expose_message_and_metadata() {
924        let mut errors = Errors::new();
925        let mut details = HashMap::new();
926        details.insert("count".to_owned(), json!(3));
927
928        errors.add_with_details("name", ErrorType::TooShort, "is too short", details);
929
930        assert_eq!(errors.details()[0].message, "is too short");
931        assert_eq!(errors.details()[0].details.get("count"), Some(&json!(3)));
932    }
933
934    #[test]
935    fn attributes_include_base_in_first_seen_position() {
936        let mut errors = Errors::new();
937        errors.add("base", ErrorType::Invalid, "record is invalid");
938        errors.add("name", ErrorType::Blank, "can't be blank");
939        errors.add(
940            "base",
941            ErrorType::Custom("state".to_owned()),
942            "cannot transition",
943        );
944
945        assert_eq!(errors.attributes(), vec!["base", "name"]);
946    }
947
948    #[test]
949    fn has_key_is_case_sensitive() {
950        let mut errors = Errors::new();
951        errors.add("name", ErrorType::Blank, "can't be blank");
952
953        assert!(errors.has_key("name"));
954        assert!(!errors.has_key("Name"));
955    }
956
957    #[test]
958    fn iterating_empty_errors_returns_none() {
959        let errors = Errors::new();
960
961        assert_eq!((&errors).into_iter().next(), None);
962    }
963
964    #[test]
965    fn deleting_last_error_leaves_collection_empty() {
966        let mut errors = Errors::new();
967        errors.add("name", ErrorType::Blank, "can't be blank");
968
969        errors.delete("name");
970
971        assert!(errors.is_empty());
972        assert_eq!(errors.count(), 0);
973    }
974
975    #[test]
976    fn full_messages_reflect_delete_and_readd_cycles() {
977        let mut errors = Errors::new();
978        errors.add("name", ErrorType::Blank, "can't be blank");
979        errors.delete("name");
980        errors.add("name", ErrorType::TooShort, "is too short");
981
982        assert_eq!(errors.full_messages(), vec!["Name is too short".to_owned()]);
983    }
984
985    #[test]
986    fn where_attr_returns_base_entries() {
987        let mut errors = Errors::new();
988        errors.add("base", ErrorType::Invalid, "record is invalid");
989        errors.add(
990            "base",
991            ErrorType::Custom("state".to_owned()),
992            "cannot transition",
993        );
994        errors.add("name", ErrorType::Blank, "can't be blank");
995
996        let messages = errors
997            .where_attr("base")
998            .into_iter()
999            .map(|error| error.message.as_str())
1000            .collect::<Vec<_>>();
1001
1002        assert_eq!(messages, vec!["record is invalid", "cannot transition"]);
1003    }
1004
1005    #[test]
1006    fn full_messages_for_missing_attribute_stays_empty_with_base_errors_present() {
1007        let mut errors = Errors::new();
1008        errors.add("base", ErrorType::Invalid, "record is invalid");
1009
1010        assert!(errors.full_messages_for("email").is_empty());
1011    }
1012
1013    #[test]
1014    fn details_are_empty_for_new_collection() {
1015        let errors = Errors::new();
1016
1017        assert!(errors.details().is_empty());
1018    }
1019}
1020
1021#[cfg(test)]
1022mod rails_port_tests {
1023    use std::collections::HashMap;
1024
1025    use serde_json::json;
1026
1027    use super::{Error, ErrorType, Errors};
1028
1029    macro_rules! rails_ignored_test {
1030        ($name:ident, $reason:literal) => {
1031            #[test]
1032            #[ignore = $reason]
1033            fn $name() {
1034                let _ = $reason;
1035            }
1036        };
1037    }
1038
1039    #[test]
1040    fn rails_clear_errors() {
1041        let mut errors = Errors::new();
1042        errors.add("name", ErrorType::Blank, "can't be blank");
1043
1044        errors.clear();
1045
1046        assert!(errors.is_empty());
1047        assert_eq!(errors.count(), 0);
1048    }
1049
1050    #[test]
1051    fn rails_attribute_names_returns_the_error_attributes() {
1052        let mut errors = Errors::new();
1053        errors.add("foo", ErrorType::Invalid, "omg");
1054        errors.add("baz", ErrorType::Invalid, "zomg");
1055
1056        assert_eq!(errors.attributes(), vec!["foo", "baz"]);
1057    }
1058
1059    #[test]
1060    fn rails_attribute_names_only_returns_unique_attribute_names() {
1061        let mut errors = Errors::new();
1062        errors.add("foo", ErrorType::Invalid, "omg");
1063        errors.add("foo", ErrorType::Custom("alt".to_owned()), "zomg");
1064
1065        assert_eq!(errors.attributes(), vec!["foo"]);
1066    }
1067
1068    #[test]
1069    fn rails_detecting_whether_there_are_errors_with_empty_blank_include() {
1070        let mut errors = Errors::new();
1071        assert!(errors.is_empty());
1072        assert!(!errors.any());
1073        assert!(!errors.has_key("foo"));
1074
1075        errors.add("foo", ErrorType::Invalid, "new error");
1076
1077        assert!(!errors.is_empty());
1078        assert!(errors.any());
1079        assert!(errors.has_key("foo"));
1080    }
1081
1082    #[test]
1083    fn rails_add_with_type_as_string() {
1084        let mut errors = Errors::new();
1085        errors.add(
1086            "name",
1087            ErrorType::Custom("custom msg".to_owned()),
1088            "custom msg",
1089        );
1090
1091        assert_eq!(errors.messages_for("name"), vec!["custom msg".to_owned()]);
1092    }
1093
1094    #[test]
1095    fn rails_add_an_error_message_on_a_specific_attribute_with_a_defined_type() {
1096        let mut errors = Errors::new();
1097        errors.add("name", ErrorType::Blank, "cannot be blank");
1098
1099        assert_eq!(
1100            errors.messages_for("name"),
1101            vec!["cannot be blank".to_owned()]
1102        );
1103        assert_eq!(errors.where_type(&ErrorType::Blank).len(), 1);
1104    }
1105
1106    #[test]
1107    fn rails_size_calculates_the_number_of_error_messages() {
1108        let mut errors = Errors::new();
1109        errors.add("name", ErrorType::Blank, "can't be blank");
1110        errors.add("email", ErrorType::Invalid, "is invalid");
1111
1112        assert_eq!(errors.count(), 2);
1113    }
1114
1115    #[test]
1116    fn rails_to_a_returns_the_list_of_errors_with_complete_messages_containing_the_attribute_names()
1117    {
1118        let mut errors = Errors::new();
1119        errors.add("name", ErrorType::Blank, "can't be blank");
1120        errors.add("email", ErrorType::Invalid, "is invalid");
1121
1122        assert_eq!(
1123            errors.full_messages(),
1124            vec![
1125                "Name can't be blank".to_owned(),
1126                "Email is invalid".to_owned()
1127            ],
1128        );
1129    }
1130
1131    #[test]
1132    fn rails_to_hash_returns_the_error_messages_hash() {
1133        let mut errors = Errors::new();
1134        errors.add("name", ErrorType::Blank, "can't be blank");
1135        errors.add("name", ErrorType::TooShort, "is too short");
1136        errors.add("email", ErrorType::Invalid, "is invalid");
1137
1138        assert_eq!(
1139            errors.to_hash(),
1140            HashMap::from([
1141                (
1142                    "name".to_owned(),
1143                    vec!["can't be blank".to_owned(), "is too short".to_owned()],
1144                ),
1145                ("email".to_owned(), vec!["is invalid".to_owned()]),
1146            ]),
1147        );
1148    }
1149
1150    #[test]
1151    fn rails_messages_for_contains_all_the_error_messages_for_the_given_attribute() {
1152        let mut errors = Errors::new();
1153        errors.add("name", ErrorType::Blank, "can't be blank");
1154        errors.add("name", ErrorType::TooShort, "is too short");
1155        errors.add("email", ErrorType::Invalid, "is invalid");
1156
1157        assert_eq!(
1158            errors.messages_for("name"),
1159            vec!["can't be blank".to_owned(), "is too short".to_owned()],
1160        );
1161    }
1162
1163    #[test]
1164    fn rails_full_messages_for_contains_all_the_error_messages_for_the_given_attribute() {
1165        let mut errors = Errors::new();
1166        errors.add("name", ErrorType::Blank, "can't be blank");
1167        errors.add("name", ErrorType::TooShort, "is too short");
1168        errors.add("email", ErrorType::Invalid, "is invalid");
1169
1170        assert_eq!(
1171            errors.full_messages_for("name"),
1172            vec![
1173                "Name can't be blank".to_owned(),
1174                "Name is too short".to_owned(),
1175            ],
1176        );
1177    }
1178
1179    #[test]
1180    fn rails_full_messages_for_returns_an_empty_list_in_case_there_are_no_errors_for_the_given_attribute()
1181     {
1182        let errors = Errors::new();
1183        assert!(errors.full_messages_for("name").is_empty());
1184    }
1185
1186    #[test]
1187    fn rails_full_message_returns_the_given_message_when_attribute_is_base() {
1188        let error = Error {
1189            attribute: "base".to_owned(),
1190            error_type: ErrorType::Invalid,
1191            message: "press the button".to_owned(),
1192            details: HashMap::new(),
1193        };
1194
1195        assert_eq!(error.full_message(), "press the button");
1196    }
1197
1198    #[test]
1199    fn rails_full_message_returns_the_given_message_with_the_attribute_name_included() {
1200        let error = Error {
1201            attribute: "name".to_owned(),
1202            error_type: ErrorType::Blank,
1203            message: "can't be blank".to_owned(),
1204            details: HashMap::new(),
1205        };
1206
1207        assert_eq!(error.full_message(), "Name can't be blank");
1208    }
1209
1210    #[test]
1211    fn rails_details_returns_added_error_detail() {
1212        let mut errors = Errors::new();
1213        let mut details = HashMap::new();
1214        details.insert("count".to_owned(), json!(25));
1215
1216        errors.add_with_details("name", ErrorType::TooShort, "is too short", details);
1217
1218        assert_eq!(errors.details()[0].details.get("count"), Some(&json!(25)));
1219    }
1220
1221    #[test]
1222    fn rails_equality_by_base_attribute_type_and_options() {
1223        let first = Error {
1224            attribute: "name".to_owned(),
1225            error_type: ErrorType::TooShort,
1226            message: "is too short".to_owned(),
1227            details: HashMap::from([("count".to_owned(), json!(5))]),
1228        };
1229        let second = Error {
1230            attribute: "name".to_owned(),
1231            error_type: ErrorType::TooShort,
1232            message: "is too short".to_owned(),
1233            details: HashMap::from([("count".to_owned(), json!(5))]),
1234        };
1235
1236        assert_eq!(first, second);
1237    }
1238
1239    #[test]
1240    fn rails_inequality() {
1241        let first = Error {
1242            attribute: "name".to_owned(),
1243            error_type: ErrorType::TooShort,
1244            message: "is too short".to_owned(),
1245            details: HashMap::from([("count".to_owned(), json!(5))]),
1246        };
1247        let second = Error {
1248            attribute: "title".to_owned(),
1249            error_type: ErrorType::TooShort,
1250            message: "is too short".to_owned(),
1251            details: HashMap::from([("count".to_owned(), json!(5))]),
1252        };
1253
1254        assert_ne!(first, second);
1255    }
1256
1257    #[test]
1258    fn rails_initialize_without_type() {
1259        let error = Error::new("name", "is invalid");
1260
1261        assert_eq!(error.attribute, "name");
1262        assert_eq!(error.error_type, ErrorType::Invalid);
1263        assert_eq!(error.message, "is invalid");
1264        assert!(error.details.is_empty());
1265    }
1266
1267    #[test]
1268    fn rails_initialize_without_type_but_with_options() {
1269        let error = Error::new_with_default_details(
1270            "name",
1271            "is invalid",
1272            HashMap::from([("count".to_string(), json!(2))]),
1273        );
1274
1275        assert_eq!(error.error_type, ErrorType::Invalid);
1276        assert_eq!(error.details.get("count"), Some(&json!(2)));
1277    }
1278
1279    #[test]
1280    fn rails_match_handles_mixed_condition() {
1281        let error = Error::new_with_details(
1282            "name",
1283            ErrorType::TooShort,
1284            "is too short",
1285            HashMap::from([("count".to_string(), json!(5))]),
1286        );
1287        let details = HashMap::from([("count".to_string(), json!(5))]);
1288
1289        assert!(error.matches(Some("name"), Some(&ErrorType::TooShort), Some(&details)));
1290        assert!(!error.matches(Some("email"), Some(&ErrorType::TooShort), Some(&details)));
1291    }
1292
1293    #[test]
1294    fn rails_match_handles_attribute_match() {
1295        let error = Error::new("name", "can't be blank");
1296
1297        assert!(error.matches(Some("name"), None, None));
1298        assert!(!error.matches(Some("email"), None, None));
1299    }
1300
1301    #[test]
1302    fn rails_match_handles_error_type_match() {
1303        let error = Error::new_with_type("name", ErrorType::Blank, "can't be blank");
1304
1305        assert!(error.matches(None, Some(&ErrorType::Blank), None));
1306        assert!(!error.matches(None, Some(&ErrorType::Invalid), None));
1307    }
1308
1309    #[test]
1310    fn rails_match_handles_extra_options_match() {
1311        let error = Error::new_with_details(
1312            "name",
1313            ErrorType::TooShort,
1314            "is too short",
1315            HashMap::from([
1316                ("count".to_string(), json!(5)),
1317                ("minimum".to_string(), json!(2)),
1318            ]),
1319        );
1320
1321        assert!(error.matches(
1322            None,
1323            None,
1324            Some(&HashMap::from([("count".to_string(), json!(5))])),
1325        ));
1326        assert!(!error.matches(
1327            None,
1328            None,
1329            Some(&HashMap::from([("count".to_string(), json!(7))])),
1330        ));
1331    }
1332
1333    #[test]
1334    fn rails_add_creates_an_error_object_and_returns_it() {
1335        let mut errors = Errors::new();
1336        let error = errors.add("name", ErrorType::Blank, "can't be blank");
1337
1338        assert_eq!(error.attribute, "name");
1339        assert_eq!(error.error_type, ErrorType::Blank);
1340        assert_eq!(errors.details(), &[error]);
1341    }
1342
1343    #[test]
1344    fn rails_add_with_type_as_nil() {
1345        let mut errors = Errors::new();
1346        let error = errors.add_message("name", "is invalid");
1347
1348        assert_eq!(error.error_type, ErrorType::Invalid);
1349        assert_eq!(errors.messages_for("name"), vec!["is invalid".to_string()]);
1350    }
1351
1352    #[test]
1353    #[ignore = "Proc-evaluated messages are Ruby-specific"]
1354    fn rails_add_with_type_as_proc() {}
1355
1356    #[test]
1357    fn rails_added_predicates() {
1358        let mut errors = Errors::new();
1359        errors.add_with_details(
1360            "name",
1361            ErrorType::TooShort,
1362            "is too short",
1363            HashMap::from([("count".to_string(), json!(5))]),
1364        );
1365
1366        assert!(errors.added(
1367            "name",
1368            &ErrorType::TooShort,
1369            Some(&HashMap::from([("count".to_string(), json!(5))])),
1370        ));
1371        assert!(!errors.added(
1372            "name",
1373            &ErrorType::TooShort,
1374            Some(&HashMap::from([("count".to_string(), json!(7))])),
1375        ));
1376    }
1377
1378    #[test]
1379    fn rails_of_kind_predicates() {
1380        let mut errors = Errors::new();
1381        errors.add("name", ErrorType::Blank, "can't be blank");
1382
1383        assert!(errors.of_kind("name", &ErrorType::Blank));
1384        assert!(!errors.of_kind("name", &ErrorType::Invalid));
1385    }
1386
1387    #[test]
1388    fn rails_as_json_with_full_messages_option() {
1389        let mut errors = Errors::new();
1390        errors.add("name", ErrorType::Blank, "can't be blank");
1391        errors.add("base", ErrorType::Invalid, "record is invalid");
1392
1393        assert_eq!(
1394            errors.as_json_with_full_messages(),
1395            json!({
1396                "name": ["Name can't be blank"],
1397                "base": ["record is invalid"],
1398            }),
1399        );
1400    }
1401
1402    #[test]
1403    fn rails_generate_message_works_without_i18n_scope() {
1404        let mut errors = Errors::new();
1405        errors.add("name", ErrorType::Blank, "can't be blank");
1406
1407        assert_eq!(
1408            errors.full_messages(),
1409            vec!["Name can't be blank".to_owned()],
1410        );
1411    }
1412
1413    #[test]
1414    fn rails_group_by_attribute() {
1415        let mut errors = Errors::new();
1416        errors.add("name", ErrorType::Blank, "can't be blank");
1417        errors.add("name", ErrorType::TooShort, "is too short");
1418        errors.add("email", ErrorType::Invalid, "is invalid");
1419
1420        let grouped = errors.group_by_attribute();
1421        assert_eq!(grouped["name"].len(), 2);
1422        assert_eq!(grouped["email"].len(), 1);
1423    }
1424
1425    #[test]
1426    fn rails_dup_duplicates_details() {
1427        let mut errors = Errors::new();
1428        errors.add_with_details(
1429            "name",
1430            ErrorType::TooShort,
1431            "is too short",
1432            HashMap::from([("count".to_string(), json!(5))]),
1433        );
1434
1435        let duplicated = errors.clone();
1436        assert_eq!(duplicated, errors);
1437        assert_eq!(
1438            duplicated.details()[0].details.get("count"),
1439            Some(&json!(5))
1440        );
1441    }
1442
1443    #[test]
1444    fn rails_delete_returns_the_deleted_messages() {
1445        let mut errors = Errors::new();
1446        errors.add("name", ErrorType::Blank, "can't be blank");
1447        errors.add("name", ErrorType::TooShort, "is too short");
1448        errors.add("email", ErrorType::Invalid, "is invalid");
1449
1450        assert_eq!(
1451            errors.delete("name"),
1452            vec!["can't be blank".to_string(), "is too short".to_string()]
1453        );
1454        assert_eq!(errors.messages_for("email"), vec!["is invalid".to_string()]);
1455    }
1456
1457    #[test]
1458    fn rails_copy_errors() {
1459        let mut source = Errors::new();
1460        source.add("name", ErrorType::Blank, "can't be blank");
1461        let mut target = Errors::new();
1462        target.copy_from(&source);
1463
1464        assert_eq!(target, source);
1465    }
1466
1467    #[test]
1468    fn rails_merge_errors() {
1469        let mut left = Errors::new();
1470        left.add("name", ErrorType::Blank, "can't be blank");
1471        let mut right = Errors::new();
1472        right.add("email", ErrorType::Invalid, "is invalid");
1473
1474        left.merge(&right);
1475
1476        assert_eq!(left.count(), 2);
1477        assert_eq!(left.attributes(), vec!["name", "email"]);
1478    }
1479
1480    #[test]
1481    fn rails_merge_does_not_import_errors_when_merging_with_self() {
1482        let mut errors = Errors::new();
1483        errors.add("name", ErrorType::Blank, "can't be blank");
1484        let snapshot = errors.clone();
1485
1486        errors.merge(&snapshot);
1487        assert_eq!(errors.count(), 2);
1488
1489        let before_self_merge = errors.clone();
1490        errors.merge(&before_self_merge);
1491        assert_eq!(errors.count(), 4);
1492    }
1493
1494    #[test]
1495    fn rails_errors_are_marshalable() {
1496        let mut errors = Errors::new();
1497        errors.add("name", ErrorType::Blank, "can't be blank");
1498
1499        let _ = errors.clone();
1500    }
1501
1502    #[test]
1503    #[ignore = "Rails YAML compatibility is outside rustrails-model scope"]
1504    fn rails_errors_are_compatible_with_yaml_dumped_from_rails_6() {}
1505
1506    #[test]
1507    fn rails_inspect() {
1508        let mut errors = Errors::new();
1509        errors.add("name", ErrorType::Blank, "can't be blank");
1510
1511        let inspect = format!("{errors:?}");
1512        assert!(inspect.contains("Errors"));
1513        assert!(inspect.contains("name"));
1514        assert!(inspect.contains("can't be blank"));
1515    }
1516}