Skip to content

Commit d297df0

Browse files
committed
fix(cli): stabilize view json summary and keys schema
1 parent 1c0bc92 commit d297df0

2 files changed

Lines changed: 119 additions & 32 deletions

File tree

langcodec-cli/src/view.rs

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -86,33 +86,26 @@ fn plural_category_label(category: &PluralCategory) -> &'static str {
8686

8787
fn render_json_output(
8888
filtered_resources: &[(&langcodec::Resource, Vec<&langcodec::types::Entry>)],
89-
lang_filter: &Option<String>,
9089
keys_only: bool,
9190
) -> Result<String, String> {
9291
let mut total_matches = 0usize;
9392
let mut languages = BTreeSet::new();
9493
let mut status_counts: BTreeMap<String, usize> = BTreeMap::new();
9594
let mut entries_payload = Vec::new();
96-
let mut keys_by_language: BTreeMap<String, Vec<String>> = BTreeMap::new();
97-
let mut keys_for_lang = Vec::new();
95+
let mut keys_payload = Vec::new();
9896

9997
for (resource, entries) in filtered_resources {
100-
languages.insert(resource.metadata.language.clone());
101-
10298
for entry in entries {
99+
languages.insert(resource.metadata.language.clone());
103100
total_matches += 1;
104101
let status = status_label(&entry.status).to_string();
105102
*status_counts.entry(status.clone()).or_insert(0) += 1;
106103

107104
if keys_only {
108-
if lang_filter.is_some() {
109-
keys_for_lang.push(entry.id.clone());
110-
} else {
111-
keys_by_language
112-
.entry(resource.metadata.language.clone())
113-
.or_default()
114-
.push(entry.id.clone());
115-
}
105+
keys_payload.push(json!({
106+
"lang": resource.metadata.language,
107+
"key": entry.id,
108+
}));
116109
continue;
117110
}
118111

@@ -156,17 +149,10 @@ fn render_json_output(
156149
});
157150

158151
let payload = if keys_only {
159-
if lang_filter.is_some() {
160-
json!({
161-
"summary": summary,
162-
"keys": keys_for_lang,
163-
})
164-
} else {
165-
json!({
166-
"summary": summary,
167-
"keys": keys_by_language,
168-
})
169-
}
152+
json!({
153+
"summary": summary,
154+
"keys": keys_payload,
155+
})
170156
} else {
171157
json!({
172158
"summary": summary,
@@ -247,7 +233,7 @@ pub fn print_view(codec: &Codec, lang_filter: &Option<String>, opts: &ViewOption
247233
.collect::<Vec<_>>();
248234

249235
if opts.json {
250-
let rendered = match render_json_output(&filtered_resources, lang_filter, opts.keys_only) {
236+
let rendered = match render_json_output(&filtered_resources, opts.keys_only) {
251237
Ok(text) => text,
252238
Err(err) => {
253239
eprintln!("❌ {}", err);

langcodec-cli/tests/view_status_cli_tests.rs

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,34 @@ fn write_xcstrings_multilang_fixture(path: &std::path::Path) {
8282
fs::write(path, xcstrings).unwrap();
8383
}
8484

85+
fn write_xcstrings_partial_match_fixture(path: &std::path::Path) {
86+
let xcstrings = r#"{
87+
"sourceLanguage": "en",
88+
"version": "1.0",
89+
"strings": {
90+
"needs_review_key": {
91+
"localizations": {
92+
"en": {
93+
"stringUnit": {
94+
"state": "needs_review",
95+
"value": "Needs review EN"
96+
}
97+
},
98+
"fr": {
99+
"stringUnit": {
100+
"state": "translated",
101+
"value": "Besoin de revision FR"
102+
}
103+
}
104+
}
105+
}
106+
}
107+
}
108+
"#;
109+
110+
fs::write(path, xcstrings).unwrap();
111+
}
112+
85113
#[test]
86114
fn test_view_status_filters_single_status_for_lang() {
87115
let temp_dir = TempDir::new().unwrap();
@@ -431,11 +459,84 @@ fn test_view_status_json_keys_only_outputs_keys_payload() {
431459
assert_eq!(payload["summary"]["statuses"]["needs_review"], 2);
432460
assert!(payload.get("entries").is_none(), "Expected keys-only payload");
433461

434-
let keys = payload["keys"].as_object().unwrap();
435-
let en_keys = keys["en"].as_array().unwrap();
436-
let fr_keys = keys["fr"].as_array().unwrap();
437-
assert_eq!(en_keys.len(), 1);
438-
assert_eq!(fr_keys.len(), 1);
439-
assert_eq!(en_keys[0], "needs_review_key");
440-
assert_eq!(fr_keys[0], "needs_review_key");
462+
let keys = payload["keys"].as_array().unwrap();
463+
assert_eq!(keys.len(), 2);
464+
assert!(
465+
keys.iter()
466+
.any(|item| item["lang"] == "en" && item["key"] == "needs_review_key")
467+
);
468+
assert!(
469+
keys.iter()
470+
.any(|item| item["lang"] == "fr" && item["key"] == "needs_review_key")
471+
);
472+
}
473+
474+
#[test]
475+
fn test_view_status_json_excludes_zero_match_languages_in_summary() {
476+
let temp_dir = TempDir::new().unwrap();
477+
let input_file = temp_dir.path().join("Localizable.xcstrings");
478+
write_xcstrings_partial_match_fixture(&input_file);
479+
480+
let output = langcodec_cmd()
481+
.args([
482+
"view",
483+
"-i",
484+
input_file.to_str().unwrap(),
485+
"--status",
486+
"needs_review",
487+
"--json",
488+
])
489+
.output()
490+
.unwrap();
491+
492+
assert!(
493+
output.status.success(),
494+
"CLI failed: {}",
495+
String::from_utf8_lossy(&output.stderr)
496+
);
497+
498+
let stdout = String::from_utf8_lossy(&output.stdout);
499+
let payload: serde_json::Value = serde_json::from_str(&stdout)
500+
.unwrap_or_else(|e| panic!("Expected JSON output. parse error: {e}. stdout: {stdout}"));
501+
502+
let languages = payload["summary"]["languages"].as_array().unwrap();
503+
assert_eq!(languages.len(), 1);
504+
assert_eq!(languages[0], "en");
505+
}
506+
507+
#[test]
508+
fn test_view_status_json_keys_only_lang_uses_consistent_object_schema() {
509+
let temp_dir = TempDir::new().unwrap();
510+
let input_file = temp_dir.path().join("Localizable.xcstrings");
511+
write_xcstrings_multilang_fixture(&input_file);
512+
513+
let output = langcodec_cmd()
514+
.args([
515+
"view",
516+
"-i",
517+
input_file.to_str().unwrap(),
518+
"--lang",
519+
"en",
520+
"--status",
521+
"needs_review",
522+
"--keys-only",
523+
"--json",
524+
])
525+
.output()
526+
.unwrap();
527+
528+
assert!(
529+
output.status.success(),
530+
"CLI failed: {}",
531+
String::from_utf8_lossy(&output.stderr)
532+
);
533+
534+
let stdout = String::from_utf8_lossy(&output.stdout);
535+
let payload: serde_json::Value = serde_json::from_str(&stdout)
536+
.unwrap_or_else(|e| panic!("Expected JSON output. parse error: {e}. stdout: {stdout}"));
537+
538+
let keys = payload["keys"].as_array().unwrap();
539+
assert!(!keys.is_empty(), "Expected at least one key object");
540+
assert!(keys.iter().all(|item| item["lang"] == "en"));
541+
assert!(keys.iter().all(|item| item["key"] == "needs_review_key"));
441542
}

0 commit comments

Comments
 (0)