Skip to main content

ocre/i18n/
model.rs

1//! Model and attribute names and validation messages in the locale of an
2//! [`I18n`]: Rails' `model_name.human`, `human_attribute_name` and
3//! translated `errors.full_messages`.
4
5use super::{Count, I18n, builtin, interpolate};
6use crate::{FieldError, names::humanize};
7
8impl I18n {
9    /// The name of a model for `count` items, from `models.<model>` (Rails' `Post.model_name.human(count:)`).
10    ///
11    /// `model` is the snake_case name (`blog_post`). `models.<model>` is a
12    /// text or plural forms (`one`/`other`...). Without a translation, the
13    /// humanized name (`Blog post`) for every count: add an `other` form for
14    /// plurals, since Ocre has no runtime inflector.
15    ///
16    /// # Examples
17    ///
18    /// ```
19    /// # use std::sync::LazyLock;
20    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
21    ///     ocre::i18n::Catalog::load(&[("fr", "fr:\n  models:\n    post:\n      one: Article\n      other: Articles\n")])
22    /// });
23    /// let fr = LOCALES.locale("fr");
24    /// assert_eq!(fr.model_name("post", 1), "Article");
25    /// assert_eq!(fr.model_name("post", 3), "Articles");
26    /// assert_eq!(fr.model_name("blog_post", 1), "Blog post");
27    /// ```
28    pub fn model_name(&self, model: &str, count: impl Count) -> String {
29        let key = format!("models.{model}");
30        self.lookup(&[&key], Some(count.to_count()), false).map_or_else(|| humanize(model), str::to_owned)
31    }
32
33    /// The name of a model's field, from `attributes.<model>.<field>`, then `attributes.<field>` (Rails' `human_attribute_name`).
34    ///
35    /// Without a translation, the humanized field (`published_at` gives
36    /// `Published at`, `author_id` gives `Author`), as
37    /// [`FieldError::full_message`] writes it. Use it for form labels and
38    /// table headers.
39    ///
40    /// # Examples
41    ///
42    /// ```
43    /// # use std::sync::LazyLock;
44    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
45    ///     ocre::i18n::Catalog::load(&[(
46    ///         "fr",
47    ///         "fr:\n  attributes:\n    created_at: Créé le\n    post:\n      title: Titre\n",
48    ///     )])
49    /// });
50    /// let fr = LOCALES.locale("fr");
51    /// assert_eq!(fr.attribute("post", "title"), "Titre");
52    /// assert_eq!(fr.attribute("post", "created_at"), "Créé le");
53    /// assert_eq!(fr.attribute("post", "author_id"), "Author");
54    /// ```
55    pub fn attribute(&self, model: &str, field: &str) -> String {
56        let keys = [format!("attributes.{model}.{field}"), format!("attributes.{field}")];
57        self.lookup(&[&keys[0], &keys[1]], None, false).map_or_else(|| humanize(field), str::to_owned)
58    }
59
60    /// The message of a validation error in the locale, without the field name (Rails' `errors.messages`).
61    ///
62    /// Each [`Validator`](crate::Validator) check records Rails' key
63    /// ([`FieldError::key`]: `blank`, `too_long`, `inclusion`...), looked up
64    /// in this order, the first found winning:
65    /// `errors.models.<model>.attributes.<field>.<key>`,
66    /// `errors.models.<model>.<key>`, `errors.attributes.<field>.<key>`,
67    /// `errors.messages.<key>`; first in the locale's file, then in the
68    /// built-in translations of its language (English, French, German,
69    /// Spanish, Italian, Portuguese, Dutch), then in the default locale.
70    /// `%{count}` is the check's bound, `%{attribute}` the translated field
71    /// (the confirmed one for `confirmation`) and `%{model}` the model name.
72    /// An error without a key (a custom [`check`](crate::Validator::check)
73    /// or [`message`](crate::Validator::message)) keeps its message, unless
74    /// that message is one of the English built-in ones (`has already been
75    /// taken` is `taken`).
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// # use std::sync::LazyLock;
81    /// use ocre::{Error, Validator};
82    ///
83    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
84    ///     ocre::i18n::Catalog::load(&[
85    ///         ("en", "en:\n"),
86    ///         ("fr", "fr:\n  errors:\n    models:\n      post:\n        attributes:\n          title:\n            blank: \"donnez un titre\"\n"),
87    ///     ])
88    /// });
89    /// let mut v = Validator::new();
90    /// v.required("title", "").required("body", "").max_length("slug", "far-too-long", 5);
91    /// let Err(Error::Invalid(errors)) = v.finish() else { unreachable!() };
92    /// let fr = LOCALES.locale("fr");
93    /// let messages: Vec<String> = errors.iter().map(|error| fr.error_message("post", error)).collect();
94    /// assert_eq!(messages, ["donnez un titre", "doit être rempli(e)", "est trop long (pas plus de 5 caractères)"]);
95    /// ```
96    pub fn error_message(&self, model: &str, error: &FieldError) -> String {
97        let Some(key) = error.key().or_else(|| builtin::english_key(&error.message)) else {
98            return error.message.clone();
99        };
100        let field = error.field.as_str();
101        let keys = [
102            format!("errors.models.{model}.attributes.{field}.{key}"),
103            format!("errors.models.{model}.{key}"),
104            format!("errors.attributes.{field}.{key}"),
105            format!("errors.messages.{key}"),
106        ];
107        let keys = [keys[0].as_str(), keys[1].as_str(), keys[2].as_str(), keys[3].as_str()];
108        let detail = error.detail().unwrap_or_default();
109        let (attribute, count) = match key {
110            "confirmation" => (self.attribute(model, detail), None),
111            _ => (self.attribute(model, field), detail.parse::<i64>().ok()),
112        };
113        let text: &str = self.lookup(&keys, count, false).unwrap_or(&error.message);
114        let args = [("attribute", attribute), ("model", self.model_name(model, 1)), ("count", detail.to_owned())];
115        let mut out = String::new();
116        interpolate(&mut out, text, &args, count, false).expect("writing to a String");
117        out
118    }
119
120    /// The translated field name and message of a validation error, as `errors.format` lays them out (Rails' `full_message`).
121    ///
122    /// `errors.format` defaults to `%{attribute} %{message}`; the attribute
123    /// is [`attribute`](Self::attribute) and the message
124    /// [`error_message`](Self::error_message).
125    ///
126    /// # Examples
127    ///
128    /// ```
129    /// # use std::sync::LazyLock;
130    /// static LOCALES: ocre::i18n::Locales = LazyLock::new(|| {
131    ///     ocre::i18n::Catalog::load(&[("de", "de:\n  attributes:\n    post:\n      title: Titel\n")])
132    /// });
133    /// let mut v = ocre::Validator::new();
134    /// v.required("title", "");
135    /// let de = LOCALES.locale("de");
136    /// assert_eq!(de.full_message("post", &v.errors()[0]), "Titel muss ausgefüllt werden");
137    /// ```
138    pub fn full_message(&self, model: &str, error: &FieldError) -> String {
139        let layout = self.lookup(&["errors.format"], None, false).unwrap_or("%{attribute} %{message}");
140        let args = [("attribute", self.attribute(model, &error.field)), ("message", self.error_message(model, error))];
141        let mut out = String::new();
142        interpolate(&mut out, layout, &args, None, false).expect("writing to a String");
143        out
144    }
145}
146
147#[cfg(test)]
148#[path = "../../tests/i18n/model.rs"]
149mod tests;