langcodec 0.12.0

Universal localization file toolkit for Rust. Supports Apple, Android, and CSV formats.
Documentation
use langcodec::Codec;
use langcodec::converter::{convert, convert_resources_to_format};
use langcodec::formats::FormatType;
use langcodec::types::{Entry, EntryStatus, Metadata, Resource, Translation};
use proptest::prelude::*;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;

fn key_strategy() -> impl Strategy<Value = String> {
    proptest::string::string_regex("[a-z][a-z0-9_]{0,15}").expect("valid key regex")
}

fn value_strategy() -> impl Strategy<Value = String> {
    proptest::string::string_regex("[A-Za-z0-9 _\\-\\.,!\\?]{1,30}").expect("valid value regex")
}

fn single_lang_dataset_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
    prop::collection::btree_map(key_strategy(), value_strategy(), 1..8)
}

fn two_lang_dataset_strategy() -> impl Strategy<Value = BTreeMap<String, (String, String)>> {
    prop::collection::btree_map(key_strategy(), (value_strategy(), value_strategy()), 1..8)
}

fn build_resource(language: &str, values: &BTreeMap<String, String>) -> Resource {
    let mut custom = HashMap::new();
    custom.insert("source_language".to_string(), "en".to_string());
    custom.insert("version".to_string(), "1.0".to_string());

    let entries = values
        .iter()
        .map(|(key, value)| Entry {
            id: key.clone(),
            value: Translation::Singular(value.clone()),
            comment: None,
            status: EntryStatus::Translated,
            custom: HashMap::new(),
        })
        .collect();

    Resource {
        metadata: Metadata {
            language: language.to_string(),
            domain: "Localizable".to_string(),
            custom,
        },
        entries,
    }
}

fn build_two_lang_resources(values: &BTreeMap<String, (String, String)>) -> Vec<Resource> {
    let en_map = values
        .iter()
        .map(|(key, (en, _))| (key.clone(), en.clone()))
        .collect::<BTreeMap<_, _>>();
    let fr_map = values
        .iter()
        .map(|(key, (_, fr))| (key.clone(), fr.clone()))
        .collect::<BTreeMap<_, _>>();

    vec![build_resource("en", &en_map), build_resource("fr", &fr_map)]
}

fn expected_single_lang_map(
    values: &BTreeMap<String, String>,
) -> BTreeMap<(String, String), String> {
    values
        .iter()
        .map(|(key, value)| (("en".to_string(), key.clone()), value.clone()))
        .collect()
}

fn expected_two_lang_map(
    values: &BTreeMap<String, (String, String)>,
) -> BTreeMap<(String, String), String> {
    let mut out = BTreeMap::new();
    for (key, (en, fr)) in values {
        out.insert(("en".to_string(), key.clone()), en.clone());
        out.insert(("fr".to_string(), key.clone()), fr.clone());
    }
    out
}

fn read_resources(path: &Path, lang_hint: Option<&str>) -> Result<Vec<Resource>, String> {
    let mut codec = Codec::new();
    codec
        .read_file_by_extension(path, lang_hint.map(|lang| lang.to_string()))
        .map_err(|e| e.to_string())?;
    Ok(codec.resources)
}

