1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
//! Lightweight localization (i18n) for Rust user-facing strings.
//!
//! Minutes has no framework-level i18n; this module is the Rust-side runtime
//! shared by the CLI (`crates/cli`) and the Tauri desktop shell
//! (`tauri/src-tauri`). Both are separate processes that read the persisted
//! [`crate::config::UiConfig::language`] preference and call [`set_locale_from_str`]
//! once at startup.
//!
//! # Design: English source string is the key
//!
//! To keep this additive and minimize merge conflicts with the actively
//! developed upstream, callers do not introduce message keys. They wrap the
//! existing English literal:
//!
//! ```
//! use minutes_core::i18n::tr;
//! let label = tr("Stop Recording"); // "停止录音" when locale is zh-CN
//! ```
//!
//! [`tr`] looks the English string up in the embedded translation catalog for
//! the active locale. If the locale is English, or the string has no
//! translation, the input is returned unchanged (English fallback). This means
//! partial catalogs are always safe, and syncing a new English string from
//! upstream just needs a new catalog entry — no code change.
//!
//! The catalog is `locales/zh-CN.json`, an `{ "English": "中文" }` object
//! embedded at compile time via `include_str!`.
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::OnceLock;
/// A supported display language.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Locale {
/// English — the untranslated source strings.
En,
/// Simplified Chinese.
ZhCn,
}
const LOCALE_EN: u8 = 0;
const LOCALE_ZH_CN: u8 = 1;
/// Process-wide active locale. Lock-free: it is just an enum discriminant, read
/// on every [`tr`] call and written by [`set_locale`] (startup + language switch).
static CURRENT: AtomicU8 = AtomicU8::new(LOCALE_EN);
/// Embedded Simplified Chinese catalog (`{ "English": "中文" }`).
const ZH_CN_JSON: &str = include_str!("locales/zh-CN.json");
/// Parses and caches the zh-CN catalog on first use. A malformed catalog
/// degrades gracefully to English (empty map) rather than panicking.
fn zh_cn_catalog() -> &'static HashMap<String, String> {
static CATALOG: OnceLock<HashMap<String, String>> = OnceLock::new();
CATALOG.get_or_init(|| {
serde_json::from_str(ZH_CN_JSON).unwrap_or_else(|e| {
tracing::warn!(error = %e, "failed to parse zh-CN i18n catalog; falling back to English");
HashMap::new()
})
})
}
/// Resolves a configured language preference into a concrete [`Locale`].
///
/// Precedence: the `MINUTES_LANG` environment variable (if non-empty) overrides
/// `language`; a value of `"auto"` is resolved from the OS locale via
/// [`sys_locale`]. Anything whose language tag starts with `zh` (e.g. `zh`,
/// `zh-CN`, `zh-Hans`) maps to [`Locale::ZhCn`]; everything else falls back to
/// [`Locale::En`].
pub fn resolve_locale(language: &str) -> Locale {
let requested = std::env::var("MINUTES_LANG")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| language.to_string());
let effective = if requested.eq_ignore_ascii_case("auto") {
sys_locale::get_locale().unwrap_or_default()
} else {
requested
};
if effective.to_ascii_lowercase().starts_with("zh") {
Locale::ZhCn
} else {
Locale::En
}
}
/// Sets the process-wide active locale directly.
pub fn set_locale(locale: Locale) {
let v = match locale {
Locale::En => LOCALE_EN,
Locale::ZhCn => LOCALE_ZH_CN,
};
CURRENT.store(v, Ordering::Relaxed);
}
/// Resolves a language preference string (`"auto"` / `"en"` / `"zh-CN"`) and
/// sets the active locale. Call once at startup with
/// `config.ui.language`, and again when the user switches languages.
pub fn set_locale_from_str(language: &str) {
set_locale(resolve_locale(language));
}
/// Returns the process-wide active locale.
pub fn current_locale() -> Locale {
match CURRENT.load(Ordering::Relaxed) {
LOCALE_ZH_CN => Locale::ZhCn,
_ => Locale::En,
}
}
/// Translates an English UI string to the active locale.
///
/// Returns the input unchanged when the locale is English or the string has no
/// translation in the catalog, so callers can wrap any literal safely. Neither
/// path allocates: the English source and the catalog entries are both borrowed.
///
/// ```
/// use minutes_core::i18n::{tr, set_locale, Locale};
/// set_locale(Locale::En);
/// assert_eq!(tr("Stop Recording"), "Stop Recording");
/// ```
pub fn tr(en: &str) -> Cow<'_, str> {
if current_locale() == Locale::ZhCn {
if let Some(translated) = zh_cn_catalog().get(en) {
return Cow::Borrowed(translated.as_str());
}
}
Cow::Borrowed(en)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_locale_maps_zh_variants() {
// Env var must not leak between tests; these paths avoid it by using
// explicit tags (MINUTES_LANG is not set in the harness).
assert_eq!(resolve_locale("zh-CN"), Locale::ZhCn);
assert_eq!(resolve_locale("zh"), Locale::ZhCn);
assert_eq!(resolve_locale("zh-Hans"), Locale::ZhCn);
assert_eq!(resolve_locale("en"), Locale::En);
assert_eq!(resolve_locale("en-US"), Locale::En);
assert_eq!(resolve_locale("fr"), Locale::En);
}
#[test]
fn tr_is_identity_in_english() {
set_locale(Locale::En);
assert_eq!(tr("Stop Recording"), "Stop Recording");
assert_eq!(
tr("some string with no translation"),
"some string with no translation"
);
}
#[test]
fn tr_translates_known_zh_string() {
// "Stop Recording" is a seeded catalog entry; unknown strings fall back.
set_locale(Locale::ZhCn);
assert_eq!(tr("Stop Recording"), "停止录音");
assert_eq!(
tr("this key is intentionally absent from the catalog"),
"this key is intentionally absent from the catalog"
);
// Restore default so other tests in the process see English.
set_locale(Locale::En);
}
#[test]
fn catalog_parses() {
// Fails loudly if zh-CN.json is malformed JSON.
let _ = zh_cn_catalog();
}
}