prt-core 0.5.0

Core library for prt — real-time network port scanner with change tracking, alerts, suspicious detection, known ports, bandwidth and container awareness
Documentation
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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
//! Internationalization with runtime language switching.
//!
//! Supports English, Russian, and Chinese. Language is stored in an
//! [`AtomicU8`] for lock-free reads — every frame calls [`strings()`]
//! to get the current string table, so switching is instantaneous.
//!
//! # Language resolution priority
//!
//! 1. `--lang` CLI flag
//! 2. `PRT_LANG` environment variable
//! 3. System locale (via `sys-locale`)
//! 4. English (default)
//!
//! # Adding a new language
//!
//! 1. Create `xx.rs` with `pub static STRINGS: Strings = Strings { ... }`
//! 2. Add variant to [`Lang`] enum
//! 3. Update [`strings()`], [`Lang::next()`], [`Lang::label()`], `Lang::from_u8()`
//! 4. Compile — any missing `Strings` fields will be caught at compile time

pub mod en;
pub mod ru;
pub mod zh;

use std::sync::atomic::{AtomicU8, Ordering};

/// Supported UI language.
///
/// Stored as `u8` in an `AtomicU8` for lock-free runtime switching.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
    En = 0,
    Ru = 1,
    Zh = 2,
}

impl Lang {
    /// Cycle to next language: En → Ru → Zh → En
    pub fn next(self) -> Self {
        match self {
            Self::En => Self::Ru,
            Self::Ru => Self::Zh,
            Self::Zh => Self::En,
        }
    }

    /// Short display name for status bar
    pub fn label(self) -> &'static str {
        match self {
            Self::En => "EN",
            Self::Ru => "RU",
            Self::Zh => "ZH",
        }
    }

    fn from_u8(v: u8) -> Self {
        match v {
            1 => Self::Ru,
            2 => Self::Zh,
            _ => Self::En,
        }
    }
}

static LANG: AtomicU8 = AtomicU8::new(0); // 0 = En

/// Set the active UI language. Thread-safe, lock-free.
pub fn set_lang(lang: Lang) {
    LANG.store(lang as u8, Ordering::Relaxed);
}

/// Get the current UI language.
pub fn lang() -> Lang {
    Lang::from_u8(LANG.load(Ordering::Relaxed))
}

/// Get the string table for the current language.
/// Called on every frame — must be fast (just an atomic load + match).
pub fn strings() -> &'static Strings {
    match lang() {
        Lang::En => &en::STRINGS,
        Lang::Ru => &ru::STRINGS,
        Lang::Zh => &zh::STRINGS,
    }
}

/// Detect language from environment: PRT_LANG env, then system locale, fallback En.
pub fn detect_locale() -> Lang {
    if let Ok(val) = std::env::var("PRT_LANG") {
        return parse_lang(&val);
    }

    if let Some(locale) = sys_locale::get_locale() {
        let lower = locale.to_lowercase();
        if lower.starts_with("ru") {
            return Lang::Ru;
        }
        if lower.starts_with("zh") {
            return Lang::Zh;
        }
    }

    Lang::En
}

/// Parse a language string (e.g. "ru", "chinese") into a [`Lang`] variant.
/// Unknown strings default to English.
pub fn parse_lang(s: &str) -> Lang {
    match s.to_lowercase().as_str() {
        "ru" | "russian" => Lang::Ru,
        "zh" | "cn" | "chinese" => Lang::Zh,
        _ => Lang::En,
    }
}

/// All localizable UI strings for one language.
///
/// Each language module (`en`, `ru`, `zh`) provides a `static STRINGS: Strings`.
/// Adding a field here forces all language files to be updated — compile-time
/// completeness check.
pub struct Strings {
    pub app_name: &'static str,

    // Header
    pub connections: &'static str,
    pub no_root_warning: &'static str,
    pub sudo_ok: &'static str,
    pub filter_label: &'static str,
    pub search_mode: &'static str,

    // Bottom Details panel
    pub detail_panel_title: &'static str,
    pub detail_panel_tree_header: &'static str,
    pub no_selected_process: &'static str,

    // Top-level section labels
    pub section_connections: &'static str,
    pub section_processes: &'static str,
    pub section_ssh: &'static str,

    // Sub-view labels
    pub view_topology: &'static str,
    pub view_process: &'static str,

    // Tree view
    pub process_not_found: &'static str,

    // Interface tab
    pub iface_address: &'static str,
    pub iface_interface: &'static str,
    pub iface_protocol: &'static str,
    pub iface_bind: &'static str,
    pub iface_localhost_only: &'static str,
    pub iface_all_interfaces: &'static str,
    pub iface_specific: &'static str,
    pub iface_loopback: &'static str,
    pub iface_all: &'static str,

    // Connection tab
    pub conn_local: &'static str,
    pub conn_remote: &'static str,
    pub conn_state: &'static str,
    pub conn_process: &'static str,
    pub conn_cmdline: &'static str,

