use anyhow::{Context, Result};
use glob::glob;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub fn verify(
translation_files: &[PathBuf],
source_locale: Option<&str>,
ignore: &[String],
missing_keys: bool,
extra_keys: bool,
structural_equality: bool,
) -> Result<()> {
let source_locale = source_locale.context("--source-locale is required for verify command")?;
let mut expanded_files = Vec::new();
for pattern in translation_files {
let pattern_str = pattern
.to_str()
.context("Pattern path contains invalid UTF-8")?;
match glob(pattern_str) {
Ok(paths) => {
for entry in paths {
match entry {
Ok(path) => expanded_files.push(path),
Err(e) => eprintln!("Warning: Failed to read glob entry: {}", e),
}
}
}
Err(e) => {
eprintln!("Warning: Invalid glob pattern '{}': {}", pattern_str, e);
expanded_files.push(pattern.clone());
}
}
}
let mut locales: HashMap<String, Value> = HashMap::new();
for file in &expanded_files {
if should_ignore(file, ignore) {
continue;
}
let locale = extract_locale_from_path(file)?;
let content = std::fs::read_to_string(file)
.with_context(|| format!("Failed to read file: {}", file.display()))?;
let json: Value = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON in file: {}", file.display()))?;
locales.insert(locale, json);
}
if !locales.contains_key(source_locale) {
anyhow::bail!(" Missing source {}.json file", source_locale);
}
eprintln!("Loaded {} locales", locales.len());
eprintln!("Source locale: {}", source_locale);
eprintln!();
let mut exit_code = 0;
if missing_keys {
if !check_missing_keys(&locales, source_locale) {
exit_code = 1;
}
}
if extra_keys {
if !check_extra_keys(&locales, source_locale) {
exit_code = 1;
}
}
if structural_equality {
if !check_structural_equality(&locales, source_locale) {
exit_code = 1;
}
}
if exit_code == 0 {
eprintln!("✓ All checks passed!");
} else {
eprintln!("✗ Some checks failed");
std::process::exit(exit_code);
}
Ok(())
}
fn extract_locale_from_path(path: &Path) -> Result<String> {
let filename = path
.file_stem()
.context("Failed to get filename")?
.to_str()
.context("Filename is not valid UTF-8")?;
Ok(filename.to_string())
}
fn should_ignore(file: &Path, ignore_patterns: &[String]) -> bool {
let file_str = file.to_string_lossy();
ignore_patterns.iter().any(|pattern| {
file_str.contains(pattern)
})
}
fn extract_keys(value: &Value, parent_key: &str) -> Vec<String> {
let mut keys = Vec::new();
match value {
Value::Object(map) => {
for (key, val) in map {
let full_key = if parent_key.is_empty() {
key.clone()
} else {
format!("{}.{}", parent_key, key)
};
keys.push(full_key.clone());
if val.is_object() {
keys.extend(extract_keys(val, &full_key));
}
}
}
_ => {}
}
keys
}
fn flatten(value: &Value, parent_key: &str) -> HashMap<String, String> {
let mut result = HashMap::new();
match value {
Value::Object(map) => {
for (key, val) in map {
let full_key = if parent_key.is_empty() {
key.clone()
} else {
format!("{}.{}", parent_key, key)
};
match val {
Value::String(s) => {
result.insert(full_key, s.clone());
}
Value::Object(_) => {
result.extend(flatten(val, &full_key));
}
_ => {
}
}
}
}
_ => {}
}
result
}
fn check_missing_keys(locales: &HashMap<String, Value>, source_locale: &str) -> bool {
let source = locales.get(source_locale).unwrap();
let source_keys = extract_keys(source, "");
let source_key_set: HashSet<_> = source_keys.iter().collect();
let mut all_passed = true;
for (locale, content) in locales {
if locale == source_locale {
continue;
}
let target_keys = extract_keys(content, "");
let target_key_set: HashSet<_> = target_keys.iter().collect();
let mut missing: Vec<_> = source_key_set.difference(&target_key_set).collect();
missing.sort_by(|a, b| {
let a_len = a.len();
let b_len = b.len();
a_len.cmp(&b_len).then_with(|| a.cmp(b))
});
if !missing.is_empty() {
all_passed = false;
eprintln!("---------------------------------");
eprintln!("Missing translation keys for locale {}:", locale);
for key in missing {
eprintln!("{}", key);
}
eprintln!();
}
}
all_passed
}
fn check_extra_keys(locales: &HashMap<String, Value>, source_locale: &str) -> bool {
let source = locales.get(source_locale).unwrap();
let source_keys = extract_keys(source, "");
let source_key_set: HashSet<_> = source_keys.iter().collect();
let mut all_passed = true;
for (locale, content) in locales {
if locale == source_locale {
continue;
}
let target_keys = extract_keys(content, "");
let target_key_set: HashSet<_> = target_keys.iter().collect();
let extra: Vec<_> = target_key_set.difference(&source_key_set).collect();
if !extra.is_empty() {
all_passed = false;
eprintln!("---------------------------------");
eprintln!("Extra translation keys for locale {}:", locale);
for key in extra {
eprintln!("{}", key);
}
eprintln!();
}
}
all_passed
}
fn check_structural_equality(locales: &HashMap<String, Value>, source_locale: &str) -> bool {
let source = locales.get(source_locale).unwrap();
let source_messages = flatten(source, "");
let mut all_passed = true;
for (locale, content) in locales {
if locale == source_locale {
continue;
}
let target_messages = flatten(content, "");
let mut errors = Vec::new();
for (key, source_msg) in &source_messages {
if let Some(target_msg) = target_messages.get(key) {
match compare_message_structure(source_msg, target_msg, Some(key)) {
Ok((true, _)) => {
}
Ok((false, Some(detail))) => {
errors.push((key.clone(), format_error_message(&detail)));
}
Ok((false, None)) => {
errors.push((
key.clone(),
"Messages are structurally different".to_string(),
));
}
Err(e) => {
let error_msg = format!("{:#}", e); if error_msg.contains("EXPECT_ARGUMENT_CLOSING_BRACE") {
errors.push((key.clone(), "EXPECT_ARGUMENT_CLOSING_BRACE".to_string()));
} else if let Some(code) = extract_parse_error_code(&error_msg) {
errors.push((key.clone(), code));
} else {
errors.push((key.clone(), format_error_message(&error_msg)));
}
}
}
}
}
if !errors.is_empty() {
all_passed = false;
eprintln!("---------------------------------");
eprintln!(
"These translation keys for locale {} are structurally different from {}:",
locale, source_locale
);
errors.sort_by(|a, b| {
match (a.0.parse::<i32>(), b.0.parse::<i32>()) {
(Ok(a_num), Ok(b_num)) => a_num.cmp(&b_num),
_ => a.0.cmp(&b.0),
}
});
for (key, error) in errors {
eprintln!("{}: {}", key, error);
}
eprintln!();
}
}
all_passed
}
fn extract_parse_error_code(msg: &str) -> Option<String> {
let error_codes = [
"EXPECT_ARGUMENT_CLOSING_BRACE",
"EMPTY_ARGUMENT",
"MALFORMED_ARGUMENT",
"EXPECT_ARGUMENT_TYPE",
"INVALID_ARGUMENT_TYPE",
"EXPECT_SELECT_ARGUMENT_OPTIONS",
"EXPECT_PLURAL_ARGUMENT_OFFSET_VALUE",
"INVALID_PLURAL_ARGUMENT_OFFSET_VALUE",
"EXPECT_SELECT_ARGUMENT_SELECTOR",
"EXPECT_PLURAL_ARGUMENT_SELECTOR_FRAGMENT",
"UNMATCHED_CLOSING_BRACE",
"UNCLOSED_QUOTE_IN_ARGUMENT_STYLE",
];
for code in &error_codes {
if msg.contains(code) {
return Some(code.to_string());
}
}
None
}
fn format_error_message(msg: &str) -> String {
let msg = msg.replace("'", "");
let msg = msg.replace("Number", "number");
let msg = msg.replace("Date", "date");
let msg = msg.replace("Time", "time");
if msg.contains("Different number of variables:") {
if let Some((_prefix, lists)) = msg.split_once("Different number of variables:") {
if let Some((list1, rest)) = lists.split_once("] vs [") {
let list1_str = list1.trim_start_matches(&[' ', '['][..]);
let list2_str = rest.trim_end_matches(&[']'][..]);
let mut vars1: Vec<&str> = list1_str.split(", ").collect();
let mut vars2: Vec<&str> = list2_str.split(", ").collect();
vars1.sort_by(|a, b| {
let a_single = a.len() == 1;
let b_single = b.len() == 1;
match (a_single, b_single) {
(true, false) => std::cmp::Ordering::Greater, (false, true) => std::cmp::Ordering::Less, _ => a.cmp(b), }
});
vars2.sort();
return format!(
"Different number of variables: [{}] vs [{}]",
vars1.join(", "),
vars2.join(", ")
);
}
}
}
msg
}
fn compare_message_structure(source: &str, target: &str, message_id: Option<&str>) -> Result<(bool, Option<String>)> {
use formatjs_icu_messageformat_parser::{Parser, ParserOptions, is_structurally_same};
let parser_options = ParserOptions::default();
let source_parser = Parser::new(source, parser_options.clone());
let source_ast = source_parser
.parse()
.with_context(|| format!("Failed to parse source message: {}", source))?;
let target_parser = Parser::new(target, parser_options);
let target_ast = target_parser
.parse()
.with_context(|| format!("Failed to parse target message: {}", target))?;
let context = message_id.unwrap_or("unknown");
match is_structurally_same(&source_ast, &target_ast, context.to_string()) {
Ok(()) => Ok((true, None)),
Err(e) => Ok((false, Some(e.to_string()))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_extract_keys() {
let value = json!({
"a": "value",
"b": {
"c": "value2",
"d": {
"e": "value3"
}
}
});
let keys = extract_keys(&value, "");
assert!(keys.contains(&"a".to_string()));
assert!(keys.contains(&"b".to_string()));
assert!(keys.contains(&"b.c".to_string()));
assert!(keys.contains(&"b.d".to_string()));
assert!(keys.contains(&"b.d.e".to_string()));
}
#[test]
fn test_extract_keys_empty_object() {
let value = json!({});
let keys = extract_keys(&value, "");
assert_eq!(keys.len(), 0);
}
#[test]
fn test_extract_keys_flat_object() {
let value = json!({
"key1": "value1",
"key2": "value2",
"key3": "value3"
});
let keys = extract_keys(&value, "");
assert_eq!(keys.len(), 3);
assert!(keys.contains(&"key1".to_string()));
assert!(keys.contains(&"key2".to_string()));
assert!(keys.contains(&"key3".to_string()));
}
#[test]
fn test_flatten() {
let value = json!({
"a": "value1",
"b": {
"c": "value2",
"d": {
"e": "value3"
}
}
});
let flattened = flatten(&value, "");
assert_eq!(flattened.get("a"), Some(&"value1".to_string()));
assert_eq!(flattened.get("b.c"), Some(&"value2".to_string()));
assert_eq!(flattened.get("b.d.e"), Some(&"value3".to_string()));
assert_eq!(flattened.len(), 3);
}
#[test]
fn test_flatten_ignores_non_string_values() {
let value = json!({
"string": "value",
"number": 42,
"array": [1, 2, 3],
"null": null,
"bool": true,
"nested": {
"valid": "nested_value",
"invalid": 123
}
});
let flattened = flatten(&value, "");
assert_eq!(flattened.len(), 2);
assert_eq!(flattened.get("string"), Some(&"value".to_string()));
assert_eq!(
flattened.get("nested.valid"),
Some(&"nested_value".to_string())
);
assert!(!flattened.contains_key("number"));
assert!(!flattened.contains_key("array"));
assert!(!flattened.contains_key("nested.invalid"));
}
#[test]
fn test_flatten_empty_object() {
let value = json!({});
let flattened = flatten(&value, "");
assert_eq!(flattened.len(), 0);
}
#[test]
fn test_extract_locale_from_path() {
let path = Path::new("/path/to/en.json");
assert_eq!(extract_locale_from_path(path).unwrap(), "en");
let path = Path::new("fr-FR.json");
assert_eq!(extract_locale_from_path(path).unwrap(), "fr-FR");
}
#[test]
fn test_extract_locale_from_path_complex() {
let path = Path::new("/deep/nested/path/to/locales/zh-Hans-CN.json");
assert_eq!(extract_locale_from_path(path).unwrap(), "zh-Hans-CN");
let path = Path::new("pt-BR.json");
assert_eq!(extract_locale_from_path(path).unwrap(), "pt-BR");
}
#[test]
fn test_should_ignore() {
let file = Path::new("/path/to/file.json");
let ignore_patterns = vec!["node_modules".to_string(), "test".to_string()];
assert!(!should_ignore(file, &ignore_patterns));
let file = Path::new("/path/node_modules/file.json");
assert!(should_ignore(file, &ignore_patterns));
let file = Path::new("/path/test/file.json");
assert!(should_ignore(file, &ignore_patterns));
let file = Path::new("/path/to/test_file.json");
assert!(should_ignore(file, &ignore_patterns));
}
#[test]
fn test_should_ignore_empty_patterns() {
let file = Path::new("/path/to/file.json");
let ignore_patterns: Vec<String> = vec![];
assert!(!should_ignore(file, &ignore_patterns));
}
#[test]
fn test_check_missing_keys() {
let mut locales = HashMap::new();
locales.insert(
"en".to_string(),
json!({
"greeting": "Hello",
"farewell": "Goodbye",
"nested": {
"key": "value"
}
}),
);
locales.insert(
"es".to_string(),
json!({
"greeting": "Hola",
"farewell": "Adiós",
"nested": {
"key": "valor"
}
}),
);
assert!(check_missing_keys(&locales, "en"));
}
#[test]
fn test_check_missing_keys_with_missing() {
let mut locales = HashMap::new();
locales.insert(
"en".to_string(),
json!({
"greeting": "Hello",
"farewell": "Goodbye",
"extra": "Extra key"
}),
);
locales.insert(
"es".to_string(),
json!({
"greeting": "Hola"
}),
);
assert!(!check_missing_keys(&locales, "en"));
}
#[test]
fn test_check_extra_keys() {
let mut locales = HashMap::new();
locales.insert(
"en".to_string(),
json!({
"greeting": "Hello",
"farewell": "Goodbye"
}),
);
locales.insert(
"es".to_string(),
json!({
"greeting": "Hola",
"farewell": "Adiós"
}),
);
assert!(check_extra_keys(&locales, "en"));
}
#[test]
fn test_check_extra_keys_with_extra() {
let mut locales = HashMap::new();
locales.insert(
"en".to_string(),
json!({
"greeting": "Hello"
}),
);
locales.insert(
"es".to_string(),
json!({
"greeting": "Hola",
"extra_key": "Extra value",
"another_extra": "Another"
}),
);
assert!(!check_extra_keys(&locales, "en"));
}
#[test]
fn test_compare_message_structure_identical() {
let source = "Hello {name}!";
let target = "Hola {name}!";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, true);
assert_eq!(result.1, None);
}
#[test]
fn test_compare_message_structure_same_structure_different_text() {
let source = "You have {count} items";
let target = "Tienes {count} artículos";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, true);
}
#[test]
fn test_compare_message_structure_plural() {
let source = "You have {count, plural, one {# item} other {# items}}";
let target = "Tienes {count, plural, one {# artículo} other {# artículos}}";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, true);
}
#[test]
fn test_compare_message_structure_missing_variable() {
let source = "Hello {name}!";
let target = "Hello!";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, false);
assert!(result.1.is_some());
assert!(result.1.unwrap().contains("name"));
}
#[test]
fn test_compare_message_structure_different_variable_name() {
let source = "Hello {name}!";
let target = "Hello {username}!";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, false);
assert!(result.1.is_some());
}
#[test]
fn test_compare_message_structure_type_mismatch() {
let source = "{count, plural, one {# item} other {# items}}";
let target = "{count} items";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, false);
assert!(result.1.is_some());
let error_msg = result.1.unwrap();
assert!(error_msg.contains("count"));
assert!(error_msg.contains("type"));
}
#[test]
fn test_compare_message_structure_date_format() {
let source = "Today is {date, date, short}";
let target = "Hoy es {date, date, short}";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, true);
}
#[test]
fn test_compare_message_structure_date_type_mismatch() {
let source = "Today is {date, date, short}";
let target = "Today is {date}";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, false);
assert!(result.1.is_some());
}
#[test]
fn test_compare_message_structure_number_format() {
let source = "Price: {price, number, ::currency/USD}";
let target = "Precio: {price, number, ::currency/USD}";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, true);
}
#[test]
fn test_compare_message_structure_select() {
let source = "{gender, select, male {He} female {She} other {They}}";
let target = "{gender, select, male {Él} female {Ella} other {Ellos}}";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, true);
}
#[test]
fn test_compare_message_structure_multiple_variables() {
let source = "Hello {firstName} {lastName}! You have {count} messages.";
let target = "Hola {firstName} {lastName}! Tienes {count} mensajes.";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, true);
}
#[test]
fn test_compare_message_structure_multiple_variables_missing_one() {
let source = "Hello {firstName} {lastName}! You have {count} messages.";
let target = "Hola {firstName}! Tienes {count} mensajes.";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, false);
assert!(result.1.is_some());
}
#[test]
fn test_compare_message_structure_complex_nested() {
let source = "{count, plural, one {You have # {itemType, select, photo {photo} video {video} other {item}}} other {You have # {itemType, select, photo {photos} video {videos} other {items}}}}";
let target = "{count, plural, one {Tienes # {itemType, select, photo {foto} video {video} other {artículo}}} other {Tienes # {itemType, select, photo {fotos} video {videos} other {artículos}}}}";
let result = compare_message_structure(source, target, None).unwrap();
assert_eq!(result.0, true);
}
#[test]
fn test_compare_message_structure_invalid_source() {
let source = "Hello {name";
let target = "Hola {name}";
let result = compare_message_structure(source, target, None);
assert!(result.is_err());
}
#[test]
fn test_compare_message_structure_invalid_target() {
let source = "Hello {name}";
let target = "Hola {name";
let result = compare_message_structure(source, target, None);
assert!(result.is_err());
}
#[test]
fn test_check_structural_equality() {
let mut locales = HashMap::new();
locales.insert(
"en".to_string(),
json!({
"greeting": "Hello {name}!",
"count": "You have {count, plural, one {# item} other {# items}}"
}),
);
locales.insert(
"es".to_string(),
json!({
"greeting": "Hola {name}!",
"count": "Tienes {count, plural, one {# artículo} other {# artículos}}"
}),
);
assert!(check_structural_equality(&locales, "en"));
}
#[test]
fn test_check_structural_equality_with_errors() {
let mut locales = HashMap::new();
locales.insert(
"en".to_string(),
json!({
"greeting": "Hello {name}!",
"count": "You have {count, plural, one {# item} other {# items}}"
}),
);
locales.insert(
"fr".to_string(),
json!({
"greeting": "Bonjour {username}!",
"count": "Vous avez {count} articles"
}),
);
assert!(!check_structural_equality(&locales, "en"));
}
}