l10n_core 0.1.3

Core crate for l10n.
Documentation
use crate::locales::Locales;
use serde::{de::Error, Deserialize, Deserializer};
use std::{
    collections::HashMap,
    env, fs,
    path::{Path, PathBuf},
};
use thiserror::Error;

#[derive(Deserialize)]
struct ConfigFile {
    pub l10n: Config,
}

#[derive(Deserialize, PartialEq, Eq, Debug)]
pub struct Config {
    #[serde(alias = "path", default = "default_paths")]
    pub paths: Paths,
    pub locales: Option<Locales>,
}

#[derive(PartialEq, Eq, Debug)]
pub struct Paths {
    pub environments: HashMap<String, PathBuf>,
    pub default: PathBuf,
}

#[derive(Error, Debug)]
pub enum ConfigError {
    #[error("`CARGO_MANIFEST_DIR` env. variable not set, you can use `L10N_CONFIG_FILE` env. var if you are not using Cargo.")]
    CargoManifestDir,
    #[error("error reading file: {}", path.display())]
    ReadFile {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error(r#"error deserializing file "{}": {}"#, path.display(), source)]
    Deserialize {
        path: PathBuf,
        source: toml::de::Error,
    },
    #[error(r#"l10n path for environment "{0}" is not set in the configuration"#)]
    MissingPathError(String),
}

impl Config {
    pub fn path(&self) -> Result<PathBuf, ConfigError> {
        if let Ok(environment) = env::var("L10N_PATH_ENV") {
            self.paths
                .environments
                .get(&environment)
                .cloned()
                .ok_or(ConfigError::MissingPathError(environment))
        } else {
            Ok(self.paths.default.clone())
        }
    }
}

impl<'de> Deserialize<'de> for Paths {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        #[derive(Deserialize)]
        #[serde(untagged)]
        enum Helper {
            Short(PathBuf),
            Full(HashMap<String, PathBuf>),
        }

        Ok(match Helper::deserialize(deserializer)? {
            Helper::Short(default) => Paths {
                environments: HashMap::new(),
                default,
            },
            Helper::Full(mut profiles) => {
                let default = profiles
                    .remove("default")
                    .ok_or_else(|| Error::missing_field("default"))?;
                Paths {
                    environments: profiles,
                    default,
                }
            }
        })
    }
}

impl Default for Config {
    fn default() -> Self {
        Self {
            paths: default_paths(),
            locales: None,
        }
    }
}

fn default_paths() -> Paths {
    Paths {
        environments: HashMap::new(),
        default: PathBuf::from("l10n"),
    }
}

pub fn config_file_path() -> Result<Option<PathBuf>, ConfigError> {
    let l10n_config_file = env::var("L10N_CONFIG_FILE");
    if let Ok(l10n_config_file) = &l10n_config_file {
        let path = PathBuf::from(l10n_config_file);
        if path.is_absolute() {
            return path
                .canonicalize()
                .map(Some)
                .map_err(|source| ConfigError::ReadFile { path, source });
        }
    }

    let root =
        PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|_| ConfigError::CargoManifestDir)?);

    if let Ok(l10n_config_file) = l10n_config_file {
        let path = root.join(l10n_config_file);
        return path
            .canonicalize()
            .map(Some)
            .map_err(|source| ConfigError::ReadFile { path, source });
    }

    let l10n_path = root.join("l10n.toml");
    if let Ok(path) = l10n_path.canonicalize() {
        return Ok(Some(path));
    }

    let config_path = root.join("config.toml");
    if let Ok(path) = config_path.canonicalize() {
        return Ok(Some(path));
    }

    Ok(None)
}

