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;