Skip to main content

orbok_ui/
i18n.rs

1//! Typed i18n message catalog (RFC-031).
2//!
3//! Compile-time completeness: each locale module implements one
4//! exhaustive `match` over [`MessageKey`]. Adding a key without adding
5//! every translation fails the build — there is no runtime fallback
6//! path to hide a missing string.
7//!
8//! Parameterized messages are plain functions (RFC-031 §5.3) so the
9//! compiler also checks their arguments.
10
11pub mod en;
12pub mod ja;
13
14use serde::{Deserialize, Serialize};
15
16/// Supported UI locales. Default English; persisted in the catalog
17/// under the `ui.locale` setting (read/written by `orbok-app`).
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Locale {
21    #[default]
22    En,
23    Ja,
24}
25
26impl Locale {
27    pub const ALL: &'static [Locale] = &[Locale::En, Locale::Ja];
28
29    /// Setting string stored in `app_settings` (`"en"` / `"ja"`).
30    pub fn as_str(&self) -> &'static str {
31        match self {
32            Locale::En => "en",
33            Locale::Ja => "ja",
34        }
35    }
36
37    pub fn parse(s: &str) -> Option<Locale> {
38        match s {
39            "en" => Some(Locale::En),
40            "ja" => Some(Locale::Ja),
41            _ => None,
42        }
43    }
44
45
46    /// Detect the preferred locale from the operating system environment.
47    /// Checks `LANG` and `LANGUAGE` in that order. Returns `None` if
48    /// neither variable is set or contains a recognised language code.
49    /// Japanese is recognised when the value starts with `ja` (e.g. `ja`,
50    /// `ja_JP`, `ja_JP.UTF-8`).
51    pub fn from_env() -> Option<Locale> {
52        for var in &["LANG", "LANGUAGE"] {
53            if let Ok(val) = std::env::var(var) {
54                let lower = val.to_lowercase();
55                if lower.starts_with("ja") {
56                    return Some(Locale::Ja);
57                }
58                if lower.starts_with("en") {
59                    return Some(Locale::En);
60                }
61            }
62        }
63        None
64    }
65
66    /// Self-described language name, shown in the language picker.
67    pub fn display_name(&self) -> &'static str {
68        match self {
69            Locale::En => "English",
70            Locale::Ja => "日本語",
71        }
72    }
73}
74
75/// Every fixed UI string. One variant per string; views never embed
76/// literals (RFC-031 §6 rule 1).
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum MessageKey {
79    // Application chrome
80    AppTitle,
81    LocalOnlyBadge,
82    // Navigation
83    NavSearch,
84    NavSources,
85    NavIndexing,
86    NavStorage,
87    NavModels,
88    NavAi,
89    NavSettings,
90    // Search view
91    SearchPlaceholder,
92    SearchButton,
93    SearchNoSourcesTitle,
94    SearchNoSourcesBody,
95    SearchAddSource,
96    SearchNoResults,
97    SearchKeywordOnlyNotice,
98    // Sources view
99    SourcesTitle,
100    SourcesEmptyTitle,
101    SourcesEmptyBody,
102    SourcesAddFolder,
103    SourcesStatusActive,
104    SourcesStatusPaused,
105    SourcesStatusMissing,
106    // Indexing view
107    IndexingTitle,
108    IndexingIdle,
109    IndexingHealthIndexed,
110    IndexingHealthStale,
111    IndexingHealthFailed,
112    IndexingHealthQueued,
113    // Storage view
114    StorageTitle,
115    StorageIntro,
116    StorageGroupSearchIndex,
117    StorageGroupModels,
118    StorageGroupCaches,
119    StorageSafeCleanupHeading,
120    StorageClearSnippets,
121    StorageClearSearchCache,
122    StorageDangerHeading,
123    StorageResetCatalog,
124    StorageResetWarning,
125    // Models view
126    ModelsTitle,
127    ModelsEmbeddingRole,
128    ModelsRerankerRole,
129    ModelsStatusAvailable,
130    ModelsStatusMissing,
131    ModelsKeywordOnlyHint,
132    // Settings view
133    SettingsTitle,
134    SettingsLanguageHeading,
135    SettingsPrivacyHeading,
136    SettingsAdvancedHeading,
137    SettingsAdvancedOn,
138    SettingsAdvancedOff,
139    SettingsAdvancedHint,
140    SettingsPrivacyLocalOnly,
141    // Search modes (RFC-009 §8)
142    SearchModeLabel,
143    SearchModeAuto,
144    SearchModeExact,
145    SearchModeConceptual,
146    SearchModeFast,
147    // Match badges
148    BadgeKeyword,
149    BadgeSemantic,
150    BadgeFused,
151    // Startup wizard (design §wizard)
152    WizardTitleNotConfigured,
153    WizardTitleFileMissing,
154    WizardTitleValidating,
155    WizardTitleReady,
156    WizardBodyNotConfigured,
157    WizardBodyFileMissing,
158    WizardFilesNeededLabel,
159    WizardDownloadHint,
160    WizardPathInputPlaceholder,
161    WizardActionLocate,
162    WizardActionValidate,
163    WizardActionUseModel,
164    WizardActionContinue,
165    WizardPathPlaceholder,
166    WizardDownloadAction,
167    WizardDownloadProgress,
168    WizardActionSkip,
169    WizardPreviousPathLabel,
170    WizardValidationOk,
171    WizardValidationFail,
172    WizardReadyBody,
173    // Common actions
174    NoticeDownloadFailTitle,
175    NoticeDownloadFailBody,
176    NoticeFolderFailTitle,
177    NoticeFolderFailBody,
178    NoticeSearchFailTitle,
179    NoticeSearchFailBody,
180    NoticeFilesMissingTitle,
181    NoticeFilesMissingBody,
182    NoticeFolderAddedTitle,
183    NoticeFolderAddedBody,
184    NoticeSearchReadyTitle,
185    NoticeSearchReadyBody,
186    NoticePreviewsClearedTitle,
187    NoticePreviewsClearedBody,
188    NoticeActionTryAgain,
189    NoticeActionChooseFolder,
190    NoticeSensitiveSourceTitle,
191    NoticeSensitiveSourceBody,
192    NoticeDismiss,
193    Cancel,
194    Confirm,
195}
196
197/// Translate a fixed message. The per-locale functions are exhaustive
198/// matches — completeness is enforced by the compiler.
199pub fn tr(locale: Locale, key: MessageKey) -> &'static str {
200    match locale {
201        Locale::En => en::message(key),
202        Locale::Ja => ja::message(key),
203    }
204}
205
206/// Parameterized: "812 files indexed".
207pub fn files_indexed(locale: Locale, count: u64) -> String {
208    match locale {
209        Locale::En => format!("{count} files indexed"),
210        Locale::Ja => format!("{count} 件のファイルをインデックス済み"),
211    }
212}
213
214/// Parameterized: source card summary line.
215pub fn source_summary(locale: Locale, indexed: u64, stale: u64, failed: u64) -> String {
216    match locale {
217        Locale::En => format!("{indexed} indexed · {stale} stale · {failed} failed"),
218        Locale::Ja => format!("インデックス済み {indexed} · 要更新 {stale} · 失敗 {failed}"),
219    }
220}
221
222/// Parameterized: "3 results".
223pub fn search_result_count(locale: Locale, count: usize) -> String {
224    match locale {
225        Locale::En => format!("{count} result{}", if count == 1 { "" } else { "s" }),
226        Locale::Ja => format!("{count} 件の結果"),
227    }
228}