lingva 0.1.0

Standalone Lingva localization CLI
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")
        );
    }
}