Skip to main content

turnframe_core/
locale.rs

1//! Locales and localized copy.
2//!
3//! Receipts, notices and interaction labels are server-authored copy. They are
4//! stored as [`LocalizedText`]: a required default plus optional translations,
5//! resolved against the turn's [`Locale`] at render time.
6
7use std::collections::BTreeMap;
8use std::fmt;
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13/// A BCP-47 language tag such as `"it-IT"` or `"en"`.
14///
15/// The library does not validate the tag beyond being non-empty; it only
16/// splits it into language and region for fallback resolution.
17#[derive(
18    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
19)]
20#[serde(transparent)]
21pub struct Locale(pub String);
22
23impl Locale {
24    /// Wraps a BCP-47 tag.
25    #[must_use]
26    pub fn new(tag: impl Into<String>) -> Self {
27        Self(tag.into())
28    }
29
30    /// Borrows the full tag.
31    #[must_use]
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35
36    /// The primary language subtag (`"it"` for `"it-IT"`), lowercased view is
37    /// not applied: the tag is returned as written.
38    #[must_use]
39    pub fn language(&self) -> &str {
40        self.0.split(['-', '_']).next().unwrap_or(self.0.as_str())
41    }
42
43    /// The region subtag when present (`Some("IT")` for `"it-IT"`).
44    #[must_use]
45    pub fn region(&self) -> Option<&str> {
46        let mut parts = self.0.split(['-', '_']);
47        parts.next()?;
48        parts.next().filter(|s| !s.is_empty())
49    }
50
51    /// Returns `true` when both locales share the primary language.
52    #[must_use]
53    pub fn same_language(&self, other: &Locale) -> bool {
54        self.language().eq_ignore_ascii_case(other.language())
55    }
56}
57
58impl Default for Locale {
59    fn default() -> Self {
60        Self::new("en")
61    }
62}
63
64impl From<&str> for Locale {
65    fn from(value: &str) -> Self {
66        Self(value.to_owned())
67    }
68}
69
70impl From<String> for Locale {
71    fn from(value: String) -> Self {
72        Self(value)
73    }
74}
75
76impl fmt::Display for Locale {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str(&self.0)
79    }
80}
81
82/// Server-authored copy with a mandatory fallback and optional translations.
83///
84/// Resolution order for a requested locale: exact tag, then any translation
85/// whose primary language matches, then the default text.
86#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
87pub struct LocalizedText {
88    /// Text used when no translation matches.
89    pub default: String,
90    /// Translations keyed by locale tag.
91    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92    pub translations: BTreeMap<Locale, String>,
93}
94
95impl LocalizedText {
96    /// Creates copy with only a default text.
97    #[must_use]
98    pub fn new(default: impl Into<String>) -> Self {
99        Self {
100            default: default.into(),
101            translations: BTreeMap::new(),
102        }
103    }
104
105    /// Adds or replaces a translation.
106    #[must_use]
107    pub fn with(mut self, locale: impl Into<Locale>, text: impl Into<String>) -> Self {
108        self.translations.insert(locale.into(), text.into());
109        self
110    }
111
112    /// Resolves the best text for `locale`.
113    #[must_use]
114    pub fn resolve(&self, locale: &Locale) -> &str {
115        if let Some(exact) = self.translations.get(locale) {
116            return exact;
117        }
118        self.translations
119            .iter()
120            .find(|(candidate, _)| candidate.same_language(locale))
121            .map_or(self.default.as_str(), |(_, text)| text.as_str())
122    }
123}
124
125impl From<&str> for LocalizedText {
126    fn from(value: &str) -> Self {
127        Self::new(value)
128    }
129}
130
131impl From<String> for LocalizedText {
132    fn from(value: String) -> Self {
133        Self::new(value)
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn locale_parts() {
143        let l = Locale::from("it-IT");
144        assert_eq!(l.language(), "it");
145        assert_eq!(l.region(), Some("IT"));
146        assert_eq!(Locale::from("en").region(), None);
147        assert!(Locale::from("it-CH").same_language(&l));
148    }
149
150    #[test]
151    fn localized_text_resolution_order() {
152        let text = LocalizedText::new("Confirm")
153            .with("it-IT", "Conferma")
154            .with("de", "Bestätigen");
155        assert_eq!(text.resolve(&Locale::from("it-IT")), "Conferma");
156        assert_eq!(text.resolve(&Locale::from("it-CH")), "Conferma");
157        assert_eq!(text.resolve(&Locale::from("de-AT")), "Bestätigen");
158        assert_eq!(text.resolve(&Locale::from("fr")), "Confirm");
159    }
160
161    #[test]
162    fn localized_text_round_trip() {
163        let text = LocalizedText::new("x").with("it", "y");
164        let json = serde_json::to_string(&text).unwrap();
165        let back: LocalizedText = serde_json::from_str(&json).unwrap();
166        assert_eq!(back, text);
167        assert_eq!(
168            serde_json::to_string(&LocalizedText::new("x")).unwrap(),
169            r#"{"default":"x"}"#
170        );
171    }
172}