langcodec 0.14.1

Universal localization file toolkit for Rust. Supports Apple, Android, and CSV formats.
Documentation
use std::{collections::BTreeMap, io::Cursor};

use langcodec::{
    Entry, EntryStatus, Error, Metadata, Plural, Resource, Translation,
    formats::{CSVFormat, MultiLanguageCSVRecord, MultiLanguageTSVRecord, TSVFormat},
    traits::Parser,
};

const HEADER: [&str; 16] = [
    "__langcodec_extended_v1",
    "row_kind",
    "resource_index",
    "entry_index",
    "language",
    "domain",
    "resource_custom",
    "key",
    "value_kind",
    "plural_id",
    "plural_category",
    "value",
    "status",
    "comment_kind",
    "comment",
    "entry_custom",
];

fn encoded(delimiter: u8, rows: &[Vec<&str>]) -> Vec<u8> {
    let mut bytes = Vec::new();
    {
        let mut writer = csv::WriterBuilder::new()
            .delimiter(delimiter)
            .from_writer(&mut bytes);
        writer.write_record(HEADER).unwrap();
        for row in rows {
            writer.write_record(row).unwrap();
        }
        writer.flush().unwrap();
    }
    bytes
}

fn assert_data_mismatch(error: Error) {
    assert!(
        matches!(error, Error::DataMismatch(_)),
        "expected DataMismatch, got {error:?}"
    );
}

fn resource_row(custom: &str) -> Vec<&str> {
    vec![
        "v1", "resource", "0", "", "en", "", custom, "", "", "", "", "", "", "", "", "",
    ]
}

fn plural_entry_row() -> Vec<&'static str> {
    vec![
        "v1",
        "entry",
        "0",
        "0",
        "",
        "",
        "",
        "files",
        "plural",
        "files_id",
        "",
        "",
        "translated",
        "none",
        "",
        "[]",
    ]
}

