use std::{
env, fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const DEFAULT_CONFIG_FILE: &str = "lingva.config.yaml";
pub const DEFAULT_ENVIRONMENT: &str = "dev";
pub const DEFAULT_TRANSLATION_FILES: &str = ".lingva/translations/{locale}.json";
pub const DEFAULT_API_KEY_ENV: &str = "LINGVA_API_KEY";
pub const PUBLISH_TOKEN_ENV: &str = "LINGVA_PUBLISH_API_TOKEN";
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConfigDocument {
pub project_id: String,
#[serde(default = "default_environment")]
pub environment: String,
pub locales: Vec<String>,
pub default_locale: Option<String>,
pub fallback_locale: Option<String>,
#[serde(default = "default_translation_files")]
pub translation_files: String,
#[serde(default = "default_api_key_env")]
pub api_key_env: String,
pub api_url: Option<String>,
pub api_key: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub path: PathBuf,
pub project_id: String,
pub environment: String,
pub locales: Vec<String>,
pub default_locale: String,
pub fallback_locale: String,
pub translation_files: String,
pub api_url: Option<String>,
pub api_key: Option<String>,
}
fn default_environment() -> String {
DEFAULT_ENVIRONMENT.into()
}
fn default_translation_files() -> String {
DEFAULT_TRANSLATION_FILES.into()
}
fn default_api_key_env() -> String {
DEFAULT_API_KEY_ENV.into()
}
fn non_empty(value: String, field: &str) -> Result<String> {
let value = value.trim().to_owned();
if value.is_empty() {
bail!("Lingva config requires a non-empty \"{field}\" string.");
}
Ok(value)
}
impl Config {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = absolute_path(path.as_ref())?;
let source = fs::read_to_string(&path)
.with_context(|| format!("Lingva config \"{}\" does not exist.", path.display()))?;
let extension = path.extension().and_then(|value| value.to_str());
let document: ConfigDocument = if extension == Some("json") {
serde_json::from_str(&source).with_context(|| {
format!("Lingva config \"{}\" is not valid JSON.", path.display())
})?
} else {
serde_yaml::from_str(&source).with_context(|| {
format!("Lingva config \"{}\" is not valid YAML.", path.display())
})?
};
Self::from_document(path, document)
}
fn from_document(path: PathBuf, document: ConfigDocument) -> Result<Self> {
let project_id = non_empty(document.project_id, "projectId")?;
let environment = non_empty(document.environment, "environment")?;
let locales = document
.locales
.into_iter()
.try_fold(Vec::new(), |mut result, locale| {
let locale = non_empty(locale, "locales")?;
if !result.contains(&locale) {
result.push(locale);
}
Ok::<_, anyhow::Error>(result)
})?;
if locales.is_empty() {
bail!("Lingva config requires at least one locale in \"locales\".");
}
let default_locale = document
.default_locale
.map(|value| non_empty(value, "defaultLocale"))
.transpose()?
.unwrap_or_else(|| locales[0].clone());
let fallback_locale = document
.fallback_locale
.map(|value| non_empty(value, "fallbackLocale"))
.transpose()?
.unwrap_or_else(|| default_locale.clone());
if !locales.contains(&default_locale) {
bail!("Lingva \"defaultLocale\" must be included in \"locales\".");
}
if !locales.contains(&fallback_locale) {
bail!("Lingva \"fallbackLocale\" must be included in \"locales\".");
}
let api_key = env::var(&document.api_key_env)
.ok()
.filter(|value| !value.trim().is_empty())
.or(document.api_key.filter(|value| !value.trim().is_empty()));
Ok(Self {
path,
project_id,
environment,
locales,
default_locale,
fallback_locale,
translation_files: non_empty(document.translation_files, "translationFiles")?,
api_url: document.api_url.filter(|value| !value.trim().is_empty()),
api_key,
})
}
pub fn translation_path(&self, locale: &str) -> PathBuf {
let candidate = PathBuf::from(self.translation_files.replace("{locale}", locale));
if candidate.is_absolute() {
candidate
} else {
self.path.parent().unwrap_or(Path::new(".")).join(candidate)
}
}
}
pub fn write_document(path: &Path, document: &ConfigDocument) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = if path.extension().and_then(|value| value.to_str()) == Some("json") {
format!("{}\n", serde_json::to_string_pretty(document)?)
} else {
serde_yaml::to_string(document)?
};
fs::write(path, content)?;
Ok(())
}
pub fn absolute_path(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
return Ok(path.to_owned());
}
Ok(env::current_dir()?.join(path))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_portable_yaml_defaults() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join(DEFAULT_CONFIG_FILE);
fs::write(&path, "projectId: demo\nlocales: [en, ru]\n").unwrap();
let config = Config::load(&path).unwrap();
assert_eq!(config.default_locale, "en");
assert_eq!(config.fallback_locale, "en");
assert_eq!(
config.translation_path("ru"),
root.path().join(".lingva/translations/ru.json")
);
}
}