pacsea/i18n/
mod.rs

1//! Internationalization (i18n) module for Pacsea.
2//!
3//! This module provides locale detection, resolution, loading, and translation lookup.
4//!
5//! # Overview
6//!
7//! The i18n system supports:
8//! - **Locale Detection**: Auto-detects system locale from environment variables (`LANG`, `LC_ALL`, `LC_MESSAGES`)
9//! - **Locale Resolution**: Resolves locale with fallback chain (settings -> system -> default)
10//! - **Fallback Chain**: Supports locale fallbacks (e.g., `de-CH` -> `de-DE` -> `en-US`)
11//! - **Translation Loading**: Loads YAML locale files from `locales/` directory
12//! - **Translation Lookup**: Provides `t()`, `t_fmt()`, and `t_fmt1()` helpers for translation access
13//!
14//! # Locale Files
15//!
16//! Locale files are stored in `locales/{locale}.yml` (e.g., `locales/en-US.yml`, `locales/de-DE.yml`).
17//! Each file contains a nested YAML structure that is flattened into dot-notation keys:
18//!
19//! ```yaml
20//! app:
21//!   titles:
22//!     search: "Search"
23//! ```
24//!
25//! This becomes accessible as `app.titles.search`.
26//!
27//! # Configuration
28//!
29//! The i18n system is configured via `config/i18n.yml`:
30//! - `default_locale`: Default locale if auto-detection fails (usually `en-US`)
31//! - `fallbacks`: Map of locale codes to their fallback locales
32//!
33//! # Usage
34//!
35//! ```rust,no_run
36//! use pacsea::i18n;
37//! use pacsea::state::AppState;
38//!
39//! # let mut app = AppState::default();
40//! // Simple translation lookup
41//! let text = i18n::t(&app, "app.titles.search");
42//!
43//! // Translation with format arguments
44//! let file_path = "/path/to/file";
45//! let text = i18n::t_fmt1(&app, "app.toasts.exported_to", file_path);
46//! ```
47//!
48//! # Adding a New Locale
49//!
50//! 1. Create `locales/{locale}.yml` (e.g., `locales/fr-FR.yml`)
51//! 2. Copy structure from `locales/en-US.yml` and translate all strings
52//! 3. Optionally add fallback in `config/i18n.yml` if needed (e.g., `fr: fr-FR`)
53//! 4. Users can set `locale = fr-FR` in `settings.conf` or leave empty for auto-detection
54//!
55//! # Error Handling
56//!
57//! - Missing locale files fall back to English automatically
58//! - Invalid locale codes in `settings.conf` trigger warnings and fallback to system/default
59//! - Missing translation keys return the key itself (for debugging) and log debug messages
60//! - All errors are logged but do not crash the application
61
62mod detection;
63mod loader;
64mod resolver;
65pub mod translations;
66
67pub use detection::detect_system_locale;
68pub use loader::{LocaleLoader, load_locale_file};
69pub use resolver::{LocaleResolver, resolve_locale};
70pub use translations::{TranslationMap, translate, translate_with_fallback};
71
72use std::path::PathBuf;
73
74/// What: Find a config file in development and installed locations.
75///
76/// Inputs:
77/// - `relative_path`: Relative path from config directory (e.g., "i18n.yml")
78///
79/// Output:
80/// - `Some(PathBuf)` pointing to the first existing file found, or `None` if not found
81///
82/// Details:
83/// - Tries locations in order:
84///   1. Development location: `CARGO_MANIFEST_DIR/config/{relative_path}` (prioritized when running from source)
85///   2. Installed location: `/usr/share/pacsea/config/{relative_path}`
86/// - Development location is checked first to allow working with repo files during development
87#[must_use]
88pub fn find_config_file(relative_path: &str) -> Option<PathBuf> {
89    // Try development location first (when running from source)
90    let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
91        .join("config")
92        .join(relative_path);
93    if dev_path.exists() {
94        return Some(dev_path);
95    }
96
97    // Try installed location
98    let installed_path = PathBuf::from("/usr/share/pacsea/config").join(relative_path);
99    if installed_path.exists() {
100        return Some(installed_path);
101    }
102
103    None
104}
105
106/// What: Find the locales directory in development and installed locations.
107///
108/// Output:
109/// - `Some(PathBuf)` pointing to the first existing locales directory found, or `None` if not found
110///
111/// Details:
112/// - Tries locations in order:
113///   1. Development location: `CARGO_MANIFEST_DIR/config/locales` (prioritized when running from source)
114///   2. Installed location: `/usr/share/pacsea/locales`
115/// - Development location is checked first to allow working with repo files during development
116#[must_use]
117pub fn find_locales_dir() -> Option<PathBuf> {
118    // Try development location first (when running from source)
119    // Note: locales are in config/locales/ in the dev environment
120    let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
121        .join("config")
122        .join("locales");
123    if dev_path.exists() && dev_path.is_dir() {
124        return Some(dev_path);
125    }
126
127    // Also try the old location for backwards compatibility
128    let dev_path_old = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("locales");
129    if dev_path_old.exists() && dev_path_old.is_dir() {
130        return Some(dev_path_old);
131    }
132
133    // Try installed location
134    let installed_path = PathBuf::from("/usr/share/pacsea/locales");
135    if installed_path.exists() && installed_path.is_dir() {
136        return Some(installed_path);
137    }
138
139    None
140}
141
142/// What: Get a translation for a given key from `AppState`.
143///
144/// Inputs:
145/// - `app`: `AppState` containing translation maps
146/// - `key`: Dot-notation key (e.g., "app.titles.search")
147///
148/// Output:
149/// - Translated string, or the key itself if translation not found
150///
151/// Details:
152/// - Uses translations from `AppState`
153/// - Falls back to English if translation missing
154#[must_use]
155pub fn t(app: &crate::state::AppState, key: &str) -> String {
156    crate::i18n::translations::translate_with_fallback(
157        key,
158        &app.translations,
159        &app.translations_fallback,
160    )
161}
162
163/// What: Get a translation with format arguments.
164///
165/// Inputs:
166/// - `app`: `AppState` containing translation maps
167/// - `key`: Dot-notation key
168/// - `args`: Format arguments (as Display trait objects)
169///
170/// Output:
171/// - Formatted translated string
172///
173/// Details:
174/// - Replaces placeholders in order: first {} gets first arg, etc.
175/// - Supports multiple placeholders: "{} and {}" -> "arg1 and arg2"
176pub fn t_fmt(app: &crate::state::AppState, key: &str, args: &[&dyn std::fmt::Display]) -> String {
177    let translation = t(app, key);
178    let mut result = translation;
179    for arg in args {
180        result = result.replacen("{}", &arg.to_string(), 1);
181    }
182    result
183}
184
185/// What: Get a translation with a single format argument (convenience function).
186///
187/// Inputs:
188/// - `app`: `AppState` containing translation maps
189/// - `key`: Dot-notation key
190/// - `arg`: Single format argument
191///
192/// Output:
193/// - Formatted translated string
194pub fn t_fmt1<T: std::fmt::Display>(app: &crate::state::AppState, key: &str, arg: T) -> String {
195    t_fmt(app, key, &[&arg])
196}
197
198/// What: Format translated string with two format arguments.
199///
200/// Inputs:
201/// - `app`: Application state (for locale access)
202/// - `key`: Translation key
203/// - `arg1`: First format argument
204/// - `arg2`: Second format argument
205///
206/// Output:
207/// - Formatted translated string
208pub fn t_fmt2<T1: std::fmt::Display, T2: std::fmt::Display>(
209    app: &crate::state::AppState,
210    key: &str,
211    arg1: T1,
212    arg2: T2,
213) -> String {
214    t_fmt(app, key, &[&arg1, &arg2])
215}