Skip to main content

prt_core/i18n/
mod.rs

1//! Internationalization with runtime language switching.
2//!
3//! Supports English, Russian, and Chinese. Language is stored in an
4//! [`AtomicU8`] for lock-free reads — every frame calls [`strings()`]
5//! to get the current string table, so switching is instantaneous.
6//!
7//! # Language resolution priority
8//!
9//! 1. `--lang` CLI flag
10//! 2. `PRT_LANG` environment variable
11//! 3. System locale (via `sys-locale`)
12//! 4. English (default)
13//!
14//! # Adding a new language
15//!
16//! 1. Create `xx.rs` with `pub static STRINGS: Strings = Strings { ... }`
17//! 2. Add variant to [`Lang`] enum
18//! 3. Update [`strings()`], [`Lang::next()`], [`Lang::label()`], `Lang::from_u8()`
19//! 4. Compile — any missing `Strings` fields will be caught at compile time
20
21pub mod en;
22pub mod ru;
23pub mod zh;
24
25use std::sync::atomic::{AtomicU8, Ordering};
26
27/// Supported UI language.
28///
29/// Stored as `u8` in an `AtomicU8` for lock-free runtime switching.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Lang {
32    En = 0,
33    Ru = 1,
34    Zh = 2,
35}
36
37impl Lang {
38    /// Cycle to next language: En → Ru → Zh → En
39    pub fn next(self) -> Self {
40        match self {
41            Self::En => Self::Ru,
42            Self::Ru => Self::Zh,
43            Self::Zh => Self::En,
44        }
45    }
46
47    /// Short display name for status bar
48    pub fn label(self) -> &'static str {
49        match self {
50            Self::En => "EN",
51            Self::Ru => "RU",
52            Self::Zh => "ZH",
53        }
54    }
55
56    fn from_u8(v: u8) -> Self {
57        match v {
58            1 => Self::Ru,
59            2 => Self::Zh,
60            _ => Self::En,
61        }
62    }
63}
64
65static LANG: AtomicU8 = AtomicU8::new(0); // 0 = En
66
67/// Set the active UI language. Thread-safe, lock-free.
68pub fn set_lang(lang: Lang) {
69    LANG.store(lang as u8, Ordering::Relaxed);
70}
71
72/// Get the current UI language.
73pub fn lang() -> Lang {
74    Lang::from_u8(LANG.load(Ordering::Relaxed))
75}
76
77/// Get the string table for the current language.
78/// Called on every frame — must be fast (just an atomic load + match).
79pub fn strings() -> &'static Strings {
80    match lang() {
81        Lang::En => &en::STRINGS,
82        Lang::Ru => &ru::STRINGS,
83        Lang::Zh => &zh::STRINGS,
84    }
85}
86
87/// Detect language from environment: PRT_LANG env, then system locale, fallback En.
88pub fn detect_locale() -> Lang {
89    if let Ok(val) = std::env::var("PRT_LANG") {
90        return parse_lang(&val);
91    }
92
93    if let Some(locale) = sys_locale::get_locale() {
94        let lower = locale.to_lowercase();
95        if lower.starts_with("ru") {
96            return Lang::Ru;
97        }
98        if lower.starts_with("zh") {
99            return Lang::Zh;
100        }
101    }
102
103    Lang::En
104}
105
106/// Parse a language string (e.g. "ru", "chinese") into a [`Lang`] variant.
107/// Unknown strings default to English.
108pub fn parse_lang(s: &str) -> Lang {
109    match s.to_lowercase().as_str() {
110        "ru" | "russian" => Lang::Ru,
111        "zh" | "cn" | "chinese" => Lang::Zh,
112        _ => Lang::En,
113    }
114}
115
116/// All localizable UI strings for one language.
117///
118/// Each language module (`en`, `ru`, `zh`) provides a `static STRINGS: Strings`.
119/// Adding a field here forces all language files to be updated — compile-time
120/// completeness check.
121pub struct Strings {
122    pub app_name: &'static str,
123
124    // Header
125    pub connections: &'static str,
126    pub no_root_warning: &'static str,
127    pub sudo_ok: &'static str,
128    pub filter_label: &'static str,
129    pub search_mode: &'static str,
130
131    // Bottom Details panel
132    pub detail_panel_title: &'static str,
133    pub detail_panel_tree_header: &'static str,
134    pub no_selected_process: &'static str,
135
136    // Top-level section labels
137    pub section_connections: &'static str,
138    pub section_processes: &'static str,
139    pub section_ssh: &'static str,
140
141    // Sub-view labels
142    pub view_topology: &'static str,
143    pub view_process: &'static str,
144
145    // Tree view
146    pub process_not_found: &'static str,
147
148    // Interface tab
149    pub iface_address: &'static str,
150    pub iface_interface: &'static str,
151    pub iface_protocol: &'static str,
152    pub iface_bind: &'static str,
153    pub iface_localhost_only: &'static str,
154    pub iface_all_interfaces: &'static str,
155    pub iface_specific: &'static str,
156    pub iface_loopback: &'static str,
157    pub iface_all: &'static str,
158
159    // Connection tab
160    pub conn_local: &'static str,
161    pub conn_remote: &'static str,
162    pub conn_state: &'static str,
163    pub conn_process: &'static str,
164    pub conn_cmdline: &'static str,
165
166    // Actions
167    pub help_text: &'static str,
168    pub kill_cancel: &'static str,
169    pub copied: &'static str,
170    pub refreshed: &'static str,
171    pub clipboard_unavailable: &'static str,
172    pub scan_error: &'static str,
173    pub cancelled: &'static str,
174    pub lang_switched: &'static str,
175    pub paused: &'static str,
176    pub resumed: &'static str,
177    pub no_connections: &'static str,
178    pub no_filter_matches: &'static str,
179    pub more: &'static str,
180    pub col_age: &'static str,
181    pub col_remote: &'static str,
182
183    // Sudo
184    pub sudo_prompt_title: &'static str,
185    pub sudo_password_label: &'static str,
186    pub sudo_confirm_hint: &'static str,
187    pub sudo_failed: &'static str,
188    pub sudo_wrong_password: &'static str,
189    pub sudo_elevated: &'static str,
190
191    // Footer hints — common
192    pub hint_help: &'static str,
193    pub hint_search: &'static str,
194    pub hint_kill: &'static str,
195    pub hint_sudo: &'static str,
196    pub hint_quit: &'static str,
197    pub hint_lang: &'static str,
198
199    // Footer hints — context-specific
200    pub hint_back: &'static str,
201    pub hint_details: &'static str,
202    pub hint_sort: &'static str,
203    pub hint_copy: &'static str,
204    pub hint_navigate: &'static str,
205    pub hint_section_next: &'static str,
206    pub hint_subtab: &'static str,
207    pub hint_action_menu: &'static str,
208    pub hint_edit_tunnel: &'static str,
209    pub hint_pause: &'static str,
210    pub hint_resume: &'static str,
211
212    // Action menu
213    pub action_menu_title: &'static str,
214    pub action_kill: &'static str,
215    pub action_copy: &'static str,
216    pub action_copy_pid: &'static str,
217    pub action_block: &'static str,
218    pub action_trace: &'static str,
219    pub action_forward: &'static str,
220    pub action_unavailable_no_remote: &'static str,
221    pub command_palette_title: &'static str,
222    pub command_palette_empty: &'static str,
223
224    // Esc cascade hints
225    pub esc_again_to_clear_filter: &'static str,
226    pub esc_again_to_discard_form: &'static str,
227
228    // Forward dialog
229    pub forward_prompt_title: &'static str,
230    pub forward_host_label: &'static str,
231    pub forward_confirm_hint: &'static str,
232
233    // SSH hosts / tunnels views
234    pub view_ssh_hosts: &'static str,
235    pub view_tunnels: &'static str,
236
237    // SSH host columns
238    pub ssh_col_alias: &'static str,
239    pub ssh_col_target: &'static str,
240    pub ssh_col_source: &'static str,
241    pub ssh_hosts_empty: &'static str,
242    pub ssh_hosts_reloaded: &'static str,
243
244    // Tunnels columns
245    pub tunnel_col_name: &'static str,
246    pub tunnel_col_kind: &'static str,
247    pub tunnel_col_local: &'static str,
248    pub tunnel_col_remote: &'static str,
249    pub tunnel_col_host: &'static str,
250    pub tunnel_col_status: &'static str,
251    pub tunnel_status_alive: &'static str,
252    pub tunnel_status_dead: &'static str,
253    pub tunnel_status_starting: &'static str,
254    pub tunnel_status_failed: &'static str,
255    pub tunnel_form_edit_title: &'static str,
256    pub tunnel_form_field_required: &'static str,
257    pub tunnels_empty: &'static str,
258    pub tunnels_saved: &'static str,
259    pub tunnel_killed: &'static str,
260    pub tunnel_restarted: &'static str,
261    pub tunnel_create_failed: &'static str,
262    pub tunnel_kind_local: &'static str,
263    pub tunnel_kind_dynamic: &'static str,
264
265    // Tunnel form
266    pub tunnel_form_title: &'static str,
267    pub tunnel_form_kind: &'static str,
268    pub tunnel_form_local_port: &'static str,
269    pub tunnel_form_remote_host: &'static str,
270    pub tunnel_form_remote_port: &'static str,
271    pub tunnel_form_host_alias: &'static str,
272    pub tunnel_form_hint: &'static str,
273    pub tunnel_form_invalid: &'static str,
274
275    // Footer hints — ssh views
276    pub hint_new_tunnel: &'static str,
277    pub hint_kill_tunnel: &'static str,
278    pub hint_restart_tunnel: &'static str,
279    pub hint_save_tunnels: &'static str,
280    pub hint_reload: &'static str,
281    pub hint_open_tunnel: &'static str,
282
283    // Help overlay
284    pub help_title: &'static str,
285}
286
287impl Strings {
288    pub fn fmt_connections(&self, n: usize) -> String {
289        format!("{n} {}", self.connections)
290    }
291
292    pub fn fmt_kill_confirm(&self, name: &str, pid: u32) -> String {
293        match lang() {
294            Lang::En => format!("Kill {name} (pid {pid})?"),
295            Lang::Ru => format!("Завершить {name} (pid {pid})?"),
296            Lang::Zh => format!("终止 {name} (pid {pid})?"),
297        }
298    }
299
300    pub fn fmt_kill_sent(&self, sig: &str, name: &str, pid: u32) -> String {
301        match lang() {
302            Lang::En => format!("sent {sig} to {name} (pid {pid})"),
303            Lang::Ru => format!("отправлен {sig} → {name} (pid {pid})"),
304            Lang::Zh => format!("已发送 {sig} → {name} (pid {pid})"),
305        }
306    }
307
308    pub fn fmt_kill_failed(&self, err: &str) -> String {
309        match lang() {
310            Lang::En => format!("kill failed: {err}"),
311            Lang::Ru => format!("ошибка завершения: {err}"),
312            Lang::Zh => format!("终止失败: {err}"),
313        }
314    }
315
316    pub fn fmt_scan_error(&self, err: &str) -> String {
317        format!("{}: {err}", self.scan_error)
318    }
319
320    pub fn fmt_all_ports(&self, n: usize) -> String {
321        match lang() {
322            Lang::En => format!("--- All ports of process ({n}) ---"),
323            Lang::Ru => format!("--- Все порты процесса ({n}) ---"),
324            Lang::Zh => format!("--- 进程所有端口 ({n}) ---"),
325        }
326    }
327
328    pub fn fmt_sudo_error(&self, err: &str) -> String {
329        match lang() {
330            Lang::En => format!("sudo: {err}"),
331            Lang::Ru => format!("sudo ошибка: {err}"),
332            Lang::Zh => format!("sudo 错误: {err}"),
333        }
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn lang_next_cycles_all() {
343        let cases = [
344            (Lang::En, Lang::Ru),
345            (Lang::Ru, Lang::Zh),
346            (Lang::Zh, Lang::En),
347        ];
348        for (from, expected) in cases {
349            assert_eq!(from.next(), expected, "{:?}.next()", from);
350        }
351    }
352
353    #[test]
354    fn lang_next_full_cycle() {
355        let start = Lang::En;
356        let after_3 = start.next().next().next();
357        assert_eq!(after_3, start);
358    }
359
360    #[test]
361    fn lang_label() {
362        let cases = [(Lang::En, "EN"), (Lang::Ru, "RU"), (Lang::Zh, "ZH")];
363        for (lang, expected) in cases {
364            assert_eq!(lang.label(), expected);
365        }
366    }
367
368    #[test]
369    fn lang_from_u8_table() {
370        let cases = [
371            (0, Lang::En),
372            (1, Lang::Ru),
373            (2, Lang::Zh),
374            (99, Lang::En),
375            (255, Lang::En),
376        ];
377        for (val, expected) in cases {
378            assert_eq!(Lang::from_u8(val), expected, "from_u8({val})");
379        }
380    }
381
382    #[test]
383    fn parse_lang_table() {
384        let cases = [
385            ("en", Lang::En),
386            ("ru", Lang::Ru),
387            ("russian", Lang::Ru),
388            ("zh", Lang::Zh),
389            ("cn", Lang::Zh),
390            ("chinese", Lang::Zh),
391            ("EN", Lang::En),
392            ("RU", Lang::Ru),
393            ("ZH", Lang::Zh),
394            ("unknown", Lang::En),
395            ("", Lang::En),
396            ("fr", Lang::En),
397        ];
398        for (input, expected) in cases {
399            assert_eq!(parse_lang(input), expected, "parse_lang({input:?})");
400        }
401    }
402
403    #[test]
404    fn set_and_get_lang() {
405        set_lang(Lang::Ru);
406        assert_eq!(lang(), Lang::Ru);
407        set_lang(Lang::Zh);
408        assert_eq!(lang(), Lang::Zh);
409        set_lang(Lang::En);
410        assert_eq!(lang(), Lang::En);
411    }
412
413    #[test]
414    fn strings_returns_correct_lang() {
415        set_lang(Lang::En);
416        assert_eq!(strings().app_name, "PRT");
417        set_lang(Lang::Ru);
418        assert_eq!(strings().app_name, "PRT");
419        // Verify a lang-specific field
420        assert_eq!(strings().hint_quit, "выход");
421        set_lang(Lang::En);
422        assert_eq!(strings().hint_quit, "quit");
423    }
424
425    #[test]
426    fn strings_all_languages_have_non_empty_fields() {
427        for l in [Lang::En, Lang::Ru, Lang::Zh] {
428            set_lang(l);
429            let s = strings();
430            assert!(!s.app_name.is_empty(), "{:?} app_name empty", l);
431            assert!(!s.connections.is_empty(), "{:?} connections empty", l);
432            assert!(!s.help_text.is_empty(), "{:?} help_text empty", l);
433            assert!(!s.hint_help.is_empty(), "{:?} hint_help empty", l);
434            assert!(!s.hint_lang.is_empty(), "{:?} hint_lang empty", l);
435            assert!(!s.lang_switched.is_empty(), "{:?} lang_switched empty", l);
436        }
437        set_lang(Lang::En); // restore
438    }
439
440    #[test]
441    fn fmt_connections_contains_count() {
442        set_lang(Lang::En);
443        let s = strings();
444        assert!(s.fmt_connections(42).contains("42"));
445    }
446
447    #[test]
448    fn fmt_kill_confirm_contains_name_and_pid() {
449        for l in [Lang::En, Lang::Ru, Lang::Zh] {
450            set_lang(l);
451            let s = strings();
452            let msg = s.fmt_kill_confirm("nginx", 1234);
453            assert!(msg.contains("nginx"), "{:?}: {msg}", l);
454            assert!(msg.contains("1234"), "{:?}: {msg}", l);
455        }
456        set_lang(Lang::En);
457    }
458}