    // Actions
    pub help_text: &'static str,
    pub kill_cancel: &'static str,
    pub copied: &'static str,
    pub refreshed: &'static str,
    pub clipboard_unavailable: &'static str,
    pub scan_error: &'static str,
    pub cancelled: &'static str,
    pub lang_switched: &'static str,
    pub paused: &'static str,
    pub resumed: &'static str,
    pub no_connections: &'static str,
    pub no_filter_matches: &'static str,
    pub more: &'static str,
    pub col_age: &'static str,
    pub col_remote: &'static str,

    // Sudo
    pub sudo_prompt_title: &'static str,
    pub sudo_password_label: &'static str,
    pub sudo_confirm_hint: &'static str,
    pub sudo_failed: &'static str,
    pub sudo_wrong_password: &'static str,
    pub sudo_elevated: &'static str,

    // Footer hints — common
    pub hint_help: &'static str,
    pub hint_search: &'static str,
    pub hint_kill: &'static str,
    pub hint_sudo: &'static str,
    pub hint_quit: &'static str,
    pub hint_lang: &'static str,

    // Footer hints — context-specific
    pub hint_back: &'static str,
    pub hint_details: &'static str,
    pub hint_sort: &'static str,
    pub hint_copy: &'static str,
    pub hint_navigate: &'static str,
    pub hint_section_next: &'static str,
    pub hint_subtab: &'static str,
    pub hint_action_menu: &'static str,
    pub hint_edit_tunnel: &'static str,
    pub hint_pause: &'static str,
    pub hint_resume: &'static str,

    // Action menu
    pub action_menu_title: &'static str,
    pub action_kill: &'static str,
    pub action_copy: &'static str,
    pub action_copy_pid: &'static str,
    pub action_block: &'static str,
    pub action_trace: &'static str,
    pub action_forward: &'static str,
    pub action_unavailable_no_remote: &'static str,
    pub command_palette_title: &'static str,
    pub command_palette_empty: &'static str,

    // Esc cascade hints
    pub esc_again_to_clear_filter: &'static str,
    pub esc_again_to_discard_form: &'static str,

    // Forward dialog
    pub forward_prompt_title: &'static str,
    pub forward_host_label: &'static str,
    pub forward_confirm_hint: &'static str,

    // SSH hosts / tunnels views
    pub view_ssh_hosts: &'static str,
    pub view_tunnels: &'static str,

    // SSH host columns
    pub ssh_col_alias: &'static str,
    pub ssh_col_target: &'static str,
    pub ssh_col_source: &'static str,
    pub ssh_hosts_empty: &'static str,
    pub ssh_hosts_reloaded: &'static str,

    // Tunnels columns
    pub tunnel_col_name: &'static str,
    pub tunnel_col_kind: &'static str,
    pub tunnel_col_local: &'static str,
    pub tunnel_col_remote: &'static str,
    pub tunnel_col_host: &'static str,
    pub tunnel_col_status: &'static str,
    pub tunnel_status_alive: &'static str,
    pub tunnel_status_dead: &'static str,
    pub tunnel_status_starting: &'static str,
    pub tunnel_status_failed: &'static str,
    pub tunnel_form_edit_title: &'static str,
    pub tunnel_form_field_required: &'static str,
    pub tunnels_empty: &'static str,
    pub tunnels_saved: &'static str,
    pub tunnel_killed: &'static str,
    pub tunnel_restarted: &'static str,
    pub tunnel_create_failed: &'static str,
    pub tunnel_kind_local: &'static str,
    pub tunnel_kind_dynamic: &'static str,

    // Tunnel form
    pub tunnel_form_title: &'static str,
    pub tunnel_form_kind: &'static str,
    pub tunnel_form_local_port: &'static str,
    pub tunnel_form_remote_host: &'static str,
    pub tunnel_form_remote_port: &'static str,
    pub tunnel_form_host_alias: &'static str,
    pub tunnel_form_hint: &'static str,
    pub tunnel_form_invalid: &'static str,

    // Footer hints — ssh views
    pub hint_new_tunnel: &'static str,
    pub hint_kill_tunnel: &'static str,
    pub hint_restart_tunnel: &'static str,
    pub hint_save_tunnels: &'static str,
    pub hint_reload: &'static str,
    pub hint_open_tunnel: &'static str,

    // Help overlay
    pub help_title: &'static str,
}

impl Strings {
    pub fn fmt_connections(&self, n: usize) -> String {
        format!("{n} {}", self.connections)
    }

    pub fn fmt_kill_confirm(&self, name: &str, pid: u32) -> String {
        match lang() {
            Lang::En => format!("Kill {name} (pid {pid})?"),
            Lang::Ru => format!("Завершить {name} (pid {pid})?"),
            Lang::Zh => format!("终止 {name} (pid {pid})?"),
        }
    }

    pub fn fmt_kill_sent(&self, sig: &str, name: &str, pid: u32) -> String {
        match lang() {
            Lang::En => format!("sent {sig} to {name} (pid {pid})"),
            Lang::Ru => format!("отправлен {sig}{name} (pid {pid})"),
            Lang::Zh => format!("已发送 {sig}{name} (pid {pid})"),
        }
    }