pub fn get_config() -> Result<Config, ConfigError> {
    let config = if let Some(config_path) = config_file_path()? {
        let toml_string =
            fs::read_to_string(&config_path).map_err(|source| ConfigError::ReadFile {
                path: config_path.clone(),
                source,
            })?;

        let mut config = deserialize_translator_config(&toml_string).map_err(|source| {
            ConfigError::Deserialize {
                path: config_path.clone(),
                source,
            }
        })?;

        replace_root_var_in_path(&mut config.paths.default, &config_path);
        config
            .paths
            .environments
            .iter_mut()
            .for_each(|(_, path)| replace_root_var_in_path(path, &config_path));

        config
    } else {
        Default::default()
    };

    Ok(config)
}

fn deserialize_translator_config(source: &str) -> Result<Config, toml::de::Error> {
    Ok(toml::from_str::<ConfigFile>(source)?.l10n)
}

fn replace_root_var_in_path(path: &mut PathBuf, root_path: &Path) {
    if !path.is_absolute() && path.starts_with("$ROOT") {
        let unprefixed_path = path.strip_prefix("$ROOT").unwrap();
        *path = match root_path.parent() {
            Some(parent) => parent.join(unprefixed_path),
            None => PathBuf::from("/").join(unprefixed_path),
        }
    }
}

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

    #[test]
    fn deserialize_config_ok_1() {
        let config = r#"
            [l10n]
            path = "l10n_directory"
            locales = [
                "en",
                { main = "en-GB", fallback = "en" },
                { main = "en-CA", fallback = "en-GB" },
                { main = "fr" },
                { main = "fr-CA", fallback = "fr" },
            ]

            [other-config]
            with = "different config"
        "#;
        let expected = Config {
            paths: Paths {
                environments: HashMap::new(),
                default: PathBuf::from("l10n_directory"),
            },
            locales: Some(
                Locales::try_from([
                    ("en", None),
                    ("en-GB", Some("en")),
                    ("en-CA", Some("en-GB")),
                    ("fr", None),
                    ("fr-CA", Some("fr")),
                ])
                .unwrap(),
            ),
        };
        let actual = deserialize_translator_config(config).unwrap();
        assert_eq!(actual, expected);

        let config = r#"
            [l10n]
        "#;
        let expected = Config {
            paths: Paths {
                environments: HashMap::new(),
                default: PathBuf::from("l10n"),
            },
            locales: None,
        };
        let actual = deserialize_translator_config(config).unwrap();
        assert_eq!(actual, expected);

        let config = r#"
            [l10n]
            paths = { default = "$ROOT/l10n", release = "/var/l10n" }
        "#;
        let expected = Config {
            paths: Paths {
                environments: HashMap::from([("release".to_string(), PathBuf::from("/var/l10n"))]),
                default: PathBuf::from("$ROOT/l10n"),
            },
            locales: None,
        };
        let actual = deserialize_translator_config(config).unwrap();
        assert_eq!(actual, expected);
    }

    #[test]
    fn deserialize_config_paths_errors() {
        let config = indoc! {r#"
            [l10n]
            paths = { production = "/var/l10n" }
        "#};
        let error = deserialize_translator_config(config).unwrap_err();
        assert_eq!(
            &error.to_string(),
            "missing field `default` for key `l10n.paths` at line 1 column 1"
        );
    }

    #[test]
    fn deserialize_config_locales_errors() {
        let config = indoc! {r#"
            [l10n]
            locales = [
                { another = "key" },
            ]
        "#};
        let error = deserialize_translator_config(config).unwrap_err();
        assert_eq!(
            &error.to_string(),
            r#"missing field `main` for key `l10n.locales` at line 3 column 5"#
        );

        let config = indoc! {r#"
            [l10n]
            locales = [
                { main = "en-GB", fallback = "not-a-locale" },
                { main = "en-CA", fallback = "en-GB" },
                "fr",
                { main = "fr-CA", fallback = "fr" },
            ]
        "#};
        let error = deserialize_translator_config(config).unwrap_err();
        assert_eq!(
            &error.to_string(),
            r#"invalid value: string "not-a-locale", expected a valid Unicode Language Identifier like "en-US" (Parser error: Invalid subtag) for key `l10n.locales` at line 3 column 34"#
        );
    }
}