linguini-config 0.1.0-alpha.4

Configuration parsing and file discovery for Linguini projects
Documentation
use crate::error::{ConfigError, ConfigResult};

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct LinguiniConfig {
    pub project: ProjectConfig,
    pub paths: PathsConfig,
    pub targets: TargetsConfig,
    pub web: WebConfig,
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ProjectConfig {
    pub name: String,
    pub default_locale: String,
    pub locales: Vec<String>,
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct PathsConfig {
    pub schema: String,
    pub locale: String,
}

#[derive(Debug, Clone, Eq, PartialEq, Default)]
pub struct TargetsConfig {
    pub ts: Option<TypeScriptTargetConfig>,
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct TypeScriptTargetConfig {
    pub out: String,
    pub module: String,
    pub declaration: bool,
    pub gitignore: bool,
    pub tree_shaking: bool,
    pub messages: Vec<String>,
    pub framework: Option<String>,
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct WebConfig {
    pub configured: bool,
    pub strategy: Vec<String>,
    pub cookie_name: String,
    pub cookie_path: String,
    pub cookie_domain: Option<String>,
    pub cookie_max_age: u64,
    pub cookie_same_site: String,
    pub cookie_secure: bool,
    pub cookie_http_only: bool,
    pub local_storage_key: String,
    pub global_variable_name: Option<String>,
    pub prefix_default_locale: bool,
    pub base_path: String,
    pub trailing_slash: String,
    pub redirect: bool,
    pub origin: Option<String>,
    pub exclude: Vec<String>,
    pub localize_links: bool,
}

impl Default for WebConfig {
    fn default() -> Self {
        Self {
            configured: false,
            strategy: vec![
                "url".to_owned(),
                "cookie".to_owned(),
                "localStorage".to_owned(),
                "preferredLanguage".to_owned(),
                "baseLocale".to_owned(),
            ],
            cookie_name: "LINGUINI_LOCALE".to_owned(),
            cookie_path: "/".to_owned(),
            cookie_domain: None,
            cookie_max_age: 60 * 60 * 24 * 365,
            cookie_same_site: "lax".to_owned(),
            cookie_secure: false,
            cookie_http_only: false,
            local_storage_key: "LINGUINI_LOCALE".to_owned(),
            global_variable_name: None,
            prefix_default_locale: false,
            base_path: String::new(),
            trailing_slash: "ignore".to_owned(),
            redirect: true,
            origin: None,
            exclude: Vec::new(),
            localize_links: true,
        }
    }
}

impl LinguiniConfig {
    pub fn validate(&self) -> ConfigResult<()> {
        validate_locale_tag(&self.project.default_locale)?;

        if !self
            .project
            .locales
            .iter()
            .any(|locale| locale == &self.project.default_locale)
        {
            return Err(ConfigError::MissingField("project.locales default_locale"));
        }

        for locale in &self.project.locales {
            validate_locale_tag(locale)?;
        }

        if let Some(ts) = &self.targets.ts {
            if ts.out.trim().is_empty() {
                return Err(ConfigError::MissingField("targets.ts.out"));
            }
            if ts.module != "esm" {
                return Err(ConfigError::InvalidString(ts.module.clone()));
            }
            if !ts.tree_shaking && !ts.messages.is_empty() {
                return Err(ConfigError::InvalidString(
                    "targets.ts.messages requires tree_shaking = true".to_owned(),
                ));
            }
            if let Some(framework) = &ts.framework {
                match framework.as_str() {
                    "svelte" | "sveltekit" => {}
                    value => return Err(ConfigError::InvalidString(value.to_owned())),
                }
            }
        }

        validate_web_strategy(&self.web.strategy)?;
        match self.web.trailing_slash.as_str() {
            "ignore" | "always" | "never" | "directory" => {}
            value => return Err(ConfigError::InvalidString(value.to_owned())),
        }
        match self.web.cookie_same_site.as_str() {
            "lax" | "strict" | "none" => {}
            value => return Err(ConfigError::InvalidString(value.to_owned())),
        }

        Ok(())
    }
}

fn validate_web_strategy(strategy: &[String]) -> ConfigResult<()> {
    if strategy.is_empty() {
        return Err(ConfigError::InvalidArray("web.strategy".to_owned()));
    }
    for item in strategy {
        let is_builtin = matches!(
            item.as_str(),
            "url"
                | "cookie"
                | "localStorage"
                | "header"
                | "navigator"
                | "preferredLanguage"
                | "globalVariable"
                | "baseLocale"
        );
        if !is_builtin && !item.starts_with("custom-") {
            return Err(ConfigError::InvalidString(item.clone()));
        }
    }
    Ok(())
}

pub fn validate_locale_tag(tag: &str) -> ConfigResult<()> {
    let mut parts = tag.split('-');
    let Some(language) = parts.next() else {
        return Err(ConfigError::InvalidLocaleTag(tag.to_owned()));
    };

    if language.len() < 2
        || language.len() > 3
        || !language
            .chars()
            .all(|character| character.is_ascii_lowercase())
    {
        return Err(ConfigError::InvalidLocaleTag(tag.to_owned()));
    }

    for part in parts {
        let valid = (part.len() == 2
            && part.chars().all(|character| character.is_ascii_uppercase()))
            || (part.len() == 4
                && part.chars().enumerate().all(|(index, character)| {
                    if index == 0 {
                        character.is_ascii_uppercase()
                    } else {
                        character.is_ascii_lowercase()
                    }
                }));

        if !valid {
            return Err(ConfigError::InvalidLocaleTag(tag.to_owned()));
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::validate_locale_tag;

    #[test]
    fn accepts_spec_locale_tags() {
        for tag in ["ru", "en", "en-US", "pt-BR", "zh-Hant"] {
            assert!(validate_locale_tag(tag).is_ok(), "{tag}");
        }
    }

    #[test]
    fn rejects_non_bcp47_like_locale_tags() {
        for tag in ["r", "EN", "en-us", "zh-hant", "en-US-extra"] {
            assert!(validate_locale_tag(tag).is_err(), "{tag}");
        }
    }
}