Skip to main content

everruns_core/
localization.rs

1//! Backend localization helpers for deterministic platform-authored strings.
2//!
3//! Decisions:
4//! - Keep locale fallback simple: Ukrainian (`uk*`) or default English.
5//! - Centralize string catalogs here so backend-authored copy stays externalized
6//!   as we add more locales.
7//! - Allowed locale and timezone lists are the single source of truth for the
8//!   backend. The UI mirrors these in `apps/ui/src/lib/locale-data.ts`.
9
10/// Allowed BCP 47 locale tags. Russia excluded per policy.
11/// UI mirror: `apps/ui/src/lib/locale-data.ts` LOCALE_OPTIONS.
12pub const ALLOWED_LOCALES: &[&str] = &[
13    "af-ZA", "am-ET", "ar-AE", "ar-EG", "ar-SA", "az-AZ", "be-BY", "bg-BG", "bn-BD", "bn-IN",
14    "bs-BA", "ca-ES", "cs-CZ", "cy-GB", "da-DK", "de-AT", "de-CH", "de-DE", "el-GR", "en", "en-AU",
15    "en-CA", "en-GB", "en-IE", "en-IN", "en-NZ", "en-US", "en-ZA", "es-AR", "es-CL", "es-CO",
16    "es-ES", "es-MX", "es-PE", "et-EE", "eu-ES", "fa-IR", "fi-FI", "fil-PH", "fr-BE", "fr-CA",
17    "fr-CH", "fr-FR", "ga-IE", "gl-ES", "gu-IN", "he-IL", "hi-IN", "hr-HR", "hu-HU", "hy-AM",
18    "id-ID", "is-IS", "it-CH", "it-IT", "ja-JP", "ka-GE", "kk-KZ", "km-KH", "kn-IN", "ko-KR",
19    "lo-LA", "lt-LT", "lv-LV", "mk-MK", "ml-IN", "mn-MN", "mr-IN", "ms-MY", "mt-MT", "my-MM",
20    "nb-NO", "ne-NP", "nl-BE", "nl-NL", "pa-IN", "pl-PL", "pt-BR", "pt-PT", "ro-RO", "si-LK",
21    "sk-SK", "sl-SI", "sq-AL", "sr-RS", "sv-SE", "sw-KE", "ta-IN", "te-IN", "th-TH", "tr-TR", "uk",
22    "uk-UA", "ur-PK", "uz-UZ", "vi-VN", "zh-CN", "zh-HK", "zh-TW", "zu-ZA",
23];
24
25/// Allowed IANA timezone names. Russia-specific zones excluded per policy.
26/// UI mirror: `apps/ui/src/lib/locale-data.ts` TIMEZONE_OPTIONS.
27pub const ALLOWED_TIMEZONES: &[&str] = &[
28    "UTC",
29    "Africa/Abidjan",
30    "Africa/Accra",
31    "Africa/Addis_Ababa",
32    "Africa/Algiers",
33    "Africa/Cairo",
34    "Africa/Casablanca",
35    "Africa/Dar_es_Salaam",
36    "Africa/Johannesburg",
37    "Africa/Lagos",
38    "Africa/Nairobi",
39    "Africa/Tunis",
40    "America/Anchorage",
41    "America/Argentina/Buenos_Aires",
42    "America/Bogota",
43    "America/Cancun",
44    "America/Chicago",
45    "America/Denver",
46    "America/Edmonton",
47    "America/Halifax",
48    "America/Havana",
49    "America/Lima",
50    "America/Los_Angeles",
51    "America/Manaus",
52    "America/Mexico_City",
53    "America/New_York",
54    "America/Panama",
55    "America/Phoenix",
56    "America/Santiago",
57    "America/Sao_Paulo",
58    "America/St_Johns",
59    "America/Toronto",
60    "America/Vancouver",
61    "America/Winnipeg",
62    "Asia/Almaty",
63    "Asia/Amman",
64    "Asia/Baghdad",
65    "Asia/Baku",
66    "Asia/Bangkok",
67    "Asia/Beirut",
68    "Asia/Colombo",
69    "Asia/Dhaka",
70    "Asia/Dubai",
71    "Asia/Ho_Chi_Minh",
72    "Asia/Hong_Kong",
73    "Asia/Istanbul",
74    "Asia/Jakarta",
75    "Asia/Jerusalem",
76    "Asia/Kabul",
77    "Asia/Karachi",
78    "Asia/Kathmandu",
79    "Asia/Kolkata",
80    "Asia/Kuala_Lumpur",
81    "Asia/Manila",
82    "Asia/Riyadh",
83    "Asia/Seoul",
84    "Asia/Shanghai",
85    "Asia/Singapore",
86    "Asia/Taipei",
87    "Asia/Tashkent",
88    "Asia/Tbilisi",
89    "Asia/Tehran",
90    "Asia/Tokyo",
91    "Asia/Ulaanbaatar",
92    "Asia/Yangon",
93    "Asia/Yerevan",
94    "Atlantic/Azores",
95    "Atlantic/Reykjavik",
96    "Australia/Adelaide",
97    "Australia/Brisbane",
98    "Australia/Darwin",
99    "Australia/Hobart",
100    "Australia/Melbourne",
101    "Australia/Perth",
102    "Australia/Sydney",
103    "Europe/Amsterdam",
104    "Europe/Athens",
105    "Europe/Belgrade",
106    "Europe/Berlin",
107    "Europe/Brussels",
108    "Europe/Bucharest",
109    "Europe/Budapest",
110    "Europe/Copenhagen",
111    "Europe/Dublin",
112    "Europe/Helsinki",
113    "Europe/Istanbul",
114    "Europe/Kyiv",
115    "Europe/Lisbon",
116    "Europe/London",
117    "Europe/Luxembourg",
118    "Europe/Madrid",
119    "Europe/Oslo",
120    "Europe/Paris",
121    "Europe/Prague",
122    "Europe/Riga",
123    "Europe/Rome",
124    "Europe/Sofia",
125    "Europe/Stockholm",
126    "Europe/Tallinn",
127    "Europe/Vienna",
128    "Europe/Vilnius",
129    "Europe/Warsaw",
130    "Europe/Zurich",
131    "Indian/Maldives",
132    "Indian/Mauritius",
133    "Pacific/Auckland",
134    "Pacific/Fiji",
135    "Pacific/Guam",
136    "Pacific/Honolulu",
137];
138
139/// Check whether a locale tag is in the allowed set.
140pub fn is_allowed_locale(locale: &str) -> bool {
141    ALLOWED_LOCALES.contains(&locale)
142}
143
144/// Check whether a timezone name is in the allowed set.
145pub fn is_allowed_timezone(tz: &str) -> bool {
146    ALLOWED_TIMEZONES.contains(&tz)
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum BackendLocale {
151    En,
152    Uk,
153}
154
155pub fn resolve_backend_locale(locale: Option<&str>) -> BackendLocale {
156    match locale
157        .map(str::trim)
158        .filter(|value| !value.is_empty())
159        .map(|value| value.to_ascii_lowercase())
160    {
161        Some(value) if value == "uk" || value.starts_with("uk-") => BackendLocale::Uk,
162        _ => BackendLocale::En,
163    }
164}
165
166pub struct BackendStrings {
167    pub working: &'static str,
168    pub current_directory: &'static str,
169    pub and_more_actions: &'static str,
170    pub with_errors_one: &'static str,
171    pub with_errors_many: &'static str,
172    pub completed_tool_batch_one: &'static str,
173    pub completed_tool_batch_many: &'static str,
174}
175
176const EN_STRINGS: BackendStrings = BackendStrings {
177    working: "Working",
178    current_directory: "current directory",
179    and_more_actions: "and {count} more actions",
180    with_errors_one: " with 1 error",
181    with_errors_many: " with {count} errors",
182    completed_tool_batch_one: "Completed tool batch with 1 error",
183    completed_tool_batch_many: "Completed tool batch with {count} errors",
184};
185
186const UK_STRINGS: BackendStrings = BackendStrings {
187    working: "Працюю",
188    current_directory: "поточній директорії",
189    and_more_actions: "і ще {count} дій",
190    with_errors_one: " з 1 помилкою",
191    with_errors_many: " з {count} помилками",
192    completed_tool_batch_one: "Пакет інструментів завершено з 1 помилкою",
193    completed_tool_batch_many: "Пакет інструментів завершено з {count} помилками",
194};
195
196pub fn backend_strings(locale: Option<&str>) -> &'static BackendStrings {
197    match resolve_backend_locale(locale) {
198        BackendLocale::En => &EN_STRINGS,
199        BackendLocale::Uk => &UK_STRINGS,
200    }
201}
202
203pub fn format_error_suffix(locale: Option<&str>, error_count: u32) -> String {
204    let strings = backend_strings(locale);
205    if error_count == 1 {
206        strings.with_errors_one.to_string()
207    } else {
208        strings
209            .with_errors_many
210            .replace("{count}", &error_count.to_string())
211    }
212}
213
214pub fn format_completed_tool_batch(locale: Option<&str>, error_count: u32) -> String {
215    let strings = backend_strings(locale);
216    if error_count == 1 {
217        strings.completed_tool_batch_one.to_string()
218    } else {
219        strings
220            .completed_tool_batch_many
221            .replace("{count}", &error_count.to_string())
222    }
223}
224
225pub fn format_more_actions(locale: Option<&str>, count: usize) -> String {
226    match resolve_backend_locale(locale) {
227        BackendLocale::En => backend_strings(locale)
228            .and_more_actions
229            .replace("{count}", &count.to_string()),
230        BackendLocale::Uk => {
231            let template = match count {
232                1 => "і ще 1 дію",
233                2..=4 => "і ще {count} дії",
234                _ => "і ще {count} дій",
235            };
236            template.replace("{count}", &count.to_string())
237        }
238    }
239}
240
241pub fn localized_tool_display_name(
242    tool_name: &str,
243    fallback_display_name: Option<&str>,
244    locale: Option<&str>,
245) -> Option<String> {
246    if resolve_backend_locale(locale) != BackendLocale::Uk {
247        return fallback_display_name.map(ToOwned::to_owned);
248    }
249
250    let localized = match tool_name {
251        "bash" => Some("Командний рядок"),
252        "read_file" | "session_read_file" => Some("Читання файла"),
253        "read_many_files" => Some("Читання файлів"),
254        "list_directory" | "list_files" => Some("Список файлів"),
255        "grep_files" => Some("Пошук у файлах"),
256        "search" | "search_web" => Some("Пошук у вебі"),
257        name if name.ends_with("__search") => Some("Пошук"),
258        "write_file" => Some("Запис файла"),
259        "edit_file" | "replace_in_file" => Some("Редагування файла"),
260        "append_file" => Some("Дописування у файл"),
261        "move_file" => Some("Переміщення файла"),
262        "delete_file" => Some("Видалення файла"),
263        "mkdir" => Some("Створення директорії"),
264        "stat_file" => Some("Інформація про файл"),
265        "secret_store" => Some("Сховище секретів"),
266        "kv_store" => Some("Сховище значень"),
267        "spawn_agent" => Some("Запуск агента"),
268        "write_todos" => Some("Список задач"),
269        "setup_connection" => Some("Налаштування підключення"),
270        _ => None,
271    };
272
273    localized
274        .map(str::to_string)
275        .or_else(|| fallback_display_name.map(ToOwned::to_owned))
276}