Skip to content

Commit d44458b

Browse files
committed
Add stats command to show translation coverage
Introduces a new `stats` subcommand to the CLI for displaying translation coverage and per-status counts, with support for JSON output. Implements the core logic in a new `stats.rs` module and adds integration tests for the feature.
1 parent 7e65347 commit d44458b

4 files changed

Lines changed: 207 additions & 2 deletions

File tree

ROADMAP.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Legend: [ ] todo, [x] done, [~] in progress
1111
- [x] Symmetric language matching for multi‑language formats (`xcstrings`, `csv`, `tsv`)
1212
- [x] CLI view prints “Type: Plural” and plural categories
1313
- [x] Conversion tests: CSV→Android, XCStrings→Android (with plurals)
14+
- [x] CLI `stats` subcommand (per-language counts, completion %, JSON output)
1415

1516
---
1617

@@ -59,8 +60,10 @@ For each new format:
5960
- [ ] `diff` subcommand
6061
- [ ] Compare two files; output added/removed/changed keys by language
6162
- [ ] Machine‑readable JSON output and pretty mode
62-
- [ ] `stats` subcommand
63-
- [ ] Per‑language counts by `EntryStatus`, completion %, missing plurals
63+
- [x] `stats` subcommand
64+
- [x] Per‑language counts by `EntryStatus`
65+
- [x] Completion percent (excludes DoNotTranslate)
66+
- [ ] Missing plurals (depends on plural rules engine in M1)
6467
- [ ] `normalize` subcommand
6568
- [ ] Canonicalize whitespace, escapes, key casing; optional rules
6669
- [ ] Filters and export

langcodec-cli/src/main.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod path_glob;
66
mod transformers;
77
mod validation;
88
mod view;
9+
mod stats;
910

1011
use crate::convert::{ConvertOptions, run_unified_convert_command, try_custom_format_view};
1112
use crate::debug::run_debug_command;
@@ -101,6 +102,19 @@ enum Commands {
101102
version: Option<String>,
102103
},
103104

105+
/// Show translation coverage and per-status counts.
106+
Stats {
107+
/// The input file to analyze
108+
#[arg(short, long)]
109+
input: String,
110+
/// Optional language code to filter by
111+
#[arg(short, long)]
112+
lang: Option<String>,
113+
/// Output JSON instead of human-readable text
114+
#[arg(long)]
115+
json: bool,
116+
},
117+
104118
/// Debug: Read a localization file and output as JSON.
105119
Debug {
106120
/// The input file to debug
@@ -287,5 +301,34 @@ fn main() {
287301
cmd = cmd.bin_name("langcodec");
288302
generate(shell, &mut cmd, "langcodec", &mut std::io::stdout());
289303
}
304+
Commands::Stats { input, lang, json } => {
305+
// Validate
306+
let mut context = ValidationContext::new().with_input_file(input.clone());
307+
if let Some(l) = &lang { context = context.with_language_code(l.clone()); }
308+
if let Err(e) = validate_context(&context) {
309+
eprintln!("❌ Validation failed: {}", e);
310+
std::process::exit(1);
311+
}
312+
313+
// Load file using the same logic as view
314+
let mut codec = Codec::new();
315+
if let Ok(()) = codec.read_file_by_extension(&input, lang.clone()) {
316+
// ok
317+
} else if input.ends_with(".json")
318+
|| input.ends_with(".yaml")
319+
|| input.ends_with(".yml")
320+
|| input.ends_with(".langcodec")
321+
{
322+
if let Err(e) = try_custom_format_view(&input, lang.clone(), &mut codec) {
323+
eprintln!("Failed to read file: {}", e);
324+
std::process::exit(1);
325+
}
326+
} else {
327+
eprintln!("Failed to read file: unsupported format");
328+
std::process::exit(1);
329+
}
330+
331+
stats::print_stats(&codec, &lang, json);
332+
}
290333
}
291334
}

