#![forbid(unsafe_code)]
use anyhow::Result;
use unic_langid::LanguageIdentifier;
use crate::errors::SshCliError;
mod en;
mod pt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TextDirection {
Ltr,
Rtl,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Language {
English,
Portuguese,
}
impl Language {
pub const AVAILABLE: &'static [Language] = &[Language::English, Language::Portuguese];
#[must_use]
pub const fn bcp47(self) -> &'static str {
match self {
Self::English => "en",
Self::Portuguese => "pt-BR",
}
}
#[must_use]
pub fn language_identifier(self) -> LanguageIdentifier {
self.bcp47()
.parse()
.unwrap_or_else(|_| LanguageIdentifier::default())
}
#[must_use]
pub const fn fallback(self) -> Language {
match self {
Self::English => Self::English,
Self::Portuguese => Self::English,
}
}
#[must_use]
pub const fn direction(self) -> TextDirection {
match self {
Self::English | Self::Portuguese => TextDirection::Ltr,
}
}
#[must_use]
pub const fn script(self) -> &'static str {
match self {
Self::English | Self::Portuguese => "Latn",
}
}
#[must_use]
pub fn from_langid(id: &LanguageIdentifier) -> Option<Language> {
match id.language.as_str() {
"en" => Some(Self::English),
"pt" => Some(Self::Portuguese),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Message {
VpsRegistryEmpty,
VpsAdded {
name: String,
},
VpsRemoved {
name: String,
},
VpsDuplicate {
name: String,
},
VpsNotFound {
name: String,
},
VpsActiveSelected {
name: String,
},
ErrorConfig {
detail: String,
},
ErrorSshConnection {
detail: String,
},
ErrorAuthentication {
detail: String,
},
ErrorCommandFailed {
detail: String,
},
ErrorHostKeyChanged {
detail: String,
},
ErrorTimeout {
detail: String,
},
ErrorFileNotFound {
path: String,
},
ErrorUnavailable {
service: String,
},
ErrorSoftware {
op: String,
},
ErrorPartialFailure {
detail: String,
},
ErrorInvalidArgument {
detail: String,
},
ErrorNoActiveVps,
ErrorUnexpected {
detail: String,
},
VpsEdited {
name: String,
},
ExportCompleted {
path: String,
},
ImportCompleted,
PrimaryKeyReady {
source: String,
key_file: String,
},
ReencryptCompleted {
hosts: usize,
},
TunnelPressCtrlC,
HealthCheckOk {
name: String,
},
ScpUploadCompleted {
bytes: u64,
ms: u64,
},
ScpDownloadCompleted {
bytes: u64,
ms: u64,
},
SftpUploadCompleted {
bytes: u64,
ms: u64,
},
SftpDownloadCompleted {
bytes: u64,
ms: u64,
},
SftpFsOpDone {
op: String,
path: String,
ms: u64,
},
SftpFsOpDoneTo {
op: String,
path: String,
to: String,
ms: u64,
},
LocalePreferenceSaved {
lang: String,
path: String,
},
LocalePreferenceCleared,
LocaleStatusTitle,
TunnelLocalListening {
bind: String,
port: u16,
remote_host: String,
remote_port: u16,
vps: String,
timeout_ms: u64,
},
TunnelSocks5Listening {
bind: String,
port: u16,
vps: String,
timeout_ms: u64,
},
TunnelStreamLocalListening {
bind: String,
port: u16,
socket_path: String,
vps: String,
timeout_ms: u64,
},
TunnelReverseListening {
remote_bind: String,
remote_port: u16,
local_host: String,
local_port: u16,
vps: String,
timeout_ms: u64,
},
}
impl Message {
pub fn text(&self, language: Language) -> String {
match language {
Language::English => en::en(self),
Language::Portuguese => pt::pt(self),
}
}
}
pub fn initialize_language(
force_lang: Option<&str>,
config_dir_override: Option<&std::path::Path>,
) -> Result<()> {
let resolution = crate::locale::resolve_language_detailed(force_lang, config_dir_override);
tracing::debug!(
target: "ssh_cli::i18n",
language = resolution.language.bcp47(),
source = resolution.source.as_str(),
"locale resolved"
);
crate::locale::set_language(resolution.language);
Ok(())
}
#[must_use]
pub fn current_language() -> Language {
crate::locale::current_language()
}
#[must_use]
#[allow(clippy::needless_pass_by_value)]
pub fn t(msg: Message) -> String {
msg.text(current_language())
}
#[must_use]
pub fn localized_unexpected_text(detail: &str) -> String {
t(Message::ErrorUnexpected {
detail: detail.to_string(),
})
}
#[must_use]
pub fn localized_error_text(err: &SshCliError) -> Option<String> {
use std::fmt::Write as _;
let msg = match err {
SshCliError::Config(detail) => Message::ErrorConfig {
detail: detail.clone(),
},
SshCliError::SshConnection(detail) | SshCliError::ConnectionFailed(detail) => {
Message::ErrorSshConnection {
detail: detail.clone(),
}
}
SshCliError::SshAuthentication(detail) => Message::ErrorAuthentication {
detail: detail.clone(),
},
SshCliError::AuthenticationFailed => Message::ErrorAuthentication {
detail: "try --password-stdin, --key PATH, --key-passphrase-stdin, \
or verify the user"
.to_string(),
},
SshCliError::CommandFailed { exit_code, stderr } => {
let mut detail = format!("exit {exit_code}");
if !stderr.is_empty() {
let _ = write!(detail, ": {stderr}");
}
Message::ErrorCommandFailed { detail }
}
SshCliError::HostKeyChanged {
host,
port,
expected,
obtained,
} => Message::ErrorHostKeyChanged {
detail: format!(
"{host}:{port} expected {expected}, got {obtained} \
(use --replace-host-key if legitimate)"
),
},
SshCliError::SshTimeout(ms) | SshCliError::Timeout(ms) => Message::ErrorTimeout {
detail: format!("{ms}ms"),
},
SshCliError::FileNotFound(path) => Message::ErrorFileNotFound { path: path.clone() },
SshCliError::Unavailable { service } => Message::ErrorUnavailable {
service: (*service).to_string(),
},
SshCliError::Software { op } => Message::ErrorSoftware {
op: (*op).to_string(),
},
SshCliError::PartialFailure { failed, total, op } => Message::ErrorPartialFailure {
detail: format!("{failed}/{total} ({op})"),
},
SshCliError::InvalidArgument(detail) => Message::ErrorInvalidArgument {
detail: detail.clone(),
},
SshCliError::NoActiveVps => Message::ErrorNoActiveVps,
SshCliError::VpsNotFound(name) => Message::VpsNotFound { name: name.clone() },
SshCliError::VpsDuplicate(name) => Message::VpsDuplicate { name: name.clone() },
_ => return None,
};
Some(t(msg))
}
#[cfg(test)]
#[path = "i18n_tests.rs"]
mod tests;