fn plural_form_row(category: &'static str) -> Vec<&'static str> {
    vec![
        "v1",
        "plural_form",
        "0",
        "0",
        "",
        "",
        "",
        "",
        "",
        "",
        category,
        "%d files",
        "",
        "",
        "",
        "",
    ]
}

#[test]
fn malformed_basic_rows_and_duplicate_headers_are_rejected() {
    assert_data_mismatch(
        CSVFormat::from_reader(Cursor::new("key,en,fr\nwelcome,Welcome\n")).unwrap_err(),
    );
    assert_data_mismatch(
        TSVFormat::from_reader(Cursor::new("key\ten\ten\nwelcome\tOne\tTwo\n")).unwrap_err(),
    );
    assert_data_mismatch(
        CSVFormat::from_reader(Cursor::new("key,en\nsame,One\nsame,Two\n")).unwrap_err(),
    );
    assert_data_mismatch(
        CSVFormat::from_reader(Cursor::new("key, en\nwelcome,Welcome\n")).unwrap_err(),
    );
}

#[test]
fn malformed_or_unknown_extended_header_never_falls_back_to_basic() {
    let unknown = "__langcodec_extended_v2,row_kind,resource_index\nv2,resource,0\n";
    assert_data_mismatch(CSVFormat::from_reader(Cursor::new(unknown)).unwrap_err());

    let malformed = "__langcodec_extended_v1,row_kind,resource_index\nv1,resource,0\n";
    assert_data_mismatch(CSVFormat::from_reader(Cursor::new(malformed)).unwrap_err());
}

#[test]
fn extended_rows_must_follow_resource_and_entry_stream_order() {
    let entry_before_resource = vec![vec![
        "v1",
        "entry",
        "0",
        "0",
        "",
        "",
        "",
        "key",
        "singular",
        "",
        "",
        "value",
        "translated",
        "none",
        "",
        "[]",
    ]];
    assert_data_mismatch(
        CSVFormat::from_reader(Cursor::new(encoded(b',', &entry_before_resource))).unwrap_err(),
    );
    assert_data_mismatch(
        TSVFormat::from_reader(Cursor::new(encoded(b'\t', &entry_before_resource))).unwrap_err(),
    );

    let resource_gap = vec![vec![
        "v1", "resource", "1", "", "en", "", "[]", "", "", "", "", "", "", "", "", "",
    ]];
    assert_data_mismatch(
        CSVFormat::from_reader(Cursor::new(encoded(b',', &resource_gap))).unwrap_err(),
    );
}

#[test]
fn duplicate_plural_categories_and_custom_keys_are_rejected() {
    let duplicate_form = vec![
        resource_row("[]"),
        plural_entry_row(),
        plural_form_row("one"),
        plural_form_row("one"),
    ];
    assert_data_mismatch(
        CSVFormat::from_reader(Cursor::new(encoded(b',', &duplicate_form))).unwrap_err(),
    );
    assert_data_mismatch(
        TSVFormat::from_reader(Cursor::new(encoded(b'\t', &duplicate_form))).unwrap_err(),
    );

    let duplicate_custom = vec![resource_row(r#"[["same","one"],["same","two"]]"#)];
    assert_data_mismatch(
        CSVFormat::from_reader(Cursor::new(encoded(b',', &duplicate_custom))).unwrap_err(),
    );
}

#[test]
fn inapplicable_nonblank_fields_are_rejected() {
    let mut row = resource_row("[]");
    row[7] = "resource rows cannot carry keys";
    assert_data_mismatch(CSVFormat::from_reader(Cursor::new(encoded(b',', &[row]))).unwrap_err());
}

#[test]
fn plural_entries_without_forms_are_rejected_on_read_and_write() {
    let rows = vec![resource_row("[]"), plural_entry_row()];
    assert_data_mismatch(CSVFormat::from_reader(Cursor::new(encoded(b',', &rows))).unwrap_err());

    let resources = vec![Resource {
        metadata: Metadata {
            language: "en".to_string(),
            domain: String::new(),
            custom: Default::default(),
        },
        entries: vec![Entry {
            id: "files".to_string(),
            value: Translation::Plural(Plural {
                id: "files_id".to_string(),
                forms: BTreeMap::new(),
            }),
            comment: None,
            status: EntryStatus::Translated,
            custom: Default::default(),
        }],
    }];
    assert_data_mismatch(CSVFormat::try_from(resources).unwrap_err());
}

#[test]
fn public_record_mutation_cannot_hide_languages_or_duplicate_keys() {
    let mut csv =
        CSVFormat::from_reader(Cursor::new("key,en\nwelcome,Welcome\nbye,Goodbye\n")).unwrap();
    csv.records[0].add_translation("fr".to_string(), "Bienvenue".to_string());
    let csv_resources = Vec::<Resource>::try_from(csv.clone()).unwrap();
    assert_eq!(
        csv_resources
            .iter()
            .map(|resource| resource.metadata.language.as_str())
            .collect::<Vec<_>>(),
        vec!["en", "fr"]
    );
    assert_eq!(
        csv_resources[1].entries[1].value,
        Translation::Singular(String::new())
    );
    let mut csv_bytes = Vec::new();
    csv.to_writer(&mut csv_bytes).unwrap();
    let reparsed_csv = CSVFormat::from_reader(Cursor::new(csv_bytes)).unwrap();
    assert_eq!(
        Vec::<Resource>::try_from(reparsed_csv).unwrap(),
        csv_resources
    );

    let mut tsv =
        TSVFormat::from_reader(Cursor::new("key\ten\nwelcome\tWelcome\nbye\tGoodbye\n")).unwrap();
    tsv.records[0].add_translation("fr".to_string(), "Bienvenue".to_string());
    let tsv_resources = Vec::<Resource>::try_from(tsv.clone()).unwrap();
    assert_eq!(
        tsv_resources
            .iter()
            .map(|resource| resource.metadata.language.as_str())
            .collect::<Vec<_>>(),
        vec!["en", "fr"]
    );
    assert_eq!(
        tsv_resources[1].entries[1].value,
        Translation::Singular(String::new())
    );
    let mut tsv_bytes = Vec::new();
    tsv.to_writer(&mut tsv_bytes).unwrap();
    let reparsed_tsv = TSVFormat::from_reader(Cursor::new(tsv_bytes)).unwrap();
    assert_eq!(
        Vec::<Resource>::try_from(reparsed_tsv).unwrap(),
        tsv_resources
    );

    let mut csv_first = MultiLanguageCSVRecord::new("same".to_string());
    csv_first.add_translation("en".to_string(), "one".to_string());
    let mut csv_second = MultiLanguageCSVRecord::new("same".to_string());
    csv_second.add_translation("en".to_string(), "two".to_string());
    let csv_duplicates = CSVFormat::with_records(vec![csv_first, csv_second]);
    assert_data_mismatch(Vec::<Resource>::try_from(csv_duplicates).unwrap_err());

    let mut tsv_first = MultiLanguageTSVRecord::new("same".to_string());
    tsv_first.add_translation("en".to_string(), "one".to_string());
    let mut tsv_second = MultiLanguageTSVRecord::new("same".to_string());
    tsv_second.add_translation("en".to_string(), "two".to_string());
    let tsv_duplicates = TSVFormat::with_records(vec![tsv_first, tsv_second]);
    assert_data_mismatch(Vec::<Resource>::try_from(tsv_duplicates).unwrap_err());

    let mut invalid_language = MultiLanguageCSVRecord::new("welcome".to_string());
    invalid_language.add_translation(" en ".to_string(), "Welcome".to_string());
    assert_data_mismatch(
        CSVFormat::with_records(vec![invalid_language])
            .to_writer(Vec::new())
            .unwrap_err(),
    );
}