use std::collections::HashMap;
use clap::ValueEnum;
use gtfs_core::{FatalError, Notice};
use serde::Deserialize;
const EN_JSON: &str = include_str!("../locales/en.json");
const JA_JSON: &str = include_str!("../locales/ja.json");
const FR_JSON: &str = include_str!("../locales/fr.json");
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum LangArg {
Tr,
En,
Ja,
Fr,
}
#[derive(Debug, Deserialize)]
struct Dictionary {
messages: HashMap<String, String>,
remediations: HashMap<String, String>,
titles: HashMap<String, String>,
#[serde(default)]
fatal_messages: HashMap<String, String>,
}
impl Dictionary {
fn parse(raw: &str, lang: &str) -> Result<Self, String> {
serde_json::from_str(raw)
.map_err(|err| format!("embedded '{lang}' locale is not readable: {err}"))
}
}
pub struct Translator {
primary: Dictionary,
fallback: Option<Dictionary>,
}
impl Translator {
pub fn new(lang: LangArg) -> Result<Option<Self>, String> {
match lang {
LangArg::Tr => Ok(None),
LangArg::En => Ok(Some(Self {
primary: Dictionary::parse(EN_JSON, "en")?,
fallback: None,
})),
LangArg::Ja => Ok(Some(Self {
primary: Dictionary::parse(JA_JSON, "ja")?,
fallback: Some(Dictionary::parse(EN_JSON, "en")?),
})),
LangArg::Fr => Ok(Some(Self {
primary: Dictionary::parse(FR_JSON, "fr")?,
fallback: Some(Dictionary::parse(EN_JSON, "en")?),
})),
}
}
fn lookup<'a>(
&'a self,
field: impl Fn(&'a Dictionary) -> &'a HashMap<String, String>,
rule_id: &str,
) -> Option<&'a str> {
field(&self.primary)
.get(rule_id)
.or_else(|| self.fallback.as_ref().and_then(|d| field(d).get(rule_id)))
.map(String::as_str)
}
pub fn translate(&self, notice: &mut Notice) {
if let Some(title) = self.lookup(|d| &d.titles, ¬ice.rule_id) {
notice.title = title.to_string();
}
let profile_variant = notice
.details
.as_ref()
.and_then(|details| details.get("jp_profile"))
.map(|profile| format!("{}.{}", notice.rule_id, profile.to_lowercase()));
let specific = notice
.details
.as_ref()
.and_then(|details| details.get("message_variant"))
.map(|kind| {
profile_variant.as_deref().map_or_else(
|| format!("{}.{}", notice.rule_id, kind),
|key| format!("{key}.{kind}"),
)
});
if let Some(template) = specific
.as_deref()
.and_then(|key| self.lookup(|d| &d.messages, key))
.or_else(|| {
profile_variant
.as_deref()
.and_then(|key| self.lookup(|d| &d.messages, key))
})
.or_else(|| self.lookup(|d| &d.messages, ¬ice.rule_id))
{
notice.message = fill(template, notice);
}
if let Some(remediation) = specific
.as_deref()
.and_then(|key| self.lookup(|d| &d.remediations, key))
.or_else(|| {
profile_variant
.as_deref()
.and_then(|key| self.lookup(|d| &d.remediations, key))
})
.or_else(|| self.lookup(|d| &d.remediations, ¬ice.rule_id))
{
notice.remediation = remediation.to_string();
}
}
pub fn translate_fatal(&self, err: &mut FatalError) {
if let Some(template) = self.lookup(|d| &d.fatal_messages, &err.template_key()) {
err.message = fill_with(template, |key| {
err.params.get(key).cloned().unwrap_or_default()
});
}
}
pub fn rule_title<'a>(&'a self, rule_id: &str, fallback: &'a str) -> &'a str {
self.lookup(|d| &d.titles, rule_id).unwrap_or(fallback)
}
}
fn fill(template: &str, notice: &Notice) -> String {
fill_with(template, |key| resolve(key, notice))
}
fn fill_with(template: &str, resolve: impl Fn(&str) -> String) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some(open) = rest.find('{') {
out.push_str(&rest[..open]);
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
out.push_str(&rest[open..]);
return out;
};
let key = &after[..close];
if key.is_empty() || !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
out.push('{');
rest = after;
continue;
}
out.push_str(&resolve(key));
rest = &after[close + 1..];
}
out.push_str(rest);
out
}
fn resolve(key: &str, notice: &Notice) -> String {
if let Some(value) = notice.details.as_ref().and_then(|d| d.get(key)) {
return value.clone();
}
match key {
"entity_id" => notice.entity_id.clone().unwrap_or_default(),
"observed_value" => notice.observed_value.clone().unwrap_or_default(),
"expected_value" => notice.expected_value.clone().unwrap_or_default(),
"file" => notice.file.clone().unwrap_or_default(),
"field" => notice.field.clone().unwrap_or_default(),
"line" => notice.line.map(|l| l.to_string()).unwrap_or_default(),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn placeholders(template: &str) -> std::collections::BTreeSet<String> {
template
.split('{')
.skip(1)
.filter_map(|part| part.split_once('}').map(|(key, _)| key.to_string()))
.collect()
}
#[test]
fn fatal_templates_match_across_locales() {
let en = Dictionary::parse(EN_JSON, "en").unwrap();
assert!(
!en.fatal_messages.is_empty(),
"İngilizce fatal şablonları boş"
);
for (lang, raw) in [("fr", FR_JSON), ("ja", JA_JSON)] {
let other = Dictionary::parse(raw, lang).unwrap();
let mut en_keys: Vec<_> = en.fatal_messages.keys().collect();
let mut other_keys: Vec<_> = other.fatal_messages.keys().collect();
en_keys.sort();
other_keys.sort();
assert_eq!(
en_keys, other_keys,
"{lang}: fatal şablon anahtarları farklı"
);
for (key, template) in &en.fatal_messages {
assert_eq!(
placeholders(template),
placeholders(&other.fatal_messages[key]),
"{lang}: {key} yer tutucuları farklı"
);
}
}
}
#[test]
fn fatal_without_template_keeps_the_pipeline_text() {
let translator = Translator::new(LangArg::En).unwrap().unwrap();
let mut known =
FatalError::new(gtfs_core::FatalCode::ZipUnreadable, "ZIP dosyası okunamadı")
.variant("entry_read")
.param("file", "stops.txt")
.param("detail", "bad crc");
translator.translate_fatal(&mut known);
assert_eq!(known.message, "'stops.txt' could not be read: bad crc");
let mut unknown = FatalError::new(gtfs_core::FatalCode::ZipUnreadable, "özgün metin")
.variant("no_such_variant");
translator.translate_fatal(&mut unknown);
assert_eq!(unknown.message, "özgün metin");
}
use gtfs_core::{EntityType, RuleClass, Severity};
fn notice() -> Notice {
Notice {
id: "k2/STM_004#1".to_string(),
rule_id: "STM_004".to_string(),
severity: Severity::Kritik,
rule_class: RuleClass::Spec,
entity_type: EntityType::Trip,
entity_id: Some("T1".to_string()),
scope_key: None,
file: Some("stop_times.txt".to_string()),
line: Some(42),
field: Some("departure_time".to_string()),
observed_value: Some("25:1:00".to_string()),
expected_value: None,
details: None,
whitespace_derived: false,
whitespace_candidate: false,
title: "departure_time geçersiz format".to_string(),
message: "Türkçe mesaj".to_string(),
remediation: "Türkçe çözüm".to_string(),
blocks: Vec::new(),
base_effort: 1,
service_id: None,
}
}
#[test]
fn turkish_needs_no_dictionary() {
assert!(Translator::new(LangArg::Tr).unwrap().is_none());
}
#[test]
fn english_rewrites_title_message_and_remediation() {
let translator = Translator::new(LangArg::En).unwrap().unwrap();
let mut n = notice();
translator.translate(&mut n);
assert!(
n.message.contains("T1"),
"placeholder must be filled: {}",
n.message
);
assert!(
n.message.is_ascii(),
"expected English text, got: {}",
n.message
);
assert_ne!(n.title, "departure_time geçersiz format");
assert_ne!(n.remediation, "Türkçe çözüm");
}
#[test]
fn unknown_rule_keeps_the_pipeline_text() {
let translator = Translator::new(LangArg::En).unwrap().unwrap();
let mut n = notice();
n.rule_id = "NOPE_999".to_string();
translator.translate(&mut n);
assert_eq!(n.message, "Türkçe mesaj");
assert_eq!(n.title, "departure_time geçersiz format");
}
#[test]
fn japanese_falls_back_to_english_then_turkish() {
let translator = Translator::new(LangArg::Ja).unwrap().unwrap();
let mut n = notice();
n.rule_id = "NOPE_999".to_string();
translator.translate(&mut n);
assert_eq!(
n.message, "Türkçe mesaj",
"no dictionary entry → pipeline text"
);
}
#[test]
fn placeholders_resolve_from_notice_fields() {
let n = notice();
assert_eq!(
fill("{entity_id}@{file}:{line}", &n),
"T1@stop_times.txt:42"
);
assert_eq!(fill("[{observed_value}]", &n), "[25:1:00]");
assert_eq!(fill("<{expected_value}>", &n), "<>");
assert_eq!(fill("{nope}", &n), "");
assert_eq!(fill("{not a key}", &n), "{not a key}");
assert_eq!(fill("no braces", &n), "no braces");
assert_eq!(fill("unclosed {brace", &n), "unclosed {brace");
}
#[test]
fn details_shadow_the_fixed_fields() {
let mut n = notice();
n.details = Some(std::collections::BTreeMap::from([(
"entity_id".to_string(),
"override".to_string(),
)]));
assert_eq!(fill("{entity_id}", &n), "override");
}
#[test]
fn jp_profile_selects_the_profile_specific_message() {
let translator = Translator::new(LangArg::En).unwrap().unwrap();
let mut n = notice();
n.rule_id = "JPN_006".to_string();
n.details = Some(std::collections::BTreeMap::from([(
"jp_profile".to_string(),
"V4".to_string(),
)]));
translator.translate(&mut n);
assert!(n.message.contains("GTFS-JP v4"), "{}", n.message);
assert!(n.message.contains("cannot be represented"), "{}", n.message);
}
#[test]
fn timepoint_missing_field_variants_translate_without_a_profile() {
for lang in [LangArg::En, LangArg::Ja, LangArg::Fr] {
let translator = Translator::new(lang).unwrap().unwrap();
let mut n = notice();
n.rule_id = "STM_047".to_string();
n.entity_id = Some("T1".to_string());
n.details = Some(std::collections::BTreeMap::from([(
"message_variant".to_string(),
"missing_departure".to_string(),
)]));
translator.translate(&mut n);
let key = "STM_047.missing_departure";
assert_eq!(
n.message,
translator
.lookup(|d| &d.messages, key)
.unwrap()
.replace("{entity_id}", "T1",)
);
assert_eq!(
n.remediation,
translator.lookup(|d| &d.remediations, key).unwrap()
);
}
}
#[test]
fn registry_titles_cover_every_rule() {
for lang in [LangArg::En, LangArg::Ja, LangArg::Fr] {
let translator = Translator::new(lang).unwrap().unwrap();
for meta in gtfs_rules::RULES {
assert_ne!(
translator.rule_title(meta.id, "MISSING"),
"MISSING",
"{:?} locale has no title for {}",
lang,
meta.id
);
}
}
}
#[test]
fn jp_manual_review_and_aggregate_templates_are_used_in_all_locales() {
for lang in [LangArg::En, LangArg::Ja, LangArg::Fr] {
let translator = Translator::new(lang).unwrap().unwrap();
let mut n = notice();
n.rule_id = "JPN_006".into();
n.details = Some(std::collections::BTreeMap::from([
("jp_profile".into(), "V4".into()),
("message_variant".into(), "missing_review".into()),
]));
translator.translate(&mut n);
let specific = translator
.lookup(|d| &d.messages, "JPN_006.v4.missing_review")
.unwrap();
assert_eq!(n.message, specific);
assert_eq!(
n.remediation,
translator
.lookup(|d| &d.remediations, "JPN_006.v4.missing_review")
.unwrap()
);
n.rule_id = "JPN_029".into();
n.field = Some("stop_headsign".into());
n.details = Some(std::collections::BTreeMap::from([
("jp_profile".into(), "v4".into()),
("message_variant".into(), "aggregate".into()),
("table_name".into(), "stop_times".into()),
("source_value".into(), "渋谷".into()),
("affected_records".into(), "18426".into()),
(
"example_record_ids".into(),
"trip_id=T1,stop_sequence=2".into(),
),
]));
translator.translate(&mut n);
for part in [
"18426",
"渋谷",
"stop_times.stop_headsign",
"trip_id=T1,stop_sequence=2",
] {
assert!(n.message.contains(part), "{:?}: {}", lang, n.message);
}
}
}
#[test]
fn every_registered_rule_resolves_in_complete_dictionaries() {
for (lang, raw) in [("en", EN_JSON), ("fr", FR_JSON)] {
let dict = Dictionary::parse(raw, lang).expect("locale parses");
let mut missing: Vec<String> = Vec::new();
for rule in gtfs_rules::RULES {
for (section, table) in [
("titles", &dict.titles),
("messages", &dict.messages),
("remediations", &dict.remediations),
] {
if !table.contains_key(rule.id) {
missing.push(format!("{section}/{}", rule.id));
}
}
}
assert!(
missing.is_empty(),
"'{lang}' locale is missing {} entries: {missing:?}\n\
Add them to ui/src/locales/{lang}.ts, then run `npm run locales:export`.",
missing.len()
);
}
}
}