1use anyhow::Result;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum Language {
18 English,
20 Portuguese,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Message {
34 VpsRegistryEmpty,
37 VpsListTitle,
39 VpsAdded {
41 name: String,
43 },
44 VpsRemoved {
46 name: String,
48 },
49 VpsDuplicate {
51 name: String,
53 },
54 VpsNotFound {
56 name: String,
58 },
59 VpsActiveSelected {
61 name: String,
63 },
64 ConfigPathLabel,
67 ConfigPath {
69 path: String,
71 },
72 ConfigNoKeys,
74 ErrorLoadConfig,
77 ErrorSaveConfig,
79 ErrorSshConnection,
81 ErrorCommandFailed,
83 ErrorInvalidArgument {
85 detail: String,
87 },
88 ErrorGeneric {
90 detail: String,
92 },
93 VpsEdited {
95 name: String,
97 },
98 ExportCompleted {
100 path: String,
102 },
103 ImportCompleted,
105 PrimaryKeyReady {
107 source: String,
109 key_file: String,
111 },
112 ReencryptCompleted {
114 hosts: usize,
116 },
117 Success {
119 detail: String,
121 },
122 TunnelActive {
125 local_port: u16,
127 remote_host: String,
129 remote_port: u16,
131 vps_name: String,
133 },
134 TunnelPressCtrlC,
136 HealthCheckOk {
139 name: String,
141 },
142 HealthCheckNoVps,
144 HealthCheckFailed {
146 name: String,
148 detail: String,
150 },
151 HealthCheckLatency {
153 name: String,
155 latency_ms: u64,
157 },
158 OperationCancelled,
160 ScpUploadCompleted {
163 bytes: u64,
165 ms: u64,
167 },
168 ScpDownloadCompleted {
170 bytes: u64,
172 ms: u64,
174 },
175 ScpUploadFileOnly,
177 ScpDownloadLocalNotDirectory,
179}
180
181impl Message {
182 pub fn text(&self, language: Language) -> String {
186 match language {
187 Language::English => en(self),
188 Language::Portuguese => pt(self),
189 }
190 }
191}
192
193pub fn initialize_language(force_lang: Option<&str>) -> Result<()> {
197 let language = crate::locale::resolve_language(force_lang);
198 crate::locale::set_language(language);
199 Ok(())
200}
201
202#[must_use]
204pub fn current_language() -> Language {
205 crate::locale::current_language()
206}
207
208#[must_use]
223pub fn t(msg: Message) -> String {
224 msg.text(current_language())
225}
226
227fn en(msg: &Message) -> String {
229 match msg {
230 Message::VpsRegistryEmpty => "No VPS registered.".to_string(),
231 Message::VpsListTitle => "Registered VPS:".to_string(),
232 Message::VpsAdded { name } => format!("VPS '{name}' added successfully."),
233 Message::VpsRemoved { name } => format!("VPS '{name}' removed successfully."),
234 Message::VpsDuplicate { name } => format!("VPS '{name}' is already registered."),
235 Message::VpsNotFound { name } => format!("VPS '{name}' not found."),
236 Message::VpsActiveSelected { name } => format!("Active VPS: '{name}'."),
237 Message::ConfigPathLabel => "Configuration file:".to_string(),
238 Message::ConfigPath { path } => path.clone(),
239 Message::ConfigNoKeys => "No API keys configured.".to_string(),
240 Message::ErrorLoadConfig => "Failed to load configuration.".to_string(),
241 Message::ErrorSaveConfig => "Failed to save configuration.".to_string(),
242 Message::ErrorSshConnection => "SSH connection error.".to_string(),
243 Message::ErrorCommandFailed => "Command execution failed.".to_string(),
244 Message::ErrorInvalidArgument { detail } => format!("Invalid argument: {detail}"),
245 Message::ErrorGeneric { detail } => detail.clone(),
246 Message::VpsEdited { name } => format!("VPS '{name}' edited."),
247 Message::ExportCompleted { path } => format!("exported to {path}"),
248 Message::ImportCompleted => "import completed".to_string(),
249 Message::PrimaryKeyReady { source, key_file } => {
250 format!("primary-key ready (source={source}; key_file={key_file})")
251 }
252 Message::ReencryptCompleted { hosts } => {
253 format!("re-encrypt completed for {hosts} host(s)")
254 }
255 Message::Success { detail } => detail.clone(),
256 Message::TunnelActive {
257 local_port,
258 remote_host,
259 remote_port,
260 vps_name,
261 } => format!(
262 "SSH tunnel active: localhost:{local_port} -> {remote_host}:{remote_port} via {vps_name}"
263 ),
264 Message::TunnelPressCtrlC => "Press Ctrl+C to terminate.".to_string(),
265 Message::HealthCheckOk { name } => format!("Health check passed for '{name}'."),
266 Message::HealthCheckNoVps => {
267 "No active VPS. Use 'ssh-cli connect <NAME>' first.".to_string()
268 }
269 Message::HealthCheckFailed { name, detail } => {
270 format!("Health check FAILED for '{name}': {detail}")
271 }
272 Message::HealthCheckLatency { name, latency_ms } => {
273 format!("Health check OK for '{name}' ({latency_ms}ms)")
274 }
275 Message::OperationCancelled => "Operation cancelled by user.".to_string(),
276 Message::ScpUploadCompleted { bytes, ms } => {
277 format!("Upload completed: {bytes} bytes in {ms}ms")
278 }
279 Message::ScpDownloadCompleted { bytes, ms } => {
280 format!("Download completed: {bytes} bytes in {ms}ms")
281 }
282 Message::ScpUploadFileOnly => {
283 "upload only supports regular files (no directories / no -r)".to_string()
284 }
285 Message::ScpDownloadLocalNotDirectory => {
286 "download local path must be a file path, not an existing directory".to_string()
287 }
288 }
289}
290
291fn pt(msg: &Message) -> String {
293 match msg {
294 Message::VpsRegistryEmpty => "Nenhum VPS cadastrado.".to_string(),
295 Message::VpsListTitle => "VPS cadastrados:".to_string(),
296 Message::VpsAdded { name } => format!("VPS '{name}' adicionada com sucesso."),
297 Message::VpsRemoved { name } => format!("VPS '{name}' removida com sucesso."),
298 Message::VpsDuplicate { name } => format!("VPS '{name}' já está cadastrada."),
299 Message::VpsNotFound { name } => format!("VPS '{name}' não encontrada."),
300 Message::VpsActiveSelected { name } => format!("VPS ativa: '{name}'."),
301 Message::ConfigPathLabel => "Arquivo de configuração:".to_string(),
302 Message::ConfigPath { path } => path.clone(),
303 Message::ConfigNoKeys => "Nenhuma chave de API configurada.".to_string(),
304 Message::ErrorLoadConfig => "Falha ao carregar configuração.".to_string(),
305 Message::ErrorSaveConfig => "Falha ao salvar configuração.".to_string(),
306 Message::ErrorSshConnection => "Erro de conexão SSH.".to_string(),
307 Message::ErrorCommandFailed => "Falha na execução do comando.".to_string(),
308 Message::ErrorInvalidArgument { detail } => format!("Argumento inválido: {detail}"),
309 Message::ErrorGeneric { detail } => detail.clone(),
310 Message::VpsEdited { name } => format!("VPS '{name}' editada."),
311 Message::ExportCompleted { path } => format!("exportado para {path}"),
312 Message::ImportCompleted => "importação concluída".to_string(),
313 Message::PrimaryKeyReady { source, key_file } => {
314 format!("primary-key pronta (source={source}; key_file={key_file})")
315 }
316 Message::ReencryptCompleted { hosts } => {
317 format!("re-cifragem concluída para {hosts} host(s)")
318 }
319 Message::Success { detail } => detail.clone(),
320 Message::TunnelActive {
321 local_port,
322 remote_host,
323 remote_port,
324 vps_name,
325 } => format!(
326 "Tunnel SSH: localhost:{local_port} -> {remote_host}:{remote_port} via {vps_name}"
327 ),
328 Message::TunnelPressCtrlC => "Pressione Ctrl+C para encerrar.".to_string(),
329 Message::HealthCheckOk { name } => format!("Health check bem-sucedido para '{name}'."),
330 Message::HealthCheckNoVps => {
331 "Nenhuma VPS ativa. Use 'ssh-cli connect <NOME>' primeiro.".to_string()
332 }
333 Message::HealthCheckFailed { name, detail } => {
334 format!("Health check FALHOU para '{name}': {detail}")
335 }
336 Message::HealthCheckLatency { name, latency_ms } => {
337 format!("Health check OK para '{name}' ({latency_ms}ms)")
338 }
339 Message::OperationCancelled => "Operação cancelada pelo usuário.".to_string(),
340 Message::ScpUploadCompleted { bytes, ms } => {
341 format!("Upload concluído: {bytes} bytes em {ms}ms")
342 }
343 Message::ScpDownloadCompleted { bytes, ms } => {
344 format!("Download concluído: {bytes} bytes em {ms}ms")
345 }
346 Message::ScpUploadFileOnly => {
347 "upload só suporta arquivos regulares (sem diretórios / sem -r)".to_string()
348 }
349 Message::ScpDownloadLocalNotDirectory => {
350 "caminho local de download deve ser arquivo, não diretório existente".to_string()
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn language_enum_is_copy() {
361 let a = Language::English;
362 let b = a;
363 assert_eq!(a, b);
364 }
365
366 #[test]
367 fn message_is_not_copy_but_is_clone() {
368 let m = Message::VpsAdded {
369 name: "vps-01".to_string(),
370 };
371 let m2 = m.clone();
372 assert_eq!(m, m2);
373 }
374
375 #[test]
376 fn vps_registry_empty_en() {
377 assert_eq!(
378 Message::VpsRegistryEmpty.text(Language::English),
379 "No VPS registered."
380 );
381 }
382
383 #[test]
384 fn vps_registry_empty_pt() {
385 assert_eq!(
386 Message::VpsRegistryEmpty.text(Language::Portuguese),
387 "Nenhum VPS cadastrado."
388 );
389 }
390
391 #[test]
392 fn vps_added_includes_name_en() {
393 let msg = Message::VpsAdded {
394 name: "prod-01".to_string(),
395 };
396 assert_eq!(
397 msg.text(Language::English),
398 "VPS 'prod-01' added successfully."
399 );
400 }
401
402 #[test]
403 fn vps_added_includes_name_pt() {
404 let msg = Message::VpsAdded {
405 name: "prod-01".to_string(),
406 };
407 assert_eq!(
408 msg.text(Language::Portuguese),
409 "VPS 'prod-01' adicionada com sucesso."
410 );
411 }
412
413 #[test]
414 fn vps_removed_includes_name() {
415 let msg = Message::VpsRemoved {
416 name: "dev-01".to_string(),
417 };
418 assert!(msg.text(Language::English).contains("dev-01"));
419 assert!(msg.text(Language::Portuguese).contains("dev-01"));
420 }
421
422 #[test]
423 fn vps_duplicate_includes_name() {
424 let msg = Message::VpsDuplicate {
425 name: "staging".to_string(),
426 };
427 assert!(msg.text(Language::English).contains("staging"));
428 assert!(msg.text(Language::Portuguese).contains("staging"));
429 }
430
431 #[test]
432 fn vps_not_found_includes_name() {
433 let msg = Message::VpsNotFound {
434 name: "inexistente".to_string(),
435 };
436 assert!(msg.text(Language::English).contains("inexistente"));
437 assert!(msg.text(Language::Portuguese).contains("inexistente"));
438 }
439
440 #[test]
441 fn tunnel_active_includes_all_fields() {
442 let msg = Message::TunnelActive {
443 local_port: 8080,
444 remote_host: "1.2.3.4".to_string(),
445 remote_port: 22,
446 vps_name: "meu-servidor".to_string(),
447 };
448 let en = msg.text(Language::English);
449 assert!(en.contains("8080"));
450 assert!(en.contains("1.2.3.4"));
451 assert!(en.contains("22"));
452 assert!(en.contains("meu-servidor"));
453 }
454
455 #[test]
456 fn error_invalid_argument_includes_detail() {
457 let msg = Message::ErrorInvalidArgument {
458 detail: "port out of range".to_string(),
459 };
460 assert!(msg
461 .text(Language::English)
462 .contains("port out of range"));
463 assert!(msg
464 .text(Language::Portuguese)
465 .contains("port out of range"));
466 }
467
468 #[test]
469 fn health_check_ok_includes_name() {
470 let msg = Message::HealthCheckOk {
471 name: "prod-01".to_string(),
472 };
473 assert!(msg.text(Language::English).contains("prod-01"));
474 assert!(msg.text(Language::Portuguese).contains("prod-01"));
475 }
476
477 #[test]
478 fn all_unit_variants_en_nonempty() {
479 let unit_variants = [
480 Message::VpsRegistryEmpty,
481 Message::VpsListTitle,
482 Message::ConfigPathLabel,
483 Message::ConfigNoKeys,
484 Message::ErrorLoadConfig,
485 Message::ErrorSaveConfig,
486 Message::ErrorSshConnection,
487 Message::ErrorCommandFailed,
488 Message::TunnelPressCtrlC,
489 Message::HealthCheckNoVps,
490 Message::OperationCancelled,
491 ];
492 for v in &unit_variants {
493 let text = v.text(Language::English);
494 assert!(!text.is_empty(), "empty EN for {:?}", v);
495 }
496 }
497
498 #[test]
499 fn all_unit_variants_pt_nonempty() {
500 let unit_variants = [
501 Message::VpsRegistryEmpty,
502 Message::VpsListTitle,
503 Message::ConfigPathLabel,
504 Message::ConfigNoKeys,
505 Message::ErrorLoadConfig,
506 Message::ErrorSaveConfig,
507 Message::ErrorSshConnection,
508 Message::ErrorCommandFailed,
509 Message::TunnelPressCtrlC,
510 Message::HealthCheckNoVps,
511 Message::OperationCancelled,
512 ];
513 for v in &unit_variants {
514 let text = v.text(Language::Portuguese);
515 assert!(!text.is_empty(), "empty PT for {:?}", v);
516 }
517 }
518
519 #[test]
520 fn pt_translations_differ_from_en_for_units() {
521 let pairs = [
522 (Message::VpsRegistryEmpty, Message::VpsRegistryEmpty),
523 (Message::ErrorSshConnection, Message::ErrorSshConnection),
524 (Message::HealthCheckNoVps, Message::HealthCheckNoVps),
525 (Message::OperationCancelled, Message::OperationCancelled),
526 ];
527 for (a, b) in &pairs {
528 let en = a.text(Language::English);
529 let pt = b.text(Language::Portuguese);
530 assert_ne!(en, pt, "EN == PT for {:?}", a);
531 }
532 }
533
534 #[test]
535 fn health_check_failed_includes_name_and_detail() {
536 let msg = Message::HealthCheckFailed {
537 name: "prod-01".to_string(),
538 detail: "timeout".to_string(),
539 };
540 assert!(msg.text(Language::English).contains("prod-01"));
541 assert!(msg.text(Language::English).contains("timeout"));
542 assert!(msg.text(Language::Portuguese).contains("prod-01"));
543 assert!(msg.text(Language::Portuguese).contains("timeout"));
544 }
545
546 #[test]
547 fn health_check_latency_includes_name_and_ms() {
548 let msg = Message::HealthCheckLatency {
549 name: "relay-01".to_string(),
550 latency_ms: 42,
551 };
552 assert!(msg.text(Language::English).contains("relay-01"));
553 assert!(msg.text(Language::English).contains("42"));
554 assert!(msg.text(Language::Portuguese).contains("relay-01"));
555 assert!(msg.text(Language::Portuguese).contains("42"));
556 }
557
558 #[test]
559 fn initialize_language_without_force_no_panic() {
560 let result = initialize_language(None);
561 assert!(result.is_ok());
562 }
563
564 #[test]
565 fn initialize_language_with_pt_br_works() {
566 let result = initialize_language(Some("pt-BR"));
567 assert!(result.is_ok());
568 }
569
570 #[test]
571 fn current_language_returns_valid_value() {
572 let language = current_language();
573 assert!(language == Language::English || language == Language::Portuguese);
574 }
575}