langcodec-cli/src/stats.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use langcodec::{Codec, types::EntryStatus};
2+
use serde_json::json;
3+
use std::collections::HashMap;
4+
5+
#[derive(Default)]
6+
struct LangStats {
7+
total: usize,
8+
by_status: HashMap<&'static str, usize>,
9+
translated: usize,
10+
denominator: usize,
11+
}
12+
13+
fn accumulate(lang_stats: &mut LangStats, status: &EntryStatus) {
14+
lang_stats.total += 1;
15+
let key: &'static str = match status {
16+
EntryStatus::DoNotTranslate => "do_not_translate",
17+
EntryStatus::New => "new",
18+
EntryStatus::Stale => "stale",
19+
EntryStatus::NeedsReview => "needs_review",
20+
EntryStatus::Translated => "translated",
21+
};
22+
*lang_stats.by_status.entry(key).or_insert(0) += 1;
23+
if matches!(status, EntryStatus::Translated) {
24+
lang_stats.translated += 1;
25+
}
26+
if !matches!(status, EntryStatus::DoNotTranslate) {
27+
lang_stats.denominator += 1;
28+
}
29+
}
30+
31+
pub fn print_stats(codec: &Codec, lang_filter: &Option<String>, json_output: bool) {
32+
let resources: Vec<_> = match lang_filter {
33+
Some(lang) => codec.resources.iter().filter(|r| r.metadata.language == *lang).collect(),
34+
None => codec.resources.iter().collect(),
35+
};
36+
37+
if json_output {
38+
// Build JSON object
39+
let mut per_lang = Vec::new();
40+
for res in &resources {
41+
let mut stats = LangStats::default();
42+
for e in &res.entries {
43+
accumulate(&mut stats, &e.status);
44+
}
45+
let percent = if stats.denominator == 0 {
46+
100.0
47+
} else {
48+
(stats.translated as f64) * 100.0 / (stats.denominator as f64)
49+
};
50+
per_lang.push(json!({
51+
"language": res.metadata.language,
52+
"total": stats.total,
53+
"by_status": stats.by_status,
54+
"completion_percent": (percent * 100.0).round() / 100.0,
55+
}));
56+
}
57+
let summary = json!({
58+
"languages": resources.len(),
59+
"unique_keys": codec.all_keys().count(),
60+
});
61+
let body = json!({
62+
"summary": summary,
63+
"languages": per_lang,
64+
});
65+
println!("{}", serde_json::to_string_pretty(&body).unwrap());
66+
return;
67+
}
68+
69+
println!("=== Stats ===");
70+
println!("Languages: {}", resources.len());
71+
println!("Unique keys: {}", codec.all_keys().count());
72+
73+
for res in &resources {
74+
let mut stats = LangStats::default();
75+
for e in &res.entries {
76+
accumulate(&mut stats, &e.status);
77+
}
78+
let percent = if stats.denominator == 0 {
79+
100.0
80+
} else {
81+
(stats.translated as f64) * 100.0 / (stats.denominator as f64)
82+
};
83+
println!("\nLanguage: {}", res.metadata.language);
84+
println!(" Total: {}", stats.total);
85+
println!(" By status:");
86+
for (k, v) in [
87+
("translated", stats.by_status.get("translated").copied().unwrap_or(0)),
88+
(
89+
"needs_review",
90+
stats.by_status.get("needs_review").copied().unwrap_or(0),
91+
),
92+
("stale", stats.by_status.get("stale").copied().unwrap_or(0)),
93+
("new", stats.by_status.get("new").copied().unwrap_or(0)),
94+
(
95+
"do_not_translate",
96+
stats
97+
.by_status
98+
.get("do_not_translate")
99+
.copied()
100+
.unwrap_or(0),
101+
),
102+
] {
103+
println!(" {}: {}", k, v);
104+
}
105+
println!(" Completion: {:.2}%", percent);
106+
}
107+
}
108+
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use std::fs;
2+
use std::process::Command;
3+
use tempfile::TempDir;
4+
5+
#[test]
6+
fn test_stats_json_on_android_strings() {
7+
let temp_dir = TempDir::new().unwrap();
8+
let values_dir = temp_dir.path().join("values");
9+
fs::create_dir_all(&values_dir).unwrap();
10+
let input_file = values_dir.join("strings.xml");
11+
12+
let xml = r#"
13+
<resources>
14+
<string name="a">Hello</string>
15+
<string name="b" translatable="false">Ignored</string>
16+
<string name="c"></string>
17+
</resources>
18+
"#;
19+
fs::write(&input_file, xml).unwrap();
20+
21+
let output = Command::new("cargo")
22+
.args([
23+
"run",
24+
"--quiet",
25+
"--",
26+
"stats",
27+
"-i",
28+
input_file.to_str().unwrap(),
29+
"--json",
30+
])
31+
.output()
32+
.unwrap();
33+
34+
assert!(
35+
output.status.success(),
36+
"CLI failed: {}",
37+
String::from_utf8_lossy(&output.stderr)
38+
);
39+
40+
let stdout = String::from_utf8_lossy(&output.stdout);
41+
let v: serde_json::Value = serde_json::from_str(&stdout).unwrap();
42+
// Expect 1 language
43+
assert_eq!(v["summary"]["languages"], 1);
44+
let langs = v["languages"].as_array().unwrap();
45+
assert_eq!(langs.len(), 1);
46+
let by_status = &langs[0]["by_status"];
47+
assert_eq!(by_status["translated"], 1);
48+
assert_eq!(by_status["do_not_translate"], 1);
49+
assert_eq!(by_status["new"], 1);
50+
}
51+

0 commit comments

Comments
 (0)