Skip to main content

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 = &current[..index],
330            None => break,
331        }
332    }
333    chain
334}