use super::*;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
fn root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn catalog(code: &str) -> BTreeMap<String, String> {
let text = fs::read_to_string(root().join("locales").join(format!("{code}.yml"))).unwrap();
let mut map = BTreeMap::new();
for (n, line) in text.lines().enumerate() {
let line = line.trim_end();
if line.is_empty() || line.starts_with('#') || line.starts_with("_version") {
continue;
}
let (key, value) = line
.split_once(": ")
.unwrap_or_else(|| panic!("{code}.yml line {}: no `key: value`", n + 1));
let value = value
.strip_prefix('"')
.and_then(|v| v.strip_suffix('"'))
.unwrap_or_else(|| panic!("{code}.yml line {}: the text must be quoted", n + 1));
assert!(
map.insert(key.to_owned(), value.to_owned()).is_none(),
"{code}.yml repeats the key {key}"
);
}
map
}
fn sources(dir: &Path, out: &mut Vec<(PathBuf, String)>) {
for entry in fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
sources(&path, out);
} else if path.extension().is_some_and(|e| e == "rs")
&& path.file_name().is_some_and(|n| n != "tests.rs")
{
let text = fs::read_to_string(&path).unwrap();
out.push((path, text));
}
}
}
fn tui_sources() -> String {
let mut all = Vec::new();
sources(&root().join("src").join("tui"), &mut all);
all.into_iter()
.filter(|(p, _)| !p.ends_with("i18n/mod.rs"))
.map(|(_, t)| t)
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn every_catalog_has_exactly_the_keys_and_variables_of_en_us() {
let source = catalog("en-US");
for lang in LANGS {
let other = catalog(lang.code());
let missing: Vec<_> = source.keys().filter(|k| !other.contains_key(*k)).collect();
let extra: Vec<_> = other.keys().filter(|k| !source.contains_key(*k)).collect();
assert!(missing.is_empty(), "{} lacks {missing:?}", lang.code());
assert!(extra.is_empty(), "{} has extra {extra:?}", lang.code());
for (key, text) in &source {
assert_eq!(
variables(text),
variables(&other[key]),
"{} / {key}: the variables differ",
lang.code()
);
}
}
}
#[test]
fn every_plural_key_has_both_forms() {
for lang in LANGS {
let map = catalog(lang.code());
for key in map.keys() {
if let Some(base) = key.strip_suffix(".one") {
assert!(map.contains_key(&format!("{base}.other")), "{key}");
}
if let Some(base) = key.strip_suffix(".other") {
assert!(map.contains_key(&format!("{base}.one")), "{key}");
}
}
}
}
#[test]
fn every_key_the_code_uses_exists_in_the_catalog() {
let source = tui_sources();
let known = catalog("en-US");
for (opener, plural) in [(".t(\"", false), (".tw(\"", false), (".tn(\"", true)] {
for part in source.split(opener).skip(1) {
let key = part.split('"').next().unwrap();
if key.contains('{') {
continue;
}
let full = if plural {
format!("{key}.one")
} else {
key.to_owned()
};
assert!(
known.contains_key(&full),
"the code uses {key}, the catalog lacks it"
);
}
}
}
#[test]
fn every_key_of_the_catalog_is_used() {
let source = tui_sources();
for key in catalog("en-US").keys() {
let base = key
.strip_suffix(".one")
.or_else(|| key.strip_suffix(".other"))
.unwrap_or(key);
let built = base
.match_indices('.')
.any(|(i, _)| source.contains(&format!("\"{}.{{", &base[..i])));
let used = source.contains(&format!("\"{base}\"")) || built;
assert!(used, "the catalog key {key} is not used by the code");
}
}
#[test]
fn the_translation_falls_back_to_english_and_then_to_the_key() {
assert_eq!(tr(Lang::PtBr, "help.title"), catalog("pt-BR")["help.title"]);
assert_eq!(tr(Lang::EnUs, "no.such.key"), "no.such.key");
assert_eq!(tr(Lang::PtBr, "no.such.key"), "no.such.key");
}
#[test]
fn variables_are_replaced() {
let text = tr_with(Lang::EnUs, "notice.drained", &[("node", &"prod")]);
assert!(text.contains("prod") && !text.contains("%{"), "{text}");
}
#[test]
fn plural_forms_follow_the_language() {
assert_eq!(tr_n(Lang::EnUs, "cluster.nodes", 1), "1 node");
assert_eq!(tr_n(Lang::EnUs, "cluster.nodes", 0), "0 nodes");
assert_eq!(tr_n(Lang::PtBr, "cluster.nodes", 0), "0 nó");
assert_eq!(tr_n(Lang::PtBr, "cluster.nodes", 2), "2 nós");
assert_eq!(tr_n(Lang::EnUs, "cluster.nodes", 1500), "1,500 nodes");
}
#[test]
fn the_language_is_chosen_in_the_documented_order() {
let env = |vars: &'static [(&'static str, &'static str)]| {
move |name: &str| {
vars.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| (*v).to_owned())
}
};
let none = env(&[]);
assert_eq!(detect(None, &none, None, None), Lang::EnUs);
assert_eq!(detect(None, &none, None, Some("pt-BR")), Lang::PtBr);
let lang = env(&[("LANG", "pt_BR.UTF-8")]);
assert_eq!(detect(None, &lang, None, Some("en-US")), Lang::PtBr);
assert_eq!(detect(None, &lang, Some("en-US"), None), Lang::EnUs);
let var = env(&[("TABA_CLI_LANG", "en"), ("LANG", "pt_BR")]);
assert_eq!(detect(None, &var, Some("pt-BR"), None), Lang::EnUs);
assert_eq!(detect(Some("pt"), &var, None, None), Lang::PtBr);
assert_eq!(detect(Some("fr"), &none, Some("pt-BR"), None), Lang::PtBr);
}
#[test]
fn locale_names_are_normalized() {
for text in ["pt", "pt_BR", "pt-BR", "PT-br", "pt_BR.UTF-8", "pt_BR@euro"] {
assert_eq!(Lang::parse(text), Some(Lang::PtBr), "{text}");
}
assert_eq!(Lang::parse("en_US.UTF-8"), Some(Lang::EnUs));
assert_eq!(Lang::parse("C"), None);
assert_eq!(Lang::parse(""), None);
}
#[test]
fn numbers_sizes_and_durations_follow_the_language() {
assert_eq!(group(Lang::EnUs, 1_234_567), "1,234,567");
assert_eq!(group(Lang::PtBr, 1_234_567), "1.234.567");
assert_eq!(group(Lang::EnUs, 12), "12");
assert_eq!(bytes(Lang::EnUs, 1536), "1.5 KiB");
assert_eq!(bytes(Lang::PtBr, 1536), "1,5 KiB");
assert_eq!(bytes(Lang::EnUs, 12), "12 B");
assert_eq!(bytes(Lang::EnUs, 3 * 1024 * 1024 * 1024), "3.0 GiB");
assert_eq!(duration(9), "9s");
assert_eq!(duration(247), "4m 07s");
assert_eq!(duration(3 * 3600 + 5 * 60), "3h 05m");
assert_eq!(duration(90_000), "1d 01h");
}
#[test]
fn instants_are_read_and_written_in_utc() {
let ms = parse_instant("2026-10-15T12:34:56.789Z").unwrap();
assert_eq!(date_time(Lang::EnUs, ms), "2026-10-15 12:34:56");
assert_eq!(date_time(Lang::PtBr, ms), "15/10/2026 12:34:56");
assert_eq!(ms % 1000, 789);
assert_eq!(parse_instant("1970-01-01T00:00:00Z"), Some(0));
assert_eq!(
parse_instant("2026-10-15T14:34:56+02:00"),
parse_instant("2026-10-15T12:34:56Z")
);
assert_eq!(parse_instant("nonsense"), None);
assert_eq!(clock(ms), "12:34:56");
}