fn canonical_singular_map(resources: &[Resource]) -> BTreeMap<(String, String), String> {
    let mut out = BTreeMap::new();

    for resource in resources {
        for entry in &resource.entries {
            if let Translation::Singular(value) = &entry.value {
                out.insert(
                    (resource.metadata.language.clone(), entry.id.clone()),
                    value.clone(),
                );
            }
        }
    }

    out
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(16))]

    #[test]
    fn strings_android_strings_roundtrip_preserves_singular_entries(values in single_lang_dataset_strategy()) {
        let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
        let input = tmp.path().join("seed.strings");
        let middle = tmp.path().join("middle.xml");
        let output = tmp.path().join("roundtrip.strings");

        let seed = vec![build_resource("en", &values)];
        convert_resources_to_format(
            seed,
            input.to_str().expect("path to str"),
            FormatType::Strings(Some("en".to_string())),
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        convert(
            &input,
            FormatType::Strings(Some("en".to_string())),
            &middle,
            FormatType::AndroidStrings(Some("en".to_string())),
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        convert(
            &middle,
            FormatType::AndroidStrings(Some("en".to_string())),
            &output,
            FormatType::Strings(Some("en".to_string())),
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        let actual = read_resources(&output, Some("en")).map_err(TestCaseError::fail)?;
        prop_assert_eq!(
            canonical_singular_map(&actual),
            expected_single_lang_map(&values)
        );
    }
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(16))]

    #[test]
    fn android_strings_android_roundtrip_preserves_singular_entries(values in single_lang_dataset_strategy()) {
        let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
        let input = tmp.path().join("seed.xml");
        let middle = tmp.path().join("middle.strings");
        let output = tmp.path().join("roundtrip.xml");

        let seed = vec![build_resource("en", &values)];
        convert_resources_to_format(
            seed,
            input.to_str().expect("path to str"),
            FormatType::AndroidStrings(Some("en".to_string())),
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        convert(
            &input,
            FormatType::AndroidStrings(Some("en".to_string())),
            &middle,
            FormatType::Strings(Some("en".to_string())),
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        convert(
            &middle,
            FormatType::Strings(Some("en".to_string())),
            &output,
            FormatType::AndroidStrings(Some("en".to_string())),
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        let actual = read_resources(&output, Some("en")).map_err(TestCaseError::fail)?;
        prop_assert_eq!(
            canonical_singular_map(&actual),
            expected_single_lang_map(&values)
        );
    }
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(16))]

    #[test]
    fn strings_xcstrings_strings_roundtrip_preserves_singular_entries(values in single_lang_dataset_strategy()) {
        let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
        let input = tmp.path().join("seed.strings");
        let middle = tmp.path().join("middle.xcstrings");
        let output = tmp.path().join("roundtrip.strings");

        let seed = vec![build_resource("en", &values)];
        convert_resources_to_format(
            seed,
            input.to_str().expect("path to str"),
            FormatType::Strings(Some("en".to_string())),
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        convert(
            &input,
            FormatType::Strings(Some("en".to_string())),
            &middle,
            FormatType::Xcstrings,
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        convert(
            &middle,
            FormatType::Xcstrings,
            &output,
            FormatType::Strings(Some("en".to_string())),
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        let actual = read_resources(&output, Some("en")).map_err(TestCaseError::fail)?;
        prop_assert_eq!(
            canonical_singular_map(&actual),
            expected_single_lang_map(&values)
        );
    }
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(16))]

    #[test]
    fn csv_xcstrings_csv_roundtrip_preserves_multilang_entries(values in two_lang_dataset_strategy()) {
        let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
        let input = tmp.path().join("seed.csv");
        let middle = tmp.path().join("middle.xcstrings");
        let output = tmp.path().join("roundtrip.csv");

        let seed = build_two_lang_resources(&values);
        convert_resources_to_format(
            seed,
            input.to_str().expect("path to str"),
            FormatType::CSV,
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        convert(&input, FormatType::CSV, &middle, FormatType::Xcstrings)
            .map_err(|e| TestCaseError::fail(e.to_string()))?;
        convert(&middle, FormatType::Xcstrings, &output, FormatType::CSV)
            .map_err(|e| TestCaseError::fail(e.to_string()))?;

        let actual = read_resources(&output, None).map_err(TestCaseError::fail)?;
        prop_assert_eq!(canonical_singular_map(&actual), expected_two_lang_map(&values));
    }
}

proptest! {
    #![proptest_config(ProptestConfig::with_cases(16))]

    #[test]
    fn csv_tsv_csv_roundtrip_preserves_multilang_entries(values in two_lang_dataset_strategy()) {
        let tmp = tempfile::tempdir().map_err(|e| TestCaseError::fail(e.to_string()))?;
        let input = tmp.path().join("seed.csv");
        let middle = tmp.path().join("middle.tsv");
        let output = tmp.path().join("roundtrip.csv");

        let seed = build_two_lang_resources(&values);
        convert_resources_to_format(
            seed,
            input.to_str().expect("path to str"),
            FormatType::CSV,
        )
        .map_err(|e| TestCaseError::fail(e.to_string()))?;

        convert(&input, FormatType::CSV, &middle, FormatType::TSV)
            .map_err(|e| TestCaseError::fail(e.to_string()))?;
        convert(&middle, FormatType::TSV, &output, FormatType::CSV)
            .map_err(|e| TestCaseError::fail(e.to_string()))?;

        let actual = read_resources(&output, None).map_err(TestCaseError::fail)?;
        prop_assert_eq!(canonical_singular_map(&actual), expected_two_lang_map(&values));
    }
}