Skip to main content

rustlavel_validation/
messages.rs

1//! Default messages, placeholder interpolation, and per-field overrides.
2//!
3//! Laravel keeps its messages in `lang/en/validation.php` with `:attribute`
4//! style placeholders, and lets an application override one message for one
5//! field. That split matters: the defaults have to be good enough that nobody
6//! writes them out again, and the override has to be reachable for the one
7//! field where the default reads wrong ("The g-recaptcha-response field is
8//! required" is never what a user should see).
9
10use crate::rule::Rule;
11use std::collections::BTreeMap;
12
13/// Which of the three readings of a size rule applies to a value.
14///
15/// `min:3` means three characters, three items, or the number three depending
16/// on what is being validated, and the message has to say the right one.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum SizeKind {
19    Numeric,
20    String,
21    Array,
22}
23
24/// Message defaults, plus whatever the application overrode.
25#[derive(Debug, Clone, Default, PartialEq)]
26pub struct Messages {
27    overrides: BTreeMap<String, String>,
28    attributes: BTreeMap<String, String>,
29}
30
31impl Messages {
32    pub fn new() -> Self {
33        Messages::default()
34    }
35
36    /// Override a message.
37    ///
38    /// The key is `"field.rule"` for one field (`"email.required"`) or just
39    /// `"rule"` to replace the default everywhere (`"required"`). Placeholders
40    /// still interpolate, so an override can keep `:attribute`.
41    pub fn set(&mut self, key: impl Into<String>, message: impl Into<String>) {
42        self.overrides.insert(key.into(), message.into());
43    }
44
45    /// The chaining form of [`Messages::set`].
46    pub fn with(mut self, key: impl Into<String>, message: impl Into<String>) -> Self {
47        self.set(key, message);
48        self
49    }
50
51    /// Rename a field for display: `attribute("dob", "date of birth")`.
52    pub fn set_attribute(&mut self, field: impl Into<String>, label: impl Into<String>) {
53        self.attributes.insert(field.into(), label.into());
54    }
55
56    /// The chaining form of [`Messages::set_attribute`].
57    pub fn attribute(mut self, field: impl Into<String>, label: impl Into<String>) -> Self {
58        self.set_attribute(field, label);
59        self
60    }
61
62    /// How a field is named in a message.
63    ///
64    /// Without an override, `email_address` reads as "email address" — a form
65    /// field name is a programmer's word, and the message is a user's.
66    pub fn label(&self, field: &str) -> String {
67        match self.attributes.get(field) {
68            Some(label) => label.clone(),
69            None => field.replace(['_', '-', '.'], " "),
70        }
71    }
72
73    /// The template for a field/rule pair: a per-field override, then a
74    /// per-rule override, then the built-in default.
75    pub fn template(&self, field: &str, rule: &Rule, kind: SizeKind) -> String {
76        let name = rule.name();
77        self.overrides
78            .get(&format!("{field}.{name}"))
79            .or_else(|| self.overrides.get(name))
80            .cloned()
81            .unwrap_or_else(|| default_template(rule, kind).to_string())
82    }
83}
84
85/// The built-in English message for a rule, adapted from Laravel's `en` set.
86pub fn default_template(rule: &Rule, kind: SizeKind) -> &'static str {
87    match rule {
88        Rule::Required => "The :attribute field is required.",
89        // `nullable` never fails; it only permits a null.
90        Rule::Nullable => "The :attribute field is invalid.",
91        Rule::String => "The :attribute field must be a string.",
92        Rule::Integer => "The :attribute field must be an integer.",
93        Rule::Numeric => "The :attribute field must be a number.",
94        Rule::Boolean => "The :attribute field must be true or false.",
95        Rule::Email => "The :attribute field must be a valid email address.",
96        Rule::Url => "The :attribute field must be a valid URL.",
97        Rule::Min(_) => match kind {
98            SizeKind::Numeric => "The :attribute field must be at least :min.",
99            SizeKind::String => "The :attribute field must be at least :min characters.",
100            SizeKind::Array => "The :attribute field must have at least :min items.",
101        },
102        Rule::Max(_) => match kind {
103            SizeKind::Numeric => "The :attribute field must not be greater than :max.",
104            SizeKind::String => "The :attribute field must not be greater than :max characters.",
105            SizeKind::Array => "The :attribute field must not have more than :max items.",
106        },
107        Rule::Between(_, _) => match kind {
108            SizeKind::Numeric => "The :attribute field must be between :min and :max.",
109            SizeKind::String => "The :attribute field must be between :min and :max characters.",
110            SizeKind::Array => "The :attribute field must have between :min and :max items.",
111        },
112        Rule::Size(_) => match kind {
113            SizeKind::Numeric => "The :attribute field must be :size.",
114            SizeKind::String => "The :attribute field must be :size characters.",
115            SizeKind::Array => "The :attribute field must contain :size items.",
116        },
117        Rule::In(_) | Rule::NotIn(_) => "The selected :attribute is invalid.",
118        Rule::Confirmed => "The :attribute field confirmation does not match.",
119        Rule::Same(_) => "The :attribute field must match :other.",
120        Rule::Different(_) => "The :attribute field and :other must be different.",
121        Rule::Alpha => "The :attribute field must only contain letters.",
122        Rule::AlphaNum => "The :attribute field must only contain letters and numbers.",
123        Rule::AlphaDash => {
124            "The :attribute field must only contain letters, numbers, dashes, and underscores."
125        }
126        Rule::StartsWith(_) => "The :attribute field must start with one of the following: :values.",
127        Rule::EndsWith(_) => "The :attribute field must end with one of the following: :values.",
128        Rule::Date => "The :attribute field must be a valid date in the format YYYY-MM-DD.",
129        Rule::Uuid => "The :attribute field must be a valid UUID.",
130        Rule::Array => "The :attribute field must be an array.",
131    }
132}
133
134/// Replace `:name` placeholders. Unknown placeholders are left alone so a typo
135/// in an override is visible in the output instead of silently vanishing.
136pub fn interpolate(template: &str, values: &[(&str, String)]) -> String {
137    let mut out = template.to_string();
138    for (name, value) in values {
139        out = out.replace(&format!(":{name}"), value);
140    }
141    out
142}
143
144/// Render a bound the way a person writes it: `3`, not `3.0`.
145pub fn format_number(value: f64) -> String {
146    if value.is_finite() && value.fract() == 0.0 && value.abs() < 1e15 {
147        format!("{}", value as i64)
148    } else {
149        format!("{value}")
150    }
151}
152
153/// Join rule parameters for the `:values` placeholder.
154pub fn format_values(values: &[String]) -> String {
155    values.join(", ")
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn a_field_name_reads_as_words_by_default() {
164        let messages = Messages::new();
165        assert_eq!(messages.label("email"), "email");
166        assert_eq!(messages.label("email_address"), "email address");
167        assert_eq!(messages.label("billing.postal-code"), "billing postal code");
168    }
169
170    #[test]
171    fn an_attribute_override_replaces_the_derived_label() {
172        let messages = Messages::new().attribute("dob", "date of birth");
173        assert_eq!(messages.label("dob"), "date of birth");
174        assert_eq!(messages.label("other"), "other");
175    }
176
177    #[test]
178    fn a_field_override_beats_a_rule_override_which_beats_the_default() {
179        let messages = Messages::new()
180            .with("required", "We need :attribute.")
181            .with("email.required", "An email address is required.");
182
183        assert_eq!(
184            messages.template("email", &Rule::Required, SizeKind::String),
185            "An email address is required."
186        );
187        assert_eq!(messages.template("name", &Rule::Required, SizeKind::String), "We need :attribute.");
188        assert_eq!(
189            messages.template("name", &Rule::Email, SizeKind::String),
190            "The :attribute field must be a valid email address."
191        );
192    }
193
194    #[test]
195    fn a_size_rule_picks_its_wording_from_the_kind_of_value() {
196        assert_eq!(
197            default_template(&Rule::Min(3.0), SizeKind::String),
198            "The :attribute field must be at least :min characters."
199        );
200        assert_eq!(
201            default_template(&Rule::Min(3.0), SizeKind::Numeric),
202            "The :attribute field must be at least :min."
203        );
204        assert_eq!(
205            default_template(&Rule::Min(3.0), SizeKind::Array),
206            "The :attribute field must have at least :min items."
207        );
208    }
209
210    #[test]
211    fn placeholders_are_interpolated_and_unknown_ones_are_left_visible() {
212        let rendered = interpolate(
213            "The :attribute field must be between :min and :max. :nope",
214            &[("attribute", "age".into()), ("min", "1".into()), ("max", "10".into())],
215        );
216        assert_eq!(rendered, "The age field must be between 1 and 10. :nope");
217    }
218
219    #[test]
220    fn bounds_render_without_a_decimal_point() {
221        assert_eq!(format_number(255.0), "255");
222        assert_eq!(format_number(1.5), "1.5");
223    }
224
225    #[test]
226    fn list_parameters_render_comma_separated() {
227        assert_eq!(format_values(&["a".to_string(), "b".to_string()]), "a, b");
228    }
229}