1use std::collections::BTreeMap;
8use std::fmt;
9
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12
13#[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 #[must_use]
26 pub fn new(tag: impl Into<String>) -> Self {
27 Self(tag.into())
28 }
29
30 #[must_use]
32 pub fn as_str(&self) -> &str {
33 &self.0
34 }
35
36 #[must_use]
39 pub fn language(&self) -> &str {
40 self.0.split(['-', '_']).next().unwrap_or(self.0.as_str())
41 }
42
43 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
87pub struct LocalizedText {
88 pub default: String,
90 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
92 pub translations: BTreeMap<Locale, String>,
93}
94
95impl LocalizedText {
96 #[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 #[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 #[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}