langcodec 0.12.0

Universal localization file toolkit for Rust. Supports Apple, Android, and CSV formats.
Documentation
use langcodec::types::Translation;
use langcodec::{Codec, convert_auto};
use std::path::{Path, PathBuf};

#[derive(Clone)]
struct ExpectedValue {
    language: &'static str,
    key: &'static str,
    value: &'static str,
}

struct ParseCase {
    name: &'static str,
    input_relative_path: &'static str,
    lang_hint: Option<&'static str>,
    expected_values: Vec<ExpectedValue>,
}

struct ConvertCase {
    name: &'static str,
    input_relative_path: &'static str,
    output_file_name: &'static str,
    output_lang_hint: Option<&'static str>,
    expected_values: Vec<ExpectedValue>,
}

fn corpus_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("tests")
        .join("data")
        .join("lib")
        .join("corpus")
}

fn expected_en_stable_values() -> Vec<ExpectedValue> {
    vec![
        ExpectedValue {
            language: "en",
            key: "welcome_message",
            value: "Hello, World!",
        },
        ExpectedValue {
            language: "en",
            key: "xml_entities",
            value: "Use <tag> & value",
        },
        ExpectedValue {
            language: "en",
            key: "comma_text",
            value: "alpha, beta, gamma",
        },
        ExpectedValue {
            language: "en",
            key: "accent_text",
            value: "Café crème brûlée",
        },
    ]
}

fn expected_fr_stable_values() -> Vec<ExpectedValue> {
    vec![
        ExpectedValue {
            language: "fr",
            key: "welcome_message",
            value: "Bonjour, le monde !",
        },
        ExpectedValue {
            language: "fr",
            key: "xml_entities",
            value: "Utiliser <tag> & valeur",
        },
        ExpectedValue {
            language: "fr",
            key: "comma_text",
            value: "alpha, bêta, gamma",
        },
        ExpectedValue {
            language: "fr",
            key: "accent_text",
            value: "Café crème brûlée",
        },
    ]
}

fn read_codec(path: &Path, lang_hint: Option<&str>) -> Codec {
    let mut codec = Codec::new();
    codec
        .read_file_by_extension(path, lang_hint.map(|l| l.to_string()))
        .unwrap_or_else(|e| panic!("failed to read {}: {}", path.display(), e));
    codec
}

fn assert_expected_values(codec: &Codec, expected_values: &[ExpectedValue], case_name: &str) {
    for expected in expected_values {
        let entry = codec
            .find_entry(expected.key, expected.language)
            .unwrap_or_else(|| {
                panic!(
                    "{case_name}: missing key '{}' for language '{}'",
                    expected.key, expected.language
                )
            });

        match &entry.value {
            Translation::Singular(actual) => {
                assert_eq!(
                    actual, expected.value,
                    "{case_name}: value mismatch for {}:{}",
                    expected.language, expected.key
                );
            }
            other => panic!(
                "{case_name}: expected singular value for {}:{}, got {:?}",
                expected.language, expected.key, other
            ),
        }
    }
}

#[test]
fn parse_edge_case_corpora_table_driven() {
    let root = corpus_root();
    let mut csv_and_tsv_expected = expected_en_stable_values();
    csv_and_tsv_expected.extend(expected_fr_stable_values());

    let parse_cases = vec![
        ParseCase {
            name: "strings corpus parse",
            input_relative_path: "en.lproj/Localizable.strings",
            lang_hint: None,
            expected_values: {
                let mut expected = expected_en_stable_values();
                expected.push(ExpectedValue {
                    language: "en",
                    key: "quoted_text",
                    value: "He said \\\"Hello\\\"",
                });
                expected.push(ExpectedValue {
                    language: "en",
                    key: "apostrophe_text",
                    value: "Don't stop",
                });
                expected
            },
        },
        ParseCase {
            name: "android corpus parse",
            input_relative_path: "values-en/strings.xml",
            lang_hint: None,
            expected_values: {
                let mut expected = expected_en_stable_values();
                expected.push(ExpectedValue {
                    language: "en",
                    key: "quoted_text",
                    value: "He said \"Hello\"",
                });
                expected.push(ExpectedValue {
                    language: "en",
                    key: "apostrophe_text",
                    value: "Don\\'t stop",
                });
                expected
            },
        },
        ParseCase {
            name: "csv corpus parse",
            input_relative_path: "corpus.csv",
            lang_hint: None,
            expected_values: csv_and_tsv_expected.clone(),
        },
        ParseCase {
            name: "tsv corpus parse",
            input_relative_path: "corpus.tsv",
            lang_hint: None,
            expected_values: csv_and_tsv_expected,
        },
    ];

    for case in parse_cases {
        let input_path = root.join(case.input_relative_path);
        let codec = read_codec(&input_path, case.lang_hint);
        assert_expected_values(&codec, &case.expected_values, case.name);
    }
}

#[test]
fn convert_edge_case_corpora_table_driven() {
    let root = corpus_root();
    let output_dir = tempfile::tempdir().expect("create temp output dir");

    let csv_single_lang_expected = expected_en_stable_values();

    let convert_cases = vec![
        ConvertCase {
            name: "strings -> android",
            input_relative_path: "en.lproj/Localizable.strings",
            output_file_name: "from_strings.xml",
            output_lang_hint: Some("en"),
            expected_values: expected_en_stable_values(),
        },
        ConvertCase {
            name: "strings -> xcstrings",
            input_relative_path: "en.lproj/Localizable.strings",
            output_file_name: "from_strings.xcstrings",
            output_lang_hint: None,
            expected_values: expected_en_stable_values(),
        },
        ConvertCase {
            name: "strings -> csv",
            input_relative_path: "en.lproj/Localizable.strings",
            output_file_name: "from_strings.csv",
            output_lang_hint: None,
            expected_values: csv_single_lang_expected.clone(),
        },
        ConvertCase {
            name: "strings -> tsv",
            input_relative_path: "en.lproj/Localizable.strings",
            output_file_name: "from_strings.tsv",
            output_lang_hint: None,
            expected_values: csv_single_lang_expected,
        },
        ConvertCase {
            name: "android -> strings",
            input_relative_path: "values-en/strings.xml",
            output_file_name: "from_android.strings",
            output_lang_hint: Some("en"),
            expected_values: expected_en_stable_values(),
        },
        ConvertCase {
            name: "csv -> xcstrings",
            input_relative_path: "corpus.csv",
            output_file_name: "from_csv.xcstrings",
            output_lang_hint: None,
            expected_values: {
                let mut expected = expected_en_stable_values();
                expected.extend(expected_fr_stable_values());
                expected
            },
        },
        ConvertCase {
            name: "tsv -> xcstrings",
            input_relative_path: "corpus.tsv",
            output_file_name: "from_tsv.xcstrings",
            output_lang_hint: None,
            expected_values: {
                let mut expected = expected_en_stable_values();
                expected.extend(expected_fr_stable_values());
                expected
            },
        },
    ];

    for case in convert_cases {
        let input_path = root.join(case.input_relative_path);
        let output_path = output_dir.path().join(case.output_file_name);

        convert_auto(&input_path, &output_path).unwrap_or_else(|e| {
            panic!(
                "{}: conversion failed from {} to {}: {}",
                case.name,
                input_path.display(),
                output_path.display(),
                e
            )
        });

        let codec = read_codec(&output_path, case.output_lang_hint);
        assert_expected_values(&codec, &case.expected_values, case.name);
    }
}