use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use crate::i18n::detection::detect_system_locale;
#[must_use]
pub fn resolve_locale(settings_locale: &str, i18n_config_path: &PathBuf) -> String {
let fallbacks = load_fallbacks(i18n_config_path);
let default_locale = load_default_locale(i18n_config_path);
let available_locales = load_available_locales(i18n_config_path);
let initial_locale = if settings_locale.trim().is_empty() {
detect_system_locale().unwrap_or_else(|| {
tracing::debug!(
"System locale detection failed, using default: {}",
default_locale
);
default_locale.clone()
})
} else {
let trimmed = settings_locale.trim().to_string();
if is_valid_locale_format(&trimmed) {
trimmed
} else {
tracing::warn!(
"Invalid locale format in settings.conf: '{}'. Using system locale or default.",
trimmed
);
detect_system_locale().unwrap_or_else(|| default_locale.clone())
}
};
let resolved = resolve_with_fallbacks(
&initial_locale,
&fallbacks,
&default_locale,
&available_locales,
);
if resolved != initial_locale {
tracing::debug!(
"Locale '{}' resolved to '{}' via fallback chain",
initial_locale,
resolved
);
}
resolved
}
fn is_valid_locale_format(locale: &str) -> bool {
if locale.is_empty() || locale.len() > 20 {
return false;
}
locale.chars().all(|c| c.is_alphanumeric() || c == '-')
&& !locale.starts_with('-')
&& !locale.ends_with('-')
&& !locale.contains("--")
}
fn resolve_with_fallbacks(
locale: &str,
fallbacks: &HashMap<String, String>,
default_locale: &str,
available_locales: &std::collections::HashSet<String>,
) -> String {
let mut current = locale.to_string();
let mut visited = std::collections::HashSet::new();
while visited.insert(current.clone()) {
if let Some(fallback) = fallbacks.get(¤t) {
tracing::debug!("Locale '{}' has fallback: {}", current, fallback);
current.clone_from(fallback);
} else {
if available_locales.contains(¤t) {
tracing::debug!(
"Locale '{}' has no fallback but is available, using it directly",
current
);
return current;
} else if current == default_locale {
tracing::debug!(
"Locale '{}' has no fallback and is the default locale, using it directly",
current
);
return current;
}
tracing::debug!(
"Locale '{}' has no fallback and is not available, falling back to default: {}",
current,
default_locale
);
return default_locale.to_string();
}
if visited.len() > 10 {
tracing::warn!(
"Fallback chain too long ({} steps) for locale '{}', using default: {}",
visited.len(),
locale,
default_locale
);
return default_locale.to_string();
}
}
tracing::warn!(
"Detected cycle in fallback chain for locale '{}', using default: {}",
locale,
default_locale
);
default_locale.to_string()
}
fn load_fallbacks(config_path: &PathBuf) -> HashMap<String, String> {
let mut fallbacks = HashMap::new();
if let Ok(contents) = fs::read_to_string(config_path)
&& let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&contents)
&& let Some(fallbacks_map) = doc.get("fallbacks").and_then(|v| v.as_mapping())
{
for (key, value) in fallbacks_map {
if let (Some(k), Some(v)) = (key.as_str(), value.as_str()) {
fallbacks.insert(k.to_string(), v.to_string());
}
}
tracing::debug!(
"Loaded {} fallback mappings from i18n.yml: {:?}",
fallbacks.len(),
fallbacks.keys().collect::<Vec<_>>()
);
} else {
tracing::warn!("Failed to load fallbacks from i18n.yml");
}
fallbacks
}
fn load_available_locales(config_path: &PathBuf) -> std::collections::HashSet<String> {
let mut locales = std::collections::HashSet::new();
if let Ok(contents) = fs::read_to_string(config_path)
&& let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&contents)
&& let Some(locales_map) = doc.get("locales").and_then(|v| v.as_mapping())
{
for key in locales_map.keys() {
if let Some(locale) = key.as_str() {
locales.insert(locale.to_string());
}
}
tracing::debug!(
"Loaded {} available locales from i18n.yml: {:?}",
locales.len(),
locales.iter().collect::<Vec<_>>()
);
}
locales
}
fn load_default_locale(config_path: &PathBuf) -> String {
if let Ok(contents) = fs::read_to_string(config_path)
&& let Ok(doc) = serde_norway::from_str::<serde_norway::Value>(&contents)
&& let Some(default) = doc.get("default_locale").and_then(|v| v.as_str())
{
return default.to_string();
}
"en-US".to_string()
}
pub struct LocaleResolver {
fallbacks: HashMap<String, String>,
default_locale: String,
available_locales: std::collections::HashSet<String>,
}
impl LocaleResolver {
#[must_use]
pub fn new(i18n_config_path: &PathBuf) -> Self {
Self {
fallbacks: load_fallbacks(i18n_config_path),
default_locale: load_default_locale(i18n_config_path),
available_locales: load_available_locales(i18n_config_path),
}
}
#[must_use]
pub fn resolve(&self, settings_locale: &str) -> String {
let initial_locale = if settings_locale.trim().is_empty() {
detect_system_locale().unwrap_or_else(|| self.default_locale.clone())
} else {
let trimmed = settings_locale.trim().to_string();
if is_valid_locale_format(&trimmed) {
trimmed
} else {
tracing::warn!(
"Invalid locale format in settings.conf: '{}'. Using system locale or default.",
trimmed
);
detect_system_locale().unwrap_or_else(|| self.default_locale.clone())
}
};
tracing::debug!(
"Resolving locale '{}' with {} fallbacks available",
initial_locale,
self.fallbacks.len()
);
if initial_locale == "ch" {
tracing::debug!(
"Checking for 'ch' in fallbacks: {}",
self.fallbacks.contains_key("ch")
);
if let Some(fallback) = self.fallbacks.get("ch") {
tracing::debug!("Found fallback for 'ch': {}", fallback);
}
}
let resolved = resolve_with_fallbacks(
&initial_locale,
&self.fallbacks,
&self.default_locale,
&self.available_locales,
);
if resolved != initial_locale {
tracing::debug!(
"Locale '{}' resolved to '{}' via fallback chain",
initial_locale,
resolved
);
}
resolved
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_resolve_with_fallbacks() {
let mut fallbacks = HashMap::new();
fallbacks.insert("de-CH".to_string(), "de-DE".to_string());
fallbacks.insert("de".to_string(), "de-DE".to_string());
let mut available_locales = std::collections::HashSet::new();
available_locales.insert("de-DE".to_string());
available_locales.insert("en-US".to_string());
assert_eq!(
resolve_with_fallbacks("de-CH", &fallbacks, "en-US", &available_locales),
"de-DE" );
assert_eq!(
resolve_with_fallbacks("de-DE", &fallbacks, "en-US", &available_locales),
"de-DE"
);
assert_eq!(
resolve_with_fallbacks("en-US", &fallbacks, "en-US", &available_locales),
"en-US"
);
assert_eq!(
resolve_with_fallbacks("de", &fallbacks, "en-US", &available_locales),
"de-DE" );
let mut available_locales_no_de = std::collections::HashSet::new();
available_locales_no_de.insert("en-US".to_string());
assert_eq!(
resolve_with_fallbacks("de-DE", &fallbacks, "en-US", &available_locales_no_de),
"en-US" );
}
#[test]
fn test_resolve_with_fallbacks_cycle_detection() {
let mut fallbacks = HashMap::new();
fallbacks.insert("a".to_string(), "b".to_string());
fallbacks.insert("b".to_string(), "c".to_string());
fallbacks.insert("c".to_string(), "a".to_string());
let available_locales = std::collections::HashSet::new();
let result = resolve_with_fallbacks("a", &fallbacks, "en-US", &available_locales);
assert_eq!(result, "en-US");
}
#[test]
fn test_resolve_with_fallbacks_long_chain() {
let mut fallbacks = HashMap::new();
for i in 0..15 {
let next = i + 1;
fallbacks.insert(format!("loc{i}"), format!("loc{next}"));
}
let available_locales = std::collections::HashSet::new();
let result = resolve_with_fallbacks("loc0", &fallbacks, "en-US", &available_locales);
assert_eq!(result, "en-US");
}
#[test]
fn test_is_valid_locale_format() {
assert!(is_valid_locale_format("en-US"));
assert!(is_valid_locale_format("de-DE"));
assert!(is_valid_locale_format("zh-Hans-CN"));
assert!(is_valid_locale_format("en"));
assert!(is_valid_locale_format("fr-FR"));
assert!(!is_valid_locale_format(""));
assert!(!is_valid_locale_format("-en-US"));
assert!(!is_valid_locale_format("en-US-"));
assert!(!is_valid_locale_format("en--US"));
assert!(!is_valid_locale_format("en US"));
assert!(!is_valid_locale_format("en@US"));
assert!(!is_valid_locale_format(&"x".repeat(21))); }
#[test]
fn test_load_fallbacks() {
let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
let config_path = temp_dir.path().join("i18n.yml");
let yaml_content = r"
default_locale: en-US
fallbacks:
de-CH: de-DE
de: de-DE
fr: fr-FR
";
fs::write(&config_path, yaml_content).expect("Failed to write test config file");
let fallbacks = load_fallbacks(&config_path);
assert_eq!(fallbacks.get("de-CH"), Some(&"de-DE".to_string()));
assert_eq!(fallbacks.get("de"), Some(&"de-DE".to_string()));
assert_eq!(fallbacks.get("fr"), Some(&"fr-FR".to_string()));
}
#[test]
fn test_load_default_locale() {
let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
let config_path = temp_dir.path().join("i18n.yml");
let yaml_content = r"
default_locale: de-DE
fallbacks:
de-CH: de-DE
";
fs::write(&config_path, yaml_content).expect("Failed to write test config file");
let default = load_default_locale(&config_path);
assert_eq!(default, "de-DE");
}
#[test]
fn test_load_default_locale_missing() {
let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
let config_path = temp_dir.path().join("i18n.yml");
let yaml_content = r"
fallbacks:
de-CH: de-DE
";
fs::write(&config_path, yaml_content).expect("Failed to write test config file");
let default = load_default_locale(&config_path);
assert_eq!(default, "en-US"); }
#[test]
fn test_resolve_locale_with_settings() {
let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
let config_path = temp_dir.path().join("i18n.yml");
let yaml_content = r"
default_locale: en-US
fallbacks:
de-CH: de-DE
";
fs::write(&config_path, yaml_content).expect("Failed to write test config file");
let result = resolve_locale("de-CH", &config_path);
assert_eq!(result, "en-US");
let result = resolve_locale("en-US", &config_path);
assert_eq!(result, "en-US");
let result = resolve_locale("invalid-format-", &config_path);
assert!(!result.is_empty());
}
#[test]
fn test_resolve_locale_empty_settings() {
let temp_dir = TempDir::new().expect("Failed to create temp directory for test");
let config_path = temp_dir.path().join("i18n.yml");
let yaml_content = r"
default_locale: en-US
fallbacks:
de-CH: de-DE
";
fs::write(&config_path, yaml_content).expect("Failed to write test config file");
let result = resolve_locale("", &config_path);
assert!(!result.is_empty());
let result = resolve_locale(" ", &config_path);
assert!(!result.is_empty());
}
}