use crate::support::{flatten_keys, PathNormalizer};
use serde_json::Value as JsonValue;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, Default)]
pub struct LocaleRepository {
pub json_keys: Vec<String>,
pub toml_keys: Vec<String>,
pub yaml_keys: Vec<String>,
pub all_keys: Vec<String>,
pub key_sources: BTreeMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Default)]
pub struct LoadedRepositories {
pub locales: Vec<String>,
pub repositories: BTreeMap<String, LocaleRepository>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct TranslationRepositoryLoader;
impl TranslationRepositoryLoader {
pub fn detect_locales(&self, lang_paths: &[String]) -> Vec<String> {
let mut locales = BTreeSet::new();
for lang_path in lang_paths {
let path = Path::new(lang_path);
if !path.is_dir() {
continue;
}
let Ok(entries) = fs::read_dir(path) else {
continue;
};
for entry in entries.flatten() {
let entry_path = entry.path();
if entry_path.is_dir() {
if let Some(name) = entry_path.file_name().and_then(|s| s.to_str()) {
locales.insert(name.to_string());
}
continue;
}
if let Some((locale, _)) = locale_from_root_file(&entry_path) {
locales.insert(locale);
}
}
}
locales.into_iter().collect()
}
pub fn load(
&self,
locales: Option<Vec<String>>,
lang_paths: &[String],
skip_translation_files: &[String],
) -> LoadedRepositories {
let mut detected = locales
.unwrap_or_else(|| self.detect_locales(lang_paths))
.into_iter()
.map(|locale| locale.trim().to_string())
.filter(|locale| !locale.is_empty())
.collect::<Vec<_>>();
detected.sort();
detected.dedup();
let normalized_skip = skip_translation_files
.iter()
.map(|entry| PathNormalizer::normalize(entry))
.collect::<Vec<_>>();
let mut repositories = BTreeMap::new();
let mut warnings = Vec::new();
for locale in &detected {
let mut repository = LocaleRepository::default();
let mut locale_files = Vec::new();
for lang_path in lang_paths {
locale_files.extend(collect_locale_files(lang_path, locale));
}
locale_files.sort();
locale_files.dedup();
for file in locale_files {
let file_str = file.to_string_lossy();
let lang_path = lang_paths
.iter()
.find(|entry| {
let normalized_lang = PathNormalizer::normalize(entry);
let normalized_file = PathNormalizer::normalize(&file_str);
normalized_file
.to_ascii_lowercase()
.starts_with(&format!("{}/", normalized_lang.to_ascii_lowercase()))
|| normalized_file.eq_ignore_ascii_case(&normalized_lang)
})
.cloned()
.unwrap_or_else(|| lang_paths.first().cloned().unwrap_or_default());
if should_skip_file(&file, &lang_path, &normalized_skip) {
continue;
}
let normalized_file = PathNormalizer::normalize(&file.to_string_lossy());
let ext = file
.extension()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
match ext.as_str() {
"json" => match load_json_keys(&file, locale, &lang_path) {
Ok(keys_with_source) => {
for (key, source) in keys_with_source {
repository.json_keys.push(key.clone());
repository
.key_sources
.entry(key)
.or_default()
.push(source);
}
}
Err(message) => warnings.push(format!("{} ({})", message, normalized_file)),
},
"toml" => match load_toml_keys(&file, locale, &lang_path) {
Ok(keys_with_source) => {
for (key, source) in keys_with_source {
repository.toml_keys.push(key.clone());
repository
.key_sources
.entry(key)
.or_default()
.push(source);
}
}
Err(message) => warnings.push(format!("{} ({})", message, normalized_file)),
},
"yaml" | "yml" => match load_yaml_keys(&file, locale, &lang_path) {
Ok(keys_with_source) => {
for (key, source) in keys_with_source {
repository.yaml_keys.push(key.clone());
repository
.key_sources
.entry(key)
.or_default()
.push(source);
}
}
Err(message) => warnings.push(format!("{} ({})", message, normalized_file)),
},
_ => {}
}
}
dedup_and_sort(&mut repository.json_keys);
dedup_and_sort(&mut repository.toml_keys);
dedup_and_sort(&mut repository.yaml_keys);
let mut all = repository
.json_keys
.iter()
.chain(repository.toml_keys.iter())
.chain(repository.yaml_keys.iter())
.cloned()
.collect::<Vec<_>>();
dedup_and_sort(&mut all);
repository.all_keys = all;
for sources in repository.key_sources.values_mut() {
dedup_and_sort(sources);
}
repositories.insert(locale.clone(), repository);
}
LoadedRepositories {
locales: detected,
repositories,
warnings,
}
}
}
fn collect_locale_files(lang_path: &str, locale: &str) -> Vec<PathBuf> {
let base = Path::new(lang_path);
let mut files = Vec::new();
let locale_dir = base.join(locale);
if locale_dir.is_dir() {
for entry in WalkDir::new(&locale_dir).into_iter().filter_map(Result::ok) {
if entry.file_type().is_file() && is_supported_extension(entry.path()) {
files.push(entry.path().to_path_buf());
}
}
}
for ext in ["json", "toml", "yaml", "yml"] {
let root_file = base.join(format!("{}.{}", locale, ext));
if root_file.is_file() {
files.push(root_file);
}
}
files.sort();
files.dedup();
files
}
fn is_supported_extension(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|entry| entry.to_str())
.map(|entry| entry.to_ascii_lowercase())
.as_deref(),
Some("json") | Some("toml") | Some("yaml") | Some("yml")
)
}
fn locale_from_root_file(path: &Path) -> Option<(String, String)> {
let file_name = path.file_name()?.to_str()?;
for ext in ["json", "toml", "yaml", "yml"] {
let suffix = format!(".{}", ext);
if let Some(locale) = file_name.strip_suffix(&suffix) {
return Some((locale.to_string(), ext.to_string()));
}
}
None
}
fn should_skip_file(file_path: &Path, lang_path: &str, normalized_skip: &[String]) -> bool {
if normalized_skip.is_empty() {
return false;
}
let normalized_absolute = PathNormalizer::normalize(&file_path.to_string_lossy());
let normalized_relative = PathNormalizer::normalize(&PathNormalizer::relative_to(
lang_path,
&file_path.to_string_lossy(),
));
normalized_skip.iter().any(|skip| {
let candidate = skip.trim().to_ascii_lowercase();
!candidate.is_empty()
&& (normalized_absolute.to_ascii_lowercase().contains(&candidate)
|| normalized_relative.to_ascii_lowercase().contains(&candidate))
})
}
fn load_json_keys(file: &Path, locale: &str, lang_path: &str) -> Result<Vec<(String, String)>, String> {
let content = fs::read_to_string(file).map_err(|_| "Failed to read JSON lang file".to_string())?;
let decoded = serde_json::from_str::<JsonValue>(&content)
.map_err(|_| "Invalid JSON lang file".to_string())?;
let map = decoded
.as_object()
.ok_or_else(|| "JSON lang file must be an object".to_string())?;
let mut output = Vec::new();
let source = PathNormalizer::normalize(&PathNormalizer::relative_to(lang_path, &file.to_string_lossy()));
if is_root_locale_file(file, locale) {
for key in map.keys() {
output.push((key.to_string(), source.clone()));
}
output.sort();
return Ok(output);
}
let (prefix, value) = parse_non_root_translation_file(file, locale, map_to_json_value(map), lang_path);
for key in flatten_keys(&value, Some(&prefix)) {
output.push((key, source.clone()));
}
output.sort();
Ok(output)
}
fn load_toml_keys(file: &Path, locale: &str, lang_path: &str) -> Result<Vec<(String, String)>, String> {
let content = fs::read_to_string(file).map_err(|_| "Failed to read TOML lang file".to_string())?;
let decoded = content
.parse::<toml::Value>()
.map_err(|_| "Invalid TOML lang file".to_string())?;
let json_value = toml_to_json(decoded);
let source = PathNormalizer::normalize(&PathNormalizer::relative_to(lang_path, &file.to_string_lossy()));
if is_root_locale_file(file, locale) {
let mut output = flatten_keys(&json_value, None)
.into_iter()
.map(|key| (key, source.clone()))
.collect::<Vec<_>>();
output.sort();
return Ok(output);
}
let (prefix, value) = parse_non_root_translation_file(file, locale, json_value, lang_path);
let mut output = flatten_keys(&value, Some(&prefix))
.into_iter()
.map(|key| (key, source.clone()))
.collect::<Vec<_>>();
output.sort();
Ok(output)
}
fn load_yaml_keys(file: &Path, locale: &str, lang_path: &str) -> Result<Vec<(String, String)>, String> {
let content = fs::read_to_string(file).map_err(|_| "Failed to read YAML lang file".to_string())?;
let decoded = serde_yaml::from_str::<serde_yaml::Value>(&content)
.map_err(|_| "Invalid YAML lang file".to_string())?;
let json_value = serde_json::to_value(decoded).map_err(|_| "Invalid YAML lang value".to_string())?;
let source = PathNormalizer::normalize(&PathNormalizer::relative_to(lang_path, &file.to_string_lossy()));
if is_root_locale_file(file, locale) {
let mut output = flatten_keys(&json_value, None)
.into_iter()
.map(|key| (key, source.clone()))
.collect::<Vec<_>>();
output.sort();
return Ok(output);
}
let (prefix, value) = parse_non_root_translation_file(file, locale, json_value, lang_path);
let mut output = flatten_keys(&value, Some(&prefix))
.into_iter()
.map(|key| (key, source.clone()))
.collect::<Vec<_>>();
output.sort();
Ok(output)
}
fn parse_non_root_translation_file(
file: &Path,
locale: &str,
value: JsonValue,
lang_path: &str,
) -> (String, JsonValue) {
let rel = PathNormalizer::relative_to(lang_path, &file.to_string_lossy());
let rel_path = Path::new(&rel);
let prefix = if let Ok(stripped) = rel_path.strip_prefix(locale) {
path_to_key_prefix(stripped)
} else {
file.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string()
};
(prefix, value)
}
fn path_to_key_prefix(path: &Path) -> String {
let mut segments = Vec::new();
for segment in path.iter() {
let part = segment.to_string_lossy();
if part.is_empty() {
continue;
}
segments.push(part.to_string());
}
if let Some(last) = segments.last_mut() {
if let Some(pos) = last.rfind('.') {
*last = last[..pos].to_string();
}
}
segments
.into_iter()
.filter(|segment| !segment.is_empty())
.collect::<Vec<_>>()
.join(".")
}
fn is_root_locale_file(path: &Path, locale: &str) -> bool {
path.file_stem()
.and_then(|s| s.to_str())
.map(|stem| stem == locale)
.unwrap_or(false)
}
fn map_to_json_value(map: &serde_json::Map<String, JsonValue>) -> JsonValue {
JsonValue::Object(map.clone())
}
fn toml_to_json(value: toml::Value) -> JsonValue {
match value {
toml::Value::String(value) => JsonValue::String(value),
toml::Value::Integer(value) => JsonValue::Number(value.into()),
toml::Value::Float(value) => serde_json::Number::from_f64(value)
.map(JsonValue::Number)
.unwrap_or(JsonValue::Null),
toml::Value::Boolean(value) => JsonValue::Bool(value),
toml::Value::Datetime(value) => JsonValue::String(value.to_string()),
toml::Value::Array(values) => JsonValue::Array(values.into_iter().map(toml_to_json).collect()),
toml::Value::Table(table) => JsonValue::Object(
table
.into_iter()
.map(|(key, value)| (key, toml_to_json(value)))
.collect(),
),
}
}
fn dedup_and_sort(values: &mut Vec<String>) {
values.sort();
values.dedup();
}