Skip to content

Commit 787484c

Browse files
committed
fix(core): preserve xcstrings languages and parse single-lang csv/tsv headers
1 parent cc2ecd8 commit 787484c

6 files changed

Lines changed: 198 additions & 51 deletions

File tree

langcodec-cli/tests/edit_cli_tests.rs

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,3 +368,86 @@ fn test_edit_set_continue_on_error() {
368368
let updated = fs::read_to_string(&good).unwrap();
369369
assert!(updated.contains("\"welcome\""));
370370
}
371+
372+
#[test]
373+
fn test_edit_set_xcstrings_with_lang_preserves_other_languages() {
374+
let temp_dir = TempDir::new().unwrap();
375+
let input_file = temp_dir.path().join("Localizable.xcstrings");
376+
let initial = r#"{
377+
"sourceLanguage": "en",
378+
"version": "1.0",
379+
"strings": {
380+
"hello": {
381+
"localizations": {
382+
"en": {
383+
"stringUnit": {
384+
"state": "translated",
385+
"value": "Hello"
386+
}
387+
},
388+
"fr": {
389+
"stringUnit": {
390+
"state": "translated",
391+
"value": "Bonjour"
392+
}
393+
}
394+
}
395+
},
396+
"bye": {
397+
"localizations": {
398+
"en": {
399+
"stringUnit": {
400+
"state": "translated",
401+
"value": "Bye"
402+
}
403+
},
404+
"fr": {
405+
"stringUnit": {
406+
"state": "translated",
407+
"value": "Au revoir"
408+
}
409+
}
410+
}
411+
}
412+
}
413+
}
414+
"#;
415+
fs::write(&input_file, initial).unwrap();
416+
417+
let out = langcodec_cmd()
418+
.args([
419+
"edit",
420+
"set",
421+
"-i",
422+
input_file.to_str().unwrap(),
423+
"--lang",
424+
"en",
425+
"-k",
426+
"hello",
427+
"-v",
428+
"Hello Updated",
429+
])
430+
.output()
431+
.unwrap();
432+
assert!(
433+
out.status.success(),
434+
"stderr: {}",
435+
String::from_utf8_lossy(&out.stderr)
436+
);
437+
438+
let content = fs::read_to_string(&input_file).unwrap();
439+
let payload: serde_json::Value = serde_json::from_str(&content).unwrap();
440+
441+
assert_eq!(
442+
payload["strings"]["hello"]["localizations"]["en"]["stringUnit"]["value"],
443+
"Hello Updated"
444+
);
445+
assert_eq!(
446+
payload["strings"]["hello"]["localizations"]["fr"]["stringUnit"]["value"],
447+
"Bonjour"
448+
);
449+
assert_eq!(
450+
payload["strings"]["bye"]["localizations"]["fr"]["stringUnit"]["value"],
451+
"Au revoir"
452+
);
453+
}

langcodec/src/codec.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1081,7 +1081,7 @@ impl Codec {
10811081
};
10821082