    pub fn fmt_kill_failed(&self, err: &str) -> String {
        match lang() {
            Lang::En => format!("kill failed: {err}"),
            Lang::Ru => format!("ошибка завершения: {err}"),
            Lang::Zh => format!("终止失败: {err}"),
        }
    }

    pub fn fmt_scan_error(&self, err: &str) -> String {
        format!("{}: {err}", self.scan_error)
    }

    pub fn fmt_all_ports(&self, n: usize) -> String {
        match lang() {
            Lang::En => format!("--- All ports of process ({n}) ---"),
            Lang::Ru => format!("--- Все порты процесса ({n}) ---"),
            Lang::Zh => format!("--- 进程所有端口 ({n}) ---"),
        }
    }

    pub fn fmt_sudo_error(&self, err: &str) -> String {
        match lang() {
            Lang::En => format!("sudo: {err}"),
            Lang::Ru => format!("sudo ошибка: {err}"),
            Lang::Zh => format!("sudo 错误: {err}"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn lang_next_cycles_all() {
        let cases = [
            (Lang::En, Lang::Ru),
            (Lang::Ru, Lang::Zh),
            (Lang::Zh, Lang::En),
        ];
        for (from, expected) in cases {
            assert_eq!(from.next(), expected, "{:?}.next()", from);
        }
    }

    #[test]
    fn lang_next_full_cycle() {
        let start = Lang::En;
        let after_3 = start.next().next().next();
        assert_eq!(after_3, start);
    }

    #[test]
    fn lang_label() {
        let cases = [(Lang::En, "EN"), (Lang::Ru, "RU"), (Lang::Zh, "ZH")];
        for (lang, expected) in cases {
            assert_eq!(lang.label(), expected);
        }
    }

    #[test]
    fn lang_from_u8_table() {
        let cases = [
            (0, Lang::En),
            (1, Lang::Ru),
            (2, Lang::Zh),
            (99, Lang::En),
            (255, Lang::En),
        ];
        for (val, expected) in cases {
            assert_eq!(Lang::from_u8(val), expected, "from_u8({val})");
        }
    }

    #[test]
    fn parse_lang_table() {
        let cases = [
            ("en", Lang::En),
            ("ru", Lang::Ru),
            ("russian", Lang::Ru),
            ("zh", Lang::Zh),
            ("cn", Lang::Zh),
            ("chinese", Lang::Zh),
            ("EN", Lang::En),
            ("RU", Lang::Ru),
            ("ZH", Lang::Zh),
            ("unknown", Lang::En),
            ("", Lang::En),
            ("fr", Lang::En),
        ];
        for (input, expected) in cases {
            assert_eq!(parse_lang(input), expected, "parse_lang({input:?})");
        }
    }

    #[test]
    fn set_and_get_lang() {
        set_lang(Lang::Ru);
        assert_eq!(lang(), Lang::Ru);
        set_lang(Lang::Zh);
        assert_eq!(lang(), Lang::Zh);
        set_lang(Lang::En);
        assert_eq!(lang(), Lang::En);
    }

    #[test]
    fn strings_returns_correct_lang() {
        set_lang(Lang::En);
        assert_eq!(strings().app_name, "PRT");
        set_lang(Lang::Ru);
        assert_eq!(strings().app_name, "PRT");
        // Verify a lang-specific field
        assert_eq!(strings().hint_quit, "выход");
        set_lang(Lang::En);
        assert_eq!(strings().hint_quit, "quit");
    }

    #[test]
    fn strings_all_languages_have_non_empty_fields() {
        for l in [Lang::En, Lang::Ru, Lang::Zh] {
            set_lang(l);
            let s = strings();
            assert!(!s.app_name.is_empty(), "{:?} app_name empty", l);
            assert!(!s.connections.is_empty(), "{:?} connections empty", l);
            assert!(!s.help_text.is_empty(), "{:?} help_text empty", l);
            assert!(!s.hint_help.is_empty(), "{:?} hint_help empty", l);
            assert!(!s.hint_lang.is_empty(), "{:?} hint_lang empty", l);
            assert!(!s.lang_switched.is_empty(), "{:?} lang_switched empty", l);
        }
        set_lang(Lang::En); // restore
    }

    #[test]
    fn fmt_connections_contains_count() {
        set_lang(Lang::En);
        let s = strings();
        assert!(s.fmt_connections(42).contains("42"));
    }

    #[test]
    fn fmt_kill_confirm_contains_name_and_pid() {
        for l in [Lang::En, Lang::Ru, Lang::Zh] {
            set_lang(l);
            let s = strings();
            let msg = s.fmt_kill_confirm("nginx", 1234);
            assert!(msg.contains("nginx"), "{:?}: {msg}", l);
            assert!(msg.contains("1234"), "{:?}: {msg}", l);
        }
        set_lang(Lang::En);
    }
}