linguini-config 0.1.0-alpha.4

Configuration parsing and file discovery for Linguini projects
Documentation
use crate::error::{ConfigError, ConfigResult};
use crate::model::validate_locale_tag;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct SchemaFile {
    pub path: PathBuf,
    pub namespace: String,
}

#[derive(Debug, Clone, Eq, PartialEq)]
pub struct LocaleFile {
    pub path: PathBuf,
    pub locale: String,
    pub namespace: String,
}

pub fn discover_schema_files(root: impl AsRef<Path>) -> ConfigResult<Vec<SchemaFile>> {
    let root = root.as_ref();
    let mut files = Vec::new();
    collect_schema_files(root, root, &mut files)?;
    files.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(files)
}

pub fn discover_locale_files(root: impl AsRef<Path>) -> ConfigResult<Vec<LocaleFile>> {
    let root = root.as_ref();
    let mut files = Vec::new();
    collect_locale_files(root, root, &mut files)?;
    files.sort_by(|left, right| left.path.cmp(&right.path));
    Ok(files)
}

pub fn locale_scope_chain(locale_root: impl AsRef<Path>, file: impl AsRef<Path>) -> Vec<PathBuf> {
    let locale_root = locale_root.as_ref();
    let file = file.as_ref();
    let locale_name = file.file_name().unwrap_or_default();
    let parent = file.parent().unwrap_or(locale_root);
    let relative_parent = parent.strip_prefix(locale_root).unwrap_or(parent);
    let mut paths = Vec::new();
    let mut current = locale_root.to_path_buf();

    paths.push(current.join(locale_name));

    for component in relative_parent.components() {
        current.push(component.as_os_str());
        paths.push(current.join(locale_name));
    }

    paths
}

fn collect_schema_files(
    root: &Path,
    directory: &Path,
    files: &mut Vec<SchemaFile>,
) -> ConfigResult<()> {
    for entry in read_directory(directory)? {
        let path = entry.path();

        if path.is_dir() {
            collect_schema_files(root, &path, files)?;
        } else if path.extension().and_then(|extension| extension.to_str()) == Some("lgs") {
            files.push(SchemaFile {
                namespace: schema_namespace(root, &path),
                path,
            });
        }
    }

    Ok(())
}

fn collect_locale_files(
    root: &Path,
    directory: &Path,
    files: &mut Vec<LocaleFile>,
) -> ConfigResult<()> {
    for entry in read_directory(directory)? {
        let path = entry.path();

        if path.is_dir() {
            collect_locale_files(root, &path, files)?;
        } else if path.extension().and_then(|extension| extension.to_str()) == Some("lgl") {
            let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
                continue;
            };

            validate_locale_tag(stem)?;
            files.push(LocaleFile {
                locale: stem.to_owned(),
                namespace: locale_namespace(root, &path),
                path,
            });
        }
    }

    Ok(())
}

fn read_directory(path: &Path) -> ConfigResult<Vec<fs::DirEntry>> {
    fs::read_dir(path)
        .map_err(|_| ConfigError::UnreadableDirectory(path.to_path_buf()))?
        .collect::<Result<Vec<_>, _>>()
        .map_err(|_| ConfigError::UnreadableDirectory(path.to_path_buf()))
}

fn schema_namespace(root: &Path, path: &Path) -> String {
    namespace_from_path(root, path, true)
}

fn locale_namespace(root: &Path, path: &Path) -> String {
    namespace_from_path(root, path, false)
}

fn namespace_from_path(root: &Path, path: &Path, include_file_stem: bool) -> String {
    let relative = path.strip_prefix(root).unwrap_or(path);
    let mut parts = Vec::new();

    if let Some(parent) = relative.parent() {
        parts.extend(
            parent
                .components()
                .filter_map(|component| component.as_os_str().to_str().map(str::to_owned)),
        );
    }

    if include_file_stem {
        if let Some(stem) = relative.file_stem().and_then(|stem| stem.to_str()) {
            parts.push(stem.to_owned());
        }
    }

    parts.join(".")
}

#[cfg(test)]
mod tests {
    use super::{
        discover_locale_files, discover_schema_files, locale_scope_chain, namespace_from_path,
    };
    use std::fs;
    use std::path::Path;

    #[test]
    fn derives_schema_namespace_from_path() {
        let namespace = namespace_from_path(
            Path::new("linguini/schema"),
            Path::new("linguini/schema/shop/delivery.lgs"),
            true,
        );

        assert_eq!(namespace, "shop.delivery");
    }

    #[test]
    fn derives_locale_namespace_from_parent_path() {
        let namespace = namespace_from_path(
            Path::new("linguini/locale"),
            Path::new("linguini/locale/shop/forms/fruit/ru.lgl"),
            false,
        );

        assert_eq!(namespace, "shop.forms.fruit");
    }

    #[test]
    fn derives_locale_namespace_matching_schema_file_layout() {
        let namespace = namespace_from_path(
            Path::new("locales"),
            Path::new("locales/shop/ru.lgl"),
            false,
        );

        assert_eq!(namespace, "shop");
    }

    #[test]
    fn builds_top_down_locale_scope_chain() {
        let chain = locale_scope_chain("linguini/locale", "linguini/locale/shop/delivery/ru.lgl");

        assert_eq!(
            chain,
            [
                Path::new("linguini/locale/ru.lgl").to_path_buf(),
                Path::new("linguini/locale/shop/ru.lgl").to_path_buf(),
                Path::new("linguini/locale/shop/delivery/ru.lgl").to_path_buf(),
            ]
        );
    }

    #[test]
    fn discovers_project_structure_namespaces_and_locales() {
        let root = temp_root("discovers_project_structure_namespaces_and_locales");
        let schema_root = root.join("schema");
        let locale_root = root.join("locales");
        fs::create_dir_all(schema_root.join("shop/forms")).expect("schema dirs");
        fs::create_dir_all(locale_root.join("shop/forms/cart")).expect("locale dirs");
        fs::write(schema_root.join("shop/forms/cart.lgs"), "cart()\n").expect("schema file");
        fs::write(
            locale_root.join("shop/forms/cart/en-US.lgl"),
            "cart = Cart\n",
        )
        .expect("locale file");

        let schemas = discover_schema_files(&schema_root).expect("schema discovery");
        let locales = discover_locale_files(&locale_root).expect("locale discovery");

        assert_eq!(schemas.len(), 1);
        assert_eq!(schemas[0].namespace, "shop.forms.cart");
        assert_eq!(locales.len(), 1);
        assert_eq!(locales[0].locale, "en-US");
        assert_eq!(locales[0].namespace, "shop.forms.cart");

        fs::remove_dir_all(root).expect("remove temp project");
    }

    fn temp_root(name: &str) -> std::path::PathBuf {
        let path =
            std::env::temp_dir().join(format!("linguini-config-{name}-{}", std::process::id()));
        let _ = fs::remove_dir_all(&path);
        fs::create_dir_all(&path).expect("create temp root");
        path
    }
}