webx_core/localization.rs
1//! Localized endpoint text: resource references resolved against the host's
2//! configured culture.
3//!
4//! The framework follows the .NET model: metadata stores a **resource key**
5//! (optionally with a literal fallback), translations live in resource files,
6//! and the active culture is a host-wide setting. Nothing in a route declaration
7//! enumerates languages.
8//!
9//! ```ignore
10//! #[get("/api/blog")]
11//! #[display(key = "endpoint.blog.list.name", text = "博客模块-博客列表")]
12//! impl IRequest<Vec<BlogModel>> for ListBlogRequest {}
13//! ```
14//!
15//! Resources are registered at startup. The conventional layout needs one call:
16//!
17//! ```ignore
18//! Host::builder().add_localization().build(); // reads locales/<culture>.json
19//! ```
20//!
21//! For a single-file deployment, compile them in:
22//!
23//! ```ignore
24//! Host::builder()
25//! .add_localization_with(
26//! LocalizationOptions::none()
27//! .resources("zh-CN", include_str!("../locales/zh-CN.json"))
28//! .resources("en", include_str!("../locales/en.json")),
29//! )
30//! .build();
31//! ```
32//!
33//! Lookup follows .NET's resource fallback: exact culture, its parent cultures,
34//! then the default culture, then the literal fallback, then the key itself.
35
36use serde::Serialize;
37use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39
40/// A reference to localized text.
41///
42/// `key` is a resource key (`"endpoint.blog.list.name"`); `text` is a literal
43/// default used when the key is absent from every resource. Either may be empty.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45pub struct TextRef {
46 /// Resource key; empty for literal-only text.
47 pub key: &'static str,
48
49 /// Literal fallback; empty when the key is the only source.
50 pub text: &'static str,
51}
52
53impl TextRef {
54 /// A key-only reference; a missing resource falls back to the key itself.
55 pub const fn key(key: &'static str) -> Self {
56 Self { key, text: "" }
57 }
58
59 /// Literal-only text; never looks at resources.
60 pub const fn text(text: &'static str) -> Self {
61 Self { key: "", text }
62 }
63
64 /// A key with a literal fallback.
65 pub const fn new(key: &'static str, text: &'static str) -> Self {
66 Self { key, text }
67 }
68}
69
70/// The result of resolving a [`TextRef`].
71///
72/// Mirrors .NET's `LocalizedString`: `resource_not_found` is true when the key
73/// was not found in any resource and `value` is the key itself.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct LocalizedString {
76 /// The resolved text.
77 pub value: String,
78
79 /// True when `value` is the unresolved key.
80 pub resource_not_found: bool,
81}
82
83impl LocalizedString {
84 /// A successfully resolved value (resource hit or literal).
85 pub fn found(value: impl Into<String>) -> Self {
86 Self {
87 value: value.into(),
88 resource_not_found: false,
89 }
90 }
91
92 /// A key that was not found in any resource.
93 pub fn missing(key: &str) -> Self {
94 Self {
95 value: key.to_string(),
96 resource_not_found: true,
97 }
98 }
99}
100
101/// Resolves resource keys for a culture.
102///
103/// The default implementation of [`Self::get`] and [`Self::resolve`] uses
104/// [`Self::default_culture`], which the host sets from `Culture.Default`.
105/// Pass an explicit culture only for cross-culture output (for example a
106/// per-culture report); request handling should use the configured default.
107pub trait IStringLocalizer: Send + Sync {
108 /// Culture used when no explicit culture is supplied.
109 fn default_culture(&self) -> &str;
110
111 /// Resolve `key` for `culture`, falling back through parent cultures to the
112 /// default culture, then returning the key with `resource_not_found`.
113 fn get_in(&self, culture: &str, key: &str) -> LocalizedString;
114
115 /// Resolve `key` for the default culture.
116 fn get(&self, key: &str) -> LocalizedString {
117 self.get_in(self.default_culture(), key)
118 }
119
120 /// Resolve a metadata text reference for `culture`.
121 fn resolve_in(&self, culture: &str, text: TextRef) -> LocalizedString {
122 if !text.key.is_empty() {
123 let found = self.get_in(culture, text.key);
124 if !found.resource_not_found {
125 return found;
126 }
127 if !text.text.is_empty() {
128 return LocalizedString::found(text.text);
129 }
130 return found;
131 }
132 LocalizedString::found(text.text)
133 }
134
135 /// Resolve a metadata text reference for the default culture.
136 fn resolve(&self, text: TextRef) -> LocalizedString {
137 self.resolve_in(self.default_culture(), text)
138 }
139}
140
141/// Resource files registered with the host.
142///
143/// Two sources, merged in this order (later wins):
144///
145/// 1. a resource directory of `<culture>.json` files, relative to the
146/// application base (`locales/` by default for `add_localization()`), read at
147/// startup so operators can adjust translations without a rebuild;
148/// 2. explicit `(culture, json)` entries compiled into the binary with
149/// `include_str!` — the single-executable form.
150#[derive(Debug, Clone)]
151pub struct LocalizationOptions {
152 resource_dir: Option<PathBuf>,
153 resources: Vec<(&'static str, &'static str)>,
154}
155
156impl Default for LocalizationOptions {
157 /// The conventional layout: `<culture>.json` files under `locales/`.
158 fn default() -> Self {
159 Self {
160 resource_dir: Some(PathBuf::from("locales")),
161 resources: Vec::new(),
162 }
163 }
164}
165
166impl LocalizationOptions {
167 /// The conventional layout (`locales/<culture>.json`) with no compiled-in
168 /// entries. This is what `HostBuilder::add_localization()` uses.
169 pub fn new() -> Self {
170 Self::default()
171 }
172
173 /// No resource directory and no resources.
174 ///
175 /// Use it for explicit-only resources
176 /// (`LocalizationOptions::none().resources(...)`) or to disable localization.
177 pub fn none() -> Self {
178 Self {
179 resource_dir: None,
180 resources: Vec::new(),
181 }
182 }
183
184 /// Read `<culture>.json` files from `dir`.
185 ///
186 /// Relative paths resolve against the application base, like
187 /// `appsettings.json`. [`Self::new`] sets this to `"locales"`.
188 pub fn resource_dir(mut self, dir: impl Into<PathBuf>) -> Self {
189 self.resource_dir = Some(dir.into());
190 self
191 }
192
193 /// Register one culture's resources.
194 ///
195 /// Registering the same culture twice merges the objects; later entries win
196 /// on duplicate keys. Explicit entries override files from the resource
197 /// directory.
198 pub fn resources(mut self, culture: &'static str, json: &'static str) -> Self {
199 self.resources.push((culture, json));
200 self
201 }
202
203 pub(crate) fn iter(&self) -> impl Iterator<Item = (&'static str, &'static str)> + '_ {
204 self.resources.iter().copied()
205 }
206}
207
208/// The framework's default [`IStringLocalizer`]: static JSON resource tables.
209pub struct StringLocalizer {
210 default_culture: String,
211 resources: HashMap<String, HashMap<String, String>>,
212}
213
214impl StringLocalizer {
215 /// Build the localizer from the configured resource directory and
216 /// compiled-in resource entries.
217 ///
218 /// Returns an error naming the file or culture when a resource is not a
219 /// JSON object of `string → string`.
220 pub fn new(
221 default_culture: impl Into<String>,
222 options: &LocalizationOptions,
223 ) -> Result<Self, String> {
224 let mut resources: HashMap<String, HashMap<String, String>> = HashMap::new();
225
226 if let Some(dir) = &options.resource_dir {
227 for (culture, json) in read_resource_dir(dir)? {
228 insert_resources(&mut resources, &culture, &json)?;
229 }
230 }
231
232 for (culture, json) in options.iter() {
233 insert_resources(&mut resources, culture, json)?;
234 }
235
236 Ok(Self {
237 default_culture: default_culture.into(),
238 resources,
239 })
240 }
241
242 fn lookup(&self, culture: &str, key: &str) -> Option<&str> {
243 self.resources
244 .get(&culture.to_ascii_lowercase())?
245 .get(key)
246 .map(String::as_str)
247 }
248}
249
250fn insert_resources(
251 resources: &mut HashMap<String, HashMap<String, String>>,
252 culture: &str,
253 json: &str,
254) -> Result<(), String> {
255 let parsed: HashMap<String, String> =
256 serde_json::from_str(json).map_err(|error| format!("culture '{culture}': {error}"))?;
257 resources
258 .entry(culture.to_ascii_lowercase())
259 .or_default()
260 .extend(parsed);
261 Ok(())
262}
263
264/// Read `<culture>.json` from `dir`, relative to the application base.
265///
266/// A missing directory is not an error; it means no resources were deployed.
267fn read_resource_dir(dir: &Path) -> Result<Vec<(String, String)>, String> {
268 let base = if dir.is_absolute() {
269 dir.to_path_buf()
270 } else {
271 crate::paths::app_base().join(dir)
272 };
273
274 let entries = match std::fs::read_dir(&base) {
275 Ok(entries) => entries,
276 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
277 Err(error) => return Err(format!("{}: {error}", base.display())),
278 };
279
280 let mut files = Vec::new();
281 for entry in entries {
282 let entry = entry.map_err(|error| format!("{}: {error}", base.display()))?;
283 let path = entry.path();
284 if !path.is_file()
285 || path.extension().and_then(|extension| extension.to_str()) != Some("json")
286 {
287 continue;
288 }
289 let Some(culture) = path.file_stem().and_then(|stem| stem.to_str()) else {
290 continue;
291 };
292 let json = std::fs::read_to_string(&path)
293 .map_err(|error| format!("{}: {error}", path.display()))?;
294 files.push((culture.to_string(), json));
295 }
296 files.sort_by(|a, b| a.0.cmp(&b.0));
297 Ok(files)
298}
299
300impl IStringLocalizer for StringLocalizer {
301 fn default_culture(&self) -> &str {
302 &self.default_culture
303 }
304
305 fn get_in(&self, culture: &str, key: &str) -> LocalizedString {
306 for candidate in culture_chain(culture) {
307 if let Some(value) = self.lookup(candidate, key) {
308 return LocalizedString::found(value);
309 }
310 }
311 // A key missing from the requested culture falls back to the host's
312 // default culture, like neutral resources in ResourceManager.
313 for candidate in culture_chain(&self.default_culture) {
314 if let Some(value) = self.lookup(candidate, key) {
315 return LocalizedString::found(value);
316 }
317 }
318 LocalizedString::missing(key)
319 }
320}
321
322/// `zh-Hans-CN` → `["zh-Hans-CN", "zh-Hans", "zh"]`.
323fn culture_chain(culture: &str) -> Vec<&str> {
324 let mut chain = Vec::new();
325 let mut current = culture;
326 while !current.is_empty() {
327 chain.push(current);
328 match current.rfind(['-', '_']) {
329 Some(index) => current = ¤t[..index],
330 None => break,
331 }
332 }
333 chain
334}