10831083
for new_resource in &mut new_resources {
1084-
if let Some(ref lang) = language {
1084+
if requires_language && let Some(ref lang) = language {
10851085
new_resource.metadata.language = lang.clone();
10861086
}
10871087
new_resource.metadata.domain = domain.clone();

langcodec/src/formats/csv.rs

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -95,24 +95,42 @@ impl Parser for Format {
9595
let first_line = first_line.map_err(Error::CsvParse)?;
9696

9797
if first_line.len() == 2 {
98-
// Single language format: key, value
99-
// First line is data, not header
100-
records.push(MultiLanguageCSVRecord {
101-
key: first_line[0].to_string(),
102-
translations: {
103-
let mut map = HashMap::new();
104-
map.insert("default".to_string(), first_line[1].to_string());
105-
map
106-
},
107-
});
108-
109-
// Process remaining lines
110-
for line in lines {
111-
let line = line.map_err(Error::CsvParse)?;
112-
if line.len() == 2 {
113-
let mut record = MultiLanguageCSVRecord::new(line[0].to_string());
114-
record.add_translation("default".to_string(), line[1].to_string());
115-
records.push(record);
98+
if first_line[0].trim().eq_ignore_ascii_case("key") {
99+
// Single-language header form: key, <lang>
100+
let language = first_line[1].trim().to_string();
101+
if language.is_empty() {
102+
return Err(Error::DataMismatch(
103+
"Invalid CSV format: missing language in header".to_string(),
104+
));
105+
}
106+
for line in lines {
107+
let line = line.map_err(Error::CsvParse)?;
108+
if line.len() == 2 {
109+
let mut record = MultiLanguageCSVRecord::new(line[0].to_string());
110+
record.add_translation(language.clone(), line[1].to_string());
111+
records.push(record);
112+
}
113+
}
114+
} else {
115+
// Single language data form: key, value
116+
// First line is data, not header
117+
records.push(MultiLanguageCSVRecord {
118+
key: first_line[0].to_string(),
119+
translations: {
120+
let mut map = HashMap::new();
121+
map.insert("default".to_string(), first_line[1].to_string());
122+
map
123+
},
124+
});
125+
126+
// Process remaining lines
127+
for line in lines {
128+
let line = line.map_err(Error::CsvParse)?;
129+
if line.len() == 2 {
130+
let mut record = MultiLanguageCSVRecord::new(line[0].to_string());
131+
record.add_translation("default".to_string(), line[1].to_string());
132+
records.push(record);
133+
}
116134
}
117135
}
118136
} else if first_line.len() >= 3 {
@@ -400,6 +418,25 @@ mod tests {
400418
);
401419
}
402420

421+
#[test]
422+
fn test_parse_single_language_header_csv() {
423+
let csv_content = "key,en\nhello,Hello\nbye,Goodbye\n";
424+
let format = Format::from_reader(Cursor::new(csv_content)).unwrap();
425+
assert_eq!(format.records.len(), 2);
426+
assert_eq!(
427+
format.records[0].get_translation("en"),
428+
Some(&"Hello".to_string())
429+
);
430+
assert_eq!(
431+
format.records[1].get_translation("en"),
432+
Some(&"Goodbye".to_string())
433+
);
434+
435+
let resources = Vec::<Resource>::try_from(format).unwrap();
436+
assert_eq!(resources.len(), 1);
437+
assert_eq!(resources[0].metadata.language, "en");
438+
}
439+
403440
#[test]
404441
fn test_multi_language_csv_to_resources() {
405442
let csv_content = "key,en,cn\nhello,Hello,你好\nbye,Goodbye,再见\n";

langcodec/src/formats/tsv.rs

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -96,24 +96,42 @@ impl Parser for Format {
9696
let first_line = first_line.map_err(Error::CsvParse)?;
9797

9898
if first_line.len() == 2 {
99-
// Single language format: key, value
100-
// First line is data, not header
101-
records.push(MultiLanguageTSVRecord {
102-
key: first_line[0].to_string(),
103-
translations: {
104-
let mut map = HashMap::new();
105-
map.insert("default".to_string(), first_line[1].to_string());
106-
map
107-
},
108-
});
109-
110-
// Process remaining lines
111-
for line in lines {
112-
let line = line.map_err(Error::CsvParse)?;
113-
if line.len() == 2 {
114-
let mut record = MultiLanguageTSVRecord::new(line[0].to_string());
115-
record.add_translation("default".to_string(), line[1].to_string());
116-
records.push(record);
99+
if first_line[0].trim().eq_ignore_ascii_case("key") {
100+
// Single-language header form: key, <lang>
101+
let language = first_line[1].trim().to_string();
102+
if language.is_empty() {
103+
return Err(Error::DataMismatch(
104+
"Invalid TSV format: missing language in header".to_string(),
105+
));
106+
}
107+
for line in lines {
108+
let line = line.map_err(Error::CsvParse)?;
109+
if line.len() == 2 {
110+
let mut record = MultiLanguageTSVRecord::new(line[0].to_string());
111+
record.add_translation(language.clone(), line[1].to_string());
112+
records.push(record);
113+
}
114+
}
115+
} else {
116+
// Single language data form: key, value
117+
// First line is data, not header
118+
records.push(MultiLanguageTSVRecord {
119+
key: first_line[0].to_string(),
120+
translations: {
121+
let mut map = HashMap::new();
122+
map.insert("default".to_string(), first_line[1].to_string());
123+
map
124+
},
125+
});
126+
127+
// Process remaining lines
128+
for line in lines {
129+
let line = line.map_err(Error::CsvParse)?;
130+
if line.len() == 2 {
131+
let mut record = MultiLanguageTSVRecord::new(line[0].to_string());
132+
record.add_translation("default".to_string(), line[1].to_string());
133+
records.push(record);
134+
}
117135
}
118136
}
119137
} else if first_line.len() >= 3 {
@@ -428,6 +446,25 @@ mod tests {
428446
);
429447
}
430448

449+
#[test]
450+
fn test_parse_single_language_header_tsv() {
451+
let tsv_content = "key\ten\nhello\tHello\nbye\tGoodbye\n";
452+
let format = Format::from_reader(Cursor::new(tsv_content)).unwrap();
453+
assert_eq!(format.records.len(), 2);
454+
assert_eq!(
455+
format.records[0].get_translation("en"),
456+
Some(&"Hello".to_string())
457+
);
458+
assert_eq!(
459+
format.records[1].get_translation("en"),
460+
Some(&"Goodbye".to_string())
461+
);
462+
463+
let resources = Vec::<Resource>::try_from(format).unwrap();
464+
assert_eq!(resources.len(), 1);
465+
assert_eq!(resources[0].metadata.language, "en");
466+
}
467+
431468
#[test]
432469
fn test_multi_language_tsv_to_resources() {
433470
let tsv_content = "key\ten\tcn\nhello\tHello\t你好\nbye\tGoodbye\t再见\n";

langcodec/src/read_options.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
/// Read behavior options for [`crate::Codec`] file-loading APIs.
44
#[derive(Debug, Clone, PartialEq, Eq, Default)]
55
pub struct ReadOptions {
6-
/// Optional language hint applied to all loaded resources.
6+
/// Optional language hint used for single-language formats (e.g. `.strings`, `strings.xml`).
77
pub language_hint: Option<String>,
88
/// Enables stricter checks (e.g., language is required for single-language formats).
99
pub strict: bool,

langcodec/tests/corpus_regression_tests.rs

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,6 @@ fn expected_fr_stable_values() -> Vec<ExpectedValue> {
8383
]
8484
}
8585

86-
fn with_language(
87-
expected_values: Vec<ExpectedValue>,
88-
language: &'static str,
89-
) -> Vec<ExpectedValue> {
90-
expected_values
91-
.into_iter()
92-
.map(|item| ExpectedValue { language, ..item })
93-
.collect()
94-
}
95-
9686
fn read_codec(path: &Path, lang_hint: Option<&str>) -> Codec {
9787
let mut codec = Codec::new();
9888
codec
@@ -199,7 +189,7 @@ fn convert_edge_case_corpora_table_driven() {
199189
let root = corpus_root();
200190
let output_dir = tempfile::tempdir().expect("create temp output dir");
201191

202-
let csv_default_expected = with_language(expected_en_stable_values(), "default");
192+
let csv_single_lang_expected = expected_en_stable_values();
203193

204194
let convert_cases = vec![
205195
ConvertCase {
@@ -221,14 +211,14 @@ fn convert_edge_case_corpora_table_driven() {
221211
input_relative_path: "en.lproj/Localizable.strings",
222212
output_file_name: "from_strings.csv",
223213
output_lang_hint: None,
224-
expected_values: csv_default_expected.clone(),
214+
expected_values: csv_single_lang_expected.clone(),
225215
},
226216
ConvertCase {
227217
name: "strings -> tsv",
228218
input_relative_path: "en.lproj/Localizable.strings",
229219
output_file_name: "from_strings.tsv",
230220
output_lang_hint: None,
231-
expected_values: csv_default_expected,
221+
expected_values: csv_single_lang_expected,
232222
},
233223
ConvertCase {
234224
name: "android -> strings",

0 commit comments

Comments
 (0)