Skip to content

Commit 1c0bc92

Browse files
committed
feat(cli): add json output for view status filters
1 parent a62468b commit 1c0bc92

2 files changed

Lines changed: 228 additions & 7 deletions

File tree

langcodec-cli/src/view.rs

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
use langcodec::{Codec, types::EntryStatus};
2-
use std::collections::{BTreeMap, HashSet};
1+
use langcodec::{
2+
Codec,
3+
types::{EntryStatus, PluralCategory, Translation},
4+
};
5+
use serde_json::{Map, Value, json};
6+
use std::collections::{BTreeMap, BTreeSet, HashSet};
37

48
pub struct ViewOptions {
59
pub full: bool,
@@ -59,10 +63,125 @@ fn truncate_chars(s: &str, max_chars: usize) -> String {
5963
}
6064
}
6165

66+
fn status_label(status: &EntryStatus) -> &'static str {
67+
match status {
68+
EntryStatus::DoNotTranslate => "do_not_translate",
69+
EntryStatus::New => "new",
70+
EntryStatus::Stale => "stale",
71+
EntryStatus::NeedsReview => "needs_review",
72+
EntryStatus::Translated => "translated",
73+
}
74+
}
75+
76+
fn plural_category_label(category: &PluralCategory) -> &'static str {
77+
match category {
78+
PluralCategory::Zero => "zero",
79+
PluralCategory::One => "one",
80+
PluralCategory::Two => "two",
81+
PluralCategory::Few => "few",
82+
PluralCategory::Many => "many",
83+
PluralCategory::Other => "other",
84+
}
85+
}
86+
87+
fn render_json_output(
88+
filtered_resources: &[(&langcodec::Resource, Vec<&langcodec::types::Entry>)],
89+
lang_filter: &Option<String>,
90+
keys_only: bool,
91+
) -> Result<String, String> {
92+
let mut total_matches = 0usize;
93+
let mut languages = BTreeSet::new();
94+
let mut status_counts: BTreeMap<String, usize> = BTreeMap::new();
95+
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();
98+
99+
for (resource, entries) in filtered_resources {
100+
languages.insert(resource.metadata.language.clone());
101+
102+
for entry in entries {
103+
total_matches += 1;
104+
let status = status_label(&entry.status).to_string();
105+
*status_counts.entry(status.clone()).or_insert(0) += 1;
106+
107+
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+
}
116+
continue;
117+
}
118+
119+
let mut entry_json = Map::new();
120+
entry_json.insert("lang".to_string(), json!(resource.metadata.language));
121+
entry_json.insert("key".to_string(), json!(entry.id));
122+
entry_json.insert("status".to_string(), json!(status));
123+
entry_json.insert("domain".to_string(), json!(resource.metadata.domain));
124+
125+
match &entry.value {
126+
Translation::Empty => {
127+
entry_json.insert("type".to_string(), json!("empty"));
128+
}
129+
Translation::Singular(value) => {
130+
entry_json.insert("type".to_string(), json!("singular"));
131+
entry_json.insert("value".to_string(), json!(value));
132+
}
133+
Translation::Plural(plural) => {
134+
entry_json.insert("type".to_string(), json!("plural"));
135+
entry_json.insert("plural_id".to_string(), json!(plural.id));
136+
let mut forms = Map::new();
137+
for (category, value) in &plural.forms {
138+
forms.insert(plural_category_label(category).to_string(), json!(value));
139+
}
140+
entry_json.insert("forms".to_string(), Value::Object(forms));
141+
}
142+
}
143+
144+
if let Some(comment) = &entry.comment {
145+
entry_json.insert("comment".to_string(), json!(comment));
146+
}
147+
148+
entries_payload.push(Value::Object(entry_json));
149+
}
150+
}
151+
152+
let summary = json!({
153+
"total_matches": total_matches,
154+
"languages": languages.into_iter().collect::<Vec<_>>(),
155+
"statuses": status_counts,
156+
});
157+
158+
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+
}
170+
} else {
171+
json!({
172+
"summary": summary,
173+
"entries": entries_payload,
174+
})
175+
};
176+
177+
serde_json::to_string_pretty(&payload)
178+
.map_err(|e| format!("Failed to render view JSON payload: {e}"))
179+
}
180+
62181
/// Print a view of the resources in a codec.
63182
pub fn print_view(codec: &Codec, lang_filter: &Option<String>, opts: &ViewOptions) {
64183
let keys_only_text = opts.keys_only && !opts.json;
65-
if !keys_only_text {
184+
if !keys_only_text && !opts.json {
66185
println!("Processing resources...");
67186
}
68187
let status_filter = match parse_status_filter(&opts.status) {
@@ -107,7 +226,7 @@ pub fn print_view(codec: &Codec, lang_filter: &Option<String>, opts: &ViewOption
107226
std::process::exit(1);
108227
}
109228

110-
if !keys_only_text {
229+
if !keys_only_text && !opts.json {
111230
println!("✅ Found {} resource(s)", resources.len());
112231
}
113232

@@ -127,6 +246,18 @@ pub fn print_view(codec: &Codec, lang_filter: &Option<String>, opts: &ViewOption
127246
})
128247
.collect::<Vec<_>>();
129248

249+
if opts.json {
250+
let rendered = match render_json_output(&filtered_resources, lang_filter, opts.keys_only) {
251+
Ok(text) => text,
252+
Err(err) => {
253+
eprintln!("❌ {}", err);
254+
std::process::exit(1);
255+
}
256+
};
257+
println!("{}", rendered);
258+
return;
259+
}
260+
130261
if keys_only_text {
131262
let include_lang_prefix = lang_filter.is_none();
132263
for (resource, entries) in &filtered_resources {
@@ -156,10 +287,10 @@ pub fn print_view(codec: &Codec, lang_filter: &Option<String>, opts: &ViewOption
156287
}
157288

158289
match &entry.value {
159-
langcodec::types::Translation::Empty => {
290+
Translation::Empty => {
160291
println!(" Type: Empty");
161292
}
162-
langcodec::types::Translation::Singular(value) => {
293+
Translation::Singular(value) => {
163294
println!(" Type: Singular");
164295
if opts.full {
165296
println!(" Value: {}", value);
@@ -168,7 +299,7 @@ pub fn print_view(codec: &Codec, lang_filter: &Option<String>, opts: &ViewOption
168299
println!(" Value: {}", truncated);
169300
}
170301
}
171-
langcodec::types::Translation::Plural(plural) => {
302+
Translation::Plural(plural) => {
172303
println!(" Type: Plural");
173304
println!(" Plural ID: {}", plural.id);
174305
for (category, value) in &plural.forms {

langcodec-cli/tests/view_status_cli_tests.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,3 +349,93 @@ fn test_view_keys_only_without_lang_prints_lang_tab_key() {
349349
stdout
350350
);
351351
}
352+
353+
#[test]
354+
fn test_view_status_json_outputs_entries_payload() {
355+
let temp_dir = TempDir::new().unwrap();
356+
let input_file = temp_dir.path().join("Localizable.xcstrings");
357+
write_xcstrings_multilang_fixture(&input_file);
358+
359+
let output = langcodec_cmd()
360+
.args([
361+
"view",
362+
"-i",
363+
input_file.to_str().unwrap(),
364+
"--status",
365+
"needs_review",
366+
"--json",
367+
])
368+
.output()
369+
.unwrap();
370+
371+
assert!(
372+
output.status.success(),
373+
"CLI failed: {}",
374+
String::from_utf8_lossy(&output.stderr)
375+
);
376+
377+
let stdout = String::from_utf8_lossy(&output.stdout);
378+
let payload: serde_json::Value = serde_json::from_str(&stdout)
379+
.unwrap_or_else(|e| panic!("Expected JSON output. parse error: {e}. stdout: {stdout}"));
380+
381+
assert_eq!(payload["summary"]["total_matches"], 2);
382+
assert_eq!(payload["summary"]["statuses"]["needs_review"], 2);
383+
let languages = payload["summary"]["languages"].as_array().unwrap();
384+
assert_eq!(languages.len(), 2, "Expected 2 languages. payload: {payload}");
385+
assert!(languages.iter().any(|lang| lang == "en"));
386+
assert!(languages.iter().any(|lang| lang == "fr"));
387+
388+
let entries = payload["entries"].as_array().unwrap();
389+
assert_eq!(entries.len(), 2, "Expected only filtered entries. payload: {payload}");
390+
391+
let en_entry = entries
392+
.iter()
393+
.find(|entry| entry["lang"] == "en")
394+
.expect("Expected English entry in JSON payload");
395+
assert_eq!(en_entry["key"], "needs_review_key");
396+
assert_eq!(en_entry["status"], "needs_review");
397+
assert_eq!(en_entry["type"], "singular");
398+
assert_eq!(en_entry["value"], "Needs review EN");
399+
}
400+
401+
#[test]
402+
fn test_view_status_json_keys_only_outputs_keys_payload() {
403+
let temp_dir = TempDir::new().unwrap();
404+
let input_file = temp_dir.path().join("Localizable.xcstrings");
405+
write_xcstrings_multilang_fixture(&input_file);
406+
407+
let output = langcodec_cmd()
408+
.args([
409+
"view",
410+
"-i",
411+
input_file.to_str().unwrap(),
412+
"--status",
413+
"needs_review",
414+
"--keys-only",
415+
"--json",
416+
])
417+
.output()
418+
.unwrap();
419+
420+
assert!(
421+
output.status.success(),
422+
"CLI failed: {}",
423+
String::from_utf8_lossy(&output.stderr)
424+
);
425+
426+
let stdout = String::from_utf8_lossy(&output.stdout);
427+
let payload: serde_json::Value = serde_json::from_str(&stdout)
428+
.unwrap_or_else(|e| panic!("Expected JSON output. parse error: {e}. stdout: {stdout}"));
429+
430+
assert_eq!(payload["summary"]["total_matches"], 2);
431+
assert_eq!(payload["summary"]["statuses"]["needs_review"], 2);
432+
assert!(payload.get("entries").is_none(), "Expected keys-only payload");
433+
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");
441+
}

0 commit comments

Comments
 (0)