1#![forbid(unsafe_code)]
4use anyhow::Result;
27use unic_langid::LanguageIdentifier;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31#[non_exhaustive]
32pub enum TextDirection {
33 Ltr,
35 Rtl,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44#[non_exhaustive]
45pub enum Language {
46 English,
48 Portuguese,
50}
51
52impl Language {
53 pub const AVAILABLE: &'static [Language] = &[Language::English, Language::Portuguese];
55
56 #[must_use]
60 pub const fn bcp47(self) -> &'static str {
61 match self {
62 Self::English => "en",
63 Self::Portuguese => "pt-BR",
64 }
65 }
66
67 #[must_use]
73 pub fn language_identifier(self) -> LanguageIdentifier {
74 self.bcp47()
75 .parse()
76 .unwrap_or_else(|_| LanguageIdentifier::default())
77 }
78
79 #[must_use]
81 pub const fn fallback(self) -> Language {
82 match self {
83 Self::English => Self::English,
84 Self::Portuguese => Self::English,
85 }
86 }
87
88 #[must_use]
90 pub const fn direction(self) -> TextDirection {
91 match self {
92 Self::English | Self::Portuguese => TextDirection::Ltr,
93 }
94 }
95
96 #[must_use]
98 pub const fn script(self) -> &'static str {
99 match self {
100 Self::English | Self::Portuguese => "Latn",
101 }
102 }
103
104 #[must_use]
110 pub fn from_langid(id: &LanguageIdentifier) -> Option<Language> {
111 match id.language.as_str() {
112 "en" => Some(Self::English),
113 "pt" => Some(Self::Portuguese),
114 _ => None,
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum Message {
129 VpsRegistryEmpty,
132 VpsListTitle,
134 VpsAdded {
136 name: String,
138 },
139 VpsRemoved {
141 name: String,
143 },
144 VpsDuplicate {
146 name: String,
148 },
149 VpsNotFound {
151 name: String,
153 },
154 VpsActiveSelected {
156 name: String,
158 },
159 ConfigPathLabel,
162 ConfigPath {
164 path: String,
166 },
167 ConfigNoKeys,
169 ErrorLoadConfig,
172 ErrorSaveConfig,
174 ErrorSshConnection,
176 ErrorCommandFailed,
178 ErrorInvalidArgument {
180 detail: String,
182 },
183 ErrorGeneric {
185 detail: String,
187 },
188 VpsEdited {
190 name: String,
192 },
193 ExportCompleted {
195 path: String,
197 },
198 ImportCompleted,
200 PrimaryKeyReady {
202 source: String,
204 key_file: String,
206 },
207 ReencryptCompleted {
209 hosts: usize,
211 },
212 Success {
214 detail: String,
216 },
217 TunnelActive {
220 local_port: u16,
222 remote_host: String,
224 remote_port: u16,
226 vps_name: String,
228 },
229 TunnelPressCtrlC,
231 HealthCheckOk {
234 name: String,
236 },
237 HealthCheckNoVps,
239 HealthCheckFailed {
241 name: String,
243 detail: String,
245 },
246 HealthCheckLatency {
248 name: String,
250 latency_ms: u64,
252 },
253 OperationCancelled,
255 ScpUploadCompleted {
258 bytes: u64,
260 ms: u64,
262 },
263 ScpDownloadCompleted {
265 bytes: u64,
267 ms: u64,
269 },
270 ScpUploadFileOnly,
272 ScpDownloadLocalNotDirectory,
274 SftpUploadCompleted {
276 bytes: u64,
278 ms: u64,
280 },
281 SftpDownloadCompleted {
283 bytes: u64,
285 ms: u64,
287 },
288 LocalePreferenceSaved {
291 lang: String,
293 path: String,
295 },
296 LocalePreferenceCleared,
298 LocaleStatusTitle,
300}
301
302impl Message {
303 pub fn text(&self, language: Language) -> String {
307 match language {
308 Language::English => en(self),
309 Language::Portuguese => pt(self),
310 }
311 }
312}
313
314pub fn initialize_language(
320 force_lang: Option<&str>,
321 config_dir_override: Option<&std::path::Path>,
322) -> Result<()> {
323 let resolution =
324 crate::locale::resolve_language_detailed(force_lang, config_dir_override);
325 tracing::debug!(
326 target: "ssh_cli::i18n",
327 language = resolution.language.bcp47(),
328 source = resolution.source.as_str(),
329 "locale resolved"
330 );
331 crate::locale::set_language(resolution.language);
332 Ok(())
333}
334
335#[must_use]
337pub fn current_language() -> Language {
338 crate::locale::current_language()
339}
340
341#[must_use]
358#[allow(clippy::needless_pass_by_value)]
359pub fn t(msg: Message) -> String {
360 msg.text(current_language())
361}
362
363fn en(msg: &Message) -> String {
365 match msg {
366 Message::VpsRegistryEmpty => "No VPS registered.".to_string(),
367 Message::VpsListTitle => "Registered VPS:".to_string(),
368 Message::VpsAdded { name } => format!("VPS '{name}' added successfully."),
369 Message::VpsRemoved { name } => format!("VPS '{name}' removed successfully."),
370 Message::VpsDuplicate { name } => format!("VPS '{name}' is already registered."),
371 Message::VpsNotFound { name } => format!("VPS '{name}' not found."),
372 Message::VpsActiveSelected { name } => format!("Active VPS: '{name}'."),
373 Message::ConfigPathLabel => "Configuration file:".to_string(),
374 Message::ConfigPath { path } => path.clone(),
375 Message::ConfigNoKeys => "No API keys configured.".to_string(),
376 Message::ErrorLoadConfig => "Failed to load configuration.".to_string(),
377 Message::ErrorSaveConfig => "Failed to save configuration.".to_string(),
378 Message::ErrorSshConnection => "SSH connection error.".to_string(),
379 Message::ErrorCommandFailed => "Command execution failed.".to_string(),
380 Message::ErrorInvalidArgument { detail } => format!("Invalid argument: {detail}"),
381 Message::ErrorGeneric { detail } => detail.clone(),
382 Message::VpsEdited { name } => format!("VPS '{name}' edited."),
383 Message::ExportCompleted { path } => format!("exported to {path}"),
384 Message::ImportCompleted => "import completed".to_string(),
385 Message::PrimaryKeyReady { source, key_file } => {
386 format!("primary-key ready (source={source}; key_file={key_file})")
387 }
388 Message::ReencryptCompleted { hosts } => {
389 format!("re-encrypt completed for {hosts} host(s)")
390 }
391 Message::Success { detail } => detail.clone(),
392 Message::TunnelActive {
393 local_port,
394 remote_host,
395 remote_port,
396 vps_name,
397 } => format!(
398 "SSH tunnel active: {}:{local_port} -> {remote_host}:{remote_port} via {vps_name}",
399 crate::constants::DEFAULT_TUNNEL_BIND_ADDR
400 ),
401 Message::TunnelPressCtrlC => "Press Ctrl+C to terminate.".to_string(),
402 Message::HealthCheckOk { name } => format!("Health check passed for '{name}'."),
403 Message::HealthCheckNoVps => {
404 "No active VPS. Use 'ssh-cli connect <NAME>' first.".to_string()
405 }
406 Message::HealthCheckFailed { name, detail } => {
407 format!("Health check FAILED for '{name}': {detail}")
408 }
409 Message::HealthCheckLatency { name, latency_ms } => {
410 format!("Health check OK for '{name}' ({latency_ms}ms)")
411 }
412 Message::OperationCancelled => "Operation cancelled by user.".to_string(),
413 Message::ScpUploadCompleted { bytes, ms } => {
414 format!("Upload completed: {bytes} bytes in {ms}ms")
415 }
416 Message::ScpDownloadCompleted { bytes, ms } => {
417 format!("Download completed: {bytes} bytes in {ms}ms")
418 }
419 Message::ScpUploadFileOnly => {
420 "upload only supports regular files (no directories / no -r)".to_string()
421 }
422 Message::ScpDownloadLocalNotDirectory => {
423 "download local path must be a file path, not an existing directory".to_string()
424 }
425 Message::SftpUploadCompleted { bytes, ms } => {
426 format!("SFTP upload completed: {bytes} bytes in {ms}ms")
427 }
428 Message::SftpDownloadCompleted { bytes, ms } => {
429 format!("SFTP download completed: {bytes} bytes in {ms}ms")
430 }
431 Message::LocalePreferenceSaved { lang, path } => {
432 format!("language preference saved: {lang} ({path})")
433 }
434 Message::LocalePreferenceCleared => "language preference cleared.".to_string(),
435 Message::LocaleStatusTitle => "Locale status:".to_string(),
436 }
437}
438
439fn pt(msg: &Message) -> String {
441 match msg {
442 Message::VpsRegistryEmpty => "Nenhum VPS cadastrado.".to_string(),
443 Message::VpsListTitle => "VPS cadastrados:".to_string(),
444 Message::VpsAdded { name } => format!("VPS '{name}' adicionada com sucesso."),
445 Message::VpsRemoved { name } => format!("VPS '{name}' removida com sucesso."),
446 Message::VpsDuplicate { name } => format!("VPS '{name}' já está cadastrada."),
447 Message::VpsNotFound { name } => format!("VPS '{name}' não encontrada."),
448 Message::VpsActiveSelected { name } => format!("VPS ativa: '{name}'."),
449 Message::ConfigPathLabel => "Arquivo de configuração:".to_string(),
450 Message::ConfigPath { path } => path.clone(),
451 Message::ConfigNoKeys => "Nenhuma chave de API configurada.".to_string(),
452 Message::ErrorLoadConfig => "Falha ao carregar configuração.".to_string(),
453 Message::ErrorSaveConfig => "Falha ao salvar configuração.".to_string(),
454 Message::ErrorSshConnection => "Erro de conexão SSH.".to_string(),
455 Message::ErrorCommandFailed => "Falha na execução do comando.".to_string(),
456 Message::ErrorInvalidArgument { detail } => format!("Argumento inválido: {detail}"),
457 Message::ErrorGeneric { detail } => detail.clone(),
458 Message::VpsEdited { name } => format!("VPS '{name}' editada."),
459 Message::ExportCompleted { path } => format!("exportado para {path}"),
460 Message::ImportCompleted => "importação concluída".to_string(),
461 Message::PrimaryKeyReady { source, key_file } => {
462 format!("primary-key pronta (source={source}; key_file={key_file})")
463 }
464 Message::ReencryptCompleted { hosts } => {
465 format!("re-cifragem concluída para {hosts} host(s)")
466 }
467 Message::Success { detail } => detail.clone(),
468 Message::TunnelActive {
469 local_port,
470 remote_host,
471 remote_port,
472 vps_name,
473 } => format!(
474 "Tunnel SSH: {}:{local_port} -> {remote_host}:{remote_port} via {vps_name}",
475 crate::constants::DEFAULT_TUNNEL_BIND_ADDR
476 ),
477 Message::TunnelPressCtrlC => "Pressione Ctrl+C para encerrar.".to_string(),
478 Message::HealthCheckOk { name } => format!("Health check bem-sucedido para '{name}'."),
479 Message::HealthCheckNoVps => {
480 "Nenhuma VPS ativa. Use 'ssh-cli connect <NOME>' primeiro.".to_string()
481 }
482 Message::HealthCheckFailed { name, detail } => {
483 format!("Health check FALHOU para '{name}': {detail}")
484 }
485 Message::HealthCheckLatency { name, latency_ms } => {
486 format!("Health check OK para '{name}' ({latency_ms}ms)")
487 }
488 Message::OperationCancelled => "Operação cancelada pelo usuário.".to_string(),
489 Message::ScpUploadCompleted { bytes, ms } => {
490 format!("Upload concluído: {bytes} bytes em {ms}ms")
491 }
492 Message::ScpDownloadCompleted { bytes, ms } => {
493 format!("Download concluído: {bytes} bytes em {ms}ms")
494 }
495 Message::ScpUploadFileOnly => {
496 "upload só suporta arquivos regulares (sem diretórios / sem -r)".to_string()
497 }
498 Message::ScpDownloadLocalNotDirectory => {
499 "caminho local de download deve ser arquivo, não diretório existente".to_string()
500 }
501 Message::SftpUploadCompleted { bytes, ms } => {
502 format!("Upload SFTP concluído: {bytes} bytes em {ms}ms")
503 }
504 Message::SftpDownloadCompleted { bytes, ms } => {
505 format!("Download SFTP concluído: {bytes} bytes em {ms}ms")
506 }
507 Message::LocalePreferenceSaved { lang, path } => {
508 format!("preferência de idioma salva: {lang} ({path})")
509 }
510 Message::LocalePreferenceCleared => "preferência de idioma removida.".to_string(),
511 Message::LocaleStatusTitle => "Status do locale:".to_string(),
512 }
513}
514
515
516#[cfg(test)]
517#[path = "i18n_tests.rs"]
518mod tests;