use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::str::FromStr as _;
use autumn_web::config::{AutumnConfig, Env, OsEnv};
use autumn_web::i18n::{Bundle, I18nConfig};
use proc_macro2::{Delimiter, Group, TokenStream, TokenTree};
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Copy)]
pub struct I18nCheckOptions {
pub format: OutputFormat,
pub strict: bool,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DynamicSite {
pub file: String,
pub line: usize,
pub snippet: String,
pub key_prefix: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct LocaleReport {
pub locale: String,
pub is_default: bool,
pub missing: Vec<String>,
pub untranslated: Vec<String>,
pub unused: Vec<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Report {
pub default_locale: String,
pub fallback_chain: Vec<String>,
pub referenced_keys: usize,
pub files_scanned: usize,
pub dynamic: Vec<DynamicSite>,
pub unused_suppressed_by_dynamic: bool,
pub locales: Vec<LocaleReport>,
}
impl Report {
#[must_use]
pub fn has_missing(&self) -> bool {
self.locales.iter().any(|l| !l.missing.is_empty())
}
#[must_use]
pub fn has_warnings(&self) -> bool {
self.locales.iter().any(|l| !l.untranslated.is_empty())
|| (!self.unused_suppressed_by_dynamic
&& self.locales.iter().any(|l| !l.unused.is_empty()))
}
#[must_use]
pub fn exit_code(&self, strict: bool) -> i32 {
i32::from(self.has_missing() || (strict && self.has_warnings()))
}
}
#[derive(Debug, Default)]
pub struct ScanResult {
pub referenced: BTreeSet<String>,
pub dynamic: Vec<DynamicSite>,
pub files_scanned: usize,
}
#[must_use]
pub fn scan_project(root: &Path) -> ScanResult {
let mut result = ScanResult::default();
let mut files = Vec::new();
collect_rs_files(root, &mut files);
files.sort();
for path in files {
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
let rel = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
scan_source(&src, &rel, &mut result);
result.files_scanned += 1;
}
result
}
pub fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_symlink() {
continue;
}
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if file_type.is_dir() {
if name == "target" || name.starts_with('.') {
continue;
}
collect_rs_files(&path, out);
} else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some("rs") {
out.push(path);
}
}
}
pub fn scan_source(src: &str, file: &str, result: &mut ScanResult) {
let Ok(stream) = TokenStream::from_str(src) else {
return;
};
scan_stream(&stream, file, result);
}
fn scan_stream(stream: &TokenStream, file: &str, result: &mut ScanResult) {
let trees: Vec<TokenTree> = stream.clone().into_iter().collect();
let mut i = 0;
while i < trees.len() {
if let TokenTree::Ident(ident) = &trees[i] {
let name = ident.to_string();
if name == "t"
&& matches!(trees.get(i + 1), Some(TokenTree::Punct(p)) if p.as_char() == '!')
&& let Some(TokenTree::Group(group)) = trees.get(i + 2)
&& matches!(
group.delimiter(),
Delimiter::Parenthesis | Delimiter::Brace | Delimiter::Bracket
)
{
record_key_at(&group.stream(), 1, file, result);
scan_stream(&group.stream(), file, result);
i += 3;
continue;
}
if (name == "t" || name == "t_with")
&& let Some(TokenTree::Group(group)) = trees.get(i + 1)
&& group.delimiter() == Delimiter::Parenthesis
{
let prev_punct = i.checked_sub(1).and_then(|j| match &trees[j] {
TokenTree::Punct(p) => Some(p.as_char()),
_ => None,
});
match prev_punct {
Some('.') => {
record_key_at(&group.stream(), 0, file, result);
scan_stream(&group.stream(), file, result);
i += 2;
continue;
}
Some(':') if has_double_colon_before(&trees, i) => {
record_key_at(&group.stream(), 1, file, result);
scan_stream(&group.stream(), file, result);
i += 2;
continue;
}
_ => {}
}
}
}
if let TokenTree::Group(group) = &trees[i] {
scan_stream(&group.stream(), file, result);
}
i += 1;
}
}
fn has_double_colon_before(trees: &[TokenTree], i: usize) -> bool {
let Some(j) = i.checked_sub(2) else {
return false;
};
matches!(
&trees[j],
TokenTree::Punct(p) if p.as_char() == ':' && p.spacing() == proc_macro2::Spacing::Joint
)
}
fn split_top_level_args(args: &TokenStream) -> Vec<TokenStream> {
let mut segments = Vec::new();
let mut current: Vec<TokenTree> = Vec::new();
let mut angle_depth: u32 = 0;
for tt in args.clone() {
match &tt {
TokenTree::Punct(p) if p.as_char() == '<' => angle_depth += 1,
TokenTree::Punct(p) if p.as_char() == '>' => {
angle_depth = angle_depth.saturating_sub(1);
}
TokenTree::Punct(p) if p.as_char() == ',' && angle_depth == 0 => {
segments.push(std::mem::take(&mut current).into_iter().collect());
continue;
}
_ => {}
}
current.push(tt);
}
if !current.is_empty() {
segments.push(current.into_iter().collect());
}
segments
}
fn split_call_args(args: &TokenStream) -> Vec<TokenStream> {
use syn::parse::Parser as _;
let parser = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
if let Ok(parsed) = parser.parse2(args.clone()) {
return parsed
.into_iter()
.map(quote::ToTokens::into_token_stream)
.collect();
}
split_top_level_args(args)
}
fn record_key_at(args: &TokenStream, index: usize, file: &str, result: &mut ScanResult) {
if let Some(segment) = split_call_args(args).get(index) {
record_key_segment(segment, file, result);
}
}
fn segment_str_lit(segment: &TokenStream) -> Option<String> {
syn::parse2::<syn::LitStr>(segment.clone())
.ok()
.map(|s| s.value())
}
fn record_key_segment(segment: &TokenStream, file: &str, result: &mut ScanResult) {
let trees: Vec<TokenTree> = segment.clone().into_iter().collect();
let normalized: TokenStream = normalize_key_segment(&trees).into_iter().collect();
if let Some(key) = segment_str_lit(&normalized) {
result.referenced.insert(key);
} else {
record_dynamic_segment(segment, file, result);
}
}
fn record_dynamic_segment(segment: &TokenStream, file: &str, result: &mut ScanResult) {
let line = segment
.clone()
.into_iter()
.next()
.map_or(0, |tt| tt.span().start().line);
result.dynamic.push(DynamicSite {
file: file.to_owned(),
line,
snippet: segment.to_string(),
key_prefix: dynamic_key_prefix(segment),
});
}
fn dynamic_key_prefix(segment: &TokenStream) -> String {
let trees: Vec<TokenTree> = segment.clone().into_iter().collect();
let normalized = normalize_key_segment(&trees);
if let Some(group) = leading_format_group(&normalized) {
return split_top_level_args(&group.stream())
.first()
.and_then(segment_str_lit)
.map_or_else(String::new, |fmt| format_static_prefix(&fmt));
}
leading_str_literal(&normalized)
}
fn normalize_key_segment(trees: &[TokenTree]) -> Vec<TokenTree> {
let Some(start) = trees
.iter()
.position(|tt| !matches!(tt, TokenTree::Punct(p) if matches!(p.as_char(), '&' | '*')))
else {
return Vec::new();
};
let rest = &trees[start..];
if rest.len() == 1
&& let TokenTree::Group(g) = &rest[0]
&& matches!(g.delimiter(), Delimiter::Parenthesis | Delimiter::None)
{
let inner: Vec<TokenTree> = g.stream().into_iter().collect();
return normalize_key_segment(&inner);
}
rest.to_vec()
}
fn leading_format_group(trees: &[TokenTree]) -> Option<&Group> {
let mut i = 0;
if matches!(trees.get(i), Some(TokenTree::Punct(p)) if p.as_char() == ':')
&& matches!(trees.get(i + 1), Some(TokenTree::Punct(p)) if p.as_char() == ':')
{
i += 2;
}
let mut last_ident: Option<&proc_macro2::Ident> = None;
while let Some(TokenTree::Ident(id)) = trees.get(i) {
last_ident = Some(id);
i += 1;
if matches!(trees.get(i), Some(TokenTree::Punct(p)) if p.as_char() == ':')
&& matches!(trees.get(i + 1), Some(TokenTree::Punct(p)) if p.as_char() == ':')
{
i += 2;
} else {
break;
}
}
if let Some(id) = last_ident
&& *id == "format"
&& matches!(trees.get(i), Some(TokenTree::Punct(p)) if p.as_char() == '!')
&& let Some(TokenTree::Group(group)) = trees.get(i + 1)
&& trees.get(i + 2).is_none()
{
return Some(group);
}
None
}
fn leading_str_literal(trees: &[TokenTree]) -> String {
match trees {
[tt @ TokenTree::Literal(_)] => {
segment_str_lit(&TokenStream::from(tt.clone())).unwrap_or_default()
}
_ => String::new(),
}
}
fn format_static_prefix(fmt: &str) -> String {
let mut out = String::new();
let mut chars = fmt.chars().peekable();
while let Some(c) = chars.next() {
match c {
'{' if chars.peek() == Some(&'{') => {
chars.next();
out.push('{');
}
'{' => break, '}' if chars.peek() == Some(&'}') => {
chars.next();
out.push('}');
}
other => out.push(other),
}
}
out
}
#[must_use]
pub fn parse_ftl_keys(src: &str) -> BTreeSet<String> {
let mut keys = BTreeSet::new();
for line in src.lines() {
if line.starts_with(' ')
|| line.starts_with('\t')
|| line.trim_start().starts_with('#')
|| line.trim().is_empty()
{
continue;
}
if let Some((key, _)) = line.split_once('=') {
let key = key.trim();
if !key.is_empty() {
keys.insert(key.to_owned());
}
}
}
keys
}
fn load_locale_keys(dir: &Path) -> BTreeMap<String, BTreeSet<String>> {
let mut per_locale = BTreeMap::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return per_locale;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("ftl") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Ok(src) = std::fs::read_to_string(&path) else {
continue;
};
per_locale.insert(stem.to_owned(), parse_ftl_keys(&src));
}
per_locale
}
#[must_use]
pub fn build_report(
scan: &ScanResult,
config: &I18nConfig,
per_locale_keys: &BTreeMap<String, BTreeSet<String>>,
) -> Report {
let default_locale = config.default_locale.clone();
let chain = config.resolved_fallback_chain();
let mut chain_keys: BTreeSet<String> = BTreeSet::new();
for locale in &chain {
if let Some(keys) = per_locale_keys.get(locale) {
chain_keys.extend(keys.iter().cloned());
}
}
let empty = BTreeSet::new();
let default_keys = per_locale_keys.get(&default_locale).unwrap_or(&empty);
let unused_suppressed_by_dynamic = scan.dynamic.iter().any(|d| d.key_prefix.is_empty());
let dynamic_prefixes: Vec<&str> = scan
.dynamic
.iter()
.map(|d| d.key_prefix.as_str())
.filter(|p| !p.is_empty())
.collect();
let mut name_set: BTreeSet<&String> = per_locale_keys.keys().collect();
name_set.extend(config.supported_locales.iter());
let mut names: Vec<&String> = name_set.into_iter().collect();
names.sort_by_key(|n| (**n != default_locale, (*n).clone()));
let locales = names
.into_iter()
.map(|locale| {
let keys = per_locale_keys.get(locale).unwrap_or(&empty);
let is_default = *locale == default_locale;
let mut resolvable = keys.clone();
resolvable.extend(chain_keys.iter().cloned());
let missing: Vec<String> = scan
.referenced
.iter()
.filter(|k| !resolvable.contains(*k))
.cloned()
.collect();
let untranslated: Vec<String> = if is_default {
Vec::new()
} else {
let resolution_order = std::iter::once(locale.as_str()).chain(
chain
.iter()
.map(String::as_str)
.filter(|f| *f != locale.as_str()),
);
let resolution_order: Vec<&str> = resolution_order.collect();
default_keys
.iter()
.filter(|k| {
resolution_order.iter().copied().find(|loc| {
per_locale_keys
.get(*loc)
.is_some_and(|ks| ks.contains(k.as_str()))
}) == Some(default_locale.as_str())
})
.cloned()
.collect()
};
let unused: Vec<String> = keys
.iter()
.filter(|k| !k.starts_with('-') && !scan.referenced.contains(*k))
.filter(|k| !dynamic_prefixes.iter().any(|p| k.starts_with(p)))
.cloned()
.collect();
LocaleReport {
locale: locale.clone(),
is_default,
missing,
untranslated,
unused,
}
})
.collect();
Report {
default_locale,
fallback_chain: chain,
referenced_keys: scan.referenced.len(),
files_scanned: scan.files_scanned,
dynamic: scan.dynamic.clone(),
unused_suppressed_by_dynamic,
locales,
}
}
#[derive(Debug, Default, serde::Deserialize)]
struct ProjectToml {
#[serde(default)]
i18n: I18nConfig,
}
struct RootedEnv<'a> {
root: &'a Path,
inner: &'a dyn Env,
}
impl Env for RootedEnv<'_> {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
if key == "AUTUMN_MANIFEST_DIR" {
return Ok(self.root.to_string_lossy().into_owned());
}
self.inner.var(key)
}
}
fn load_i18n_config(root: &Path, env: &dyn Env) -> (I18nConfig, Option<String>) {
let rooted = RootedEnv { root, inner: env };
if let Ok(config) = AutumnConfig::load_with_env(&rooted) {
let profile = config.profile_name().map(str::to_owned);
return (config.i18n, profile);
}
(load_i18n_config_base_only(root), None)
}
fn i18n_is_configured(root: &Path, profile: Option<&str>, selected_input: &str) -> bool {
if let Some(base) = read_config_table(&root.join("autumn.toml")) {
if base.contains_key("i18n") {
return true;
}
if let Some(profile) = profile
&& let Some(profiles) = base.get("profile").and_then(toml::Value::as_table)
{
for name in profile_lookup_names(profile) {
if profiles
.get(name)
.and_then(toml::Value::as_table)
.is_some_and(|t| t.contains_key("i18n"))
{
return true;
}
}
}
}
if let Some(profile) = profile {
for name in profile_override_file_lookup_names(profile, selected_input) {
let path = root.join(format!("autumn-{name}.toml"));
if path.exists() {
return read_config_table(&path).is_some_and(|t| t.contains_key("i18n"));
}
}
}
false
}
fn read_config_table(path: &Path) -> Option<toml::Table> {
let raw = std::fs::read_to_string(path).ok()?;
toml::from_str::<toml::Table>(&raw).ok()
}
fn profile_lookup_names(profile: &str) -> Vec<&str> {
match profile {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
fn profile_override_file_lookup_names(profile: &str, selected_input: &str) -> Vec<String> {
match profile {
"prod" if selected_input.eq_ignore_ascii_case("production") => {
vec!["production".to_owned(), "prod".to_owned()]
}
"prod" => vec!["prod".to_owned(), "production".to_owned()],
"dev" if selected_input.eq_ignore_ascii_case("development") => {
vec!["development".to_owned(), "dev".to_owned()]
}
"dev" => vec!["dev".to_owned(), "development".to_owned()],
other => vec![other.to_owned()],
}
}
fn selected_profile_input(env: &dyn Env) -> String {
for key in ["AUTUMN_ENV", "AUTUMN_PROFILE"] {
if let Ok(val) = env.var(key) {
let trimmed = val.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
}
String::new()
}
fn load_i18n_config_base_only(root: &Path) -> I18nConfig {
let path = root.join("autumn.toml");
let Ok(raw) = std::fs::read_to_string(&path) else {
return I18nConfig::default();
};
toml::from_str::<ProjectToml>(&raw)
.map(|p| p.i18n)
.unwrap_or_default()
}
pub fn run(opts: I18nCheckOptions) -> ! {
let code = run_in(Path::new("."), &OsEnv, opts);
std::process::exit(code);
}
#[must_use]
pub fn run_in(root: &Path, env: &dyn Env, opts: I18nCheckOptions) -> i32 {
let (config, profile) = load_i18n_config(root, env);
let dir = root.join(&config.dir);
if !dir.exists() && !i18n_is_configured(root, profile.as_deref(), &selected_profile_input(env))
{
if opts.format == OutputFormat::Json {
println!(
"{{\"status\":\"skipped\",\"reason\":\"no i18n directory\",\"dir\":{}}}",
serde_json::to_string(&config.dir).unwrap_or_else(|_| "\"i18n\"".to_owned())
);
} else {
println!(
"autumn i18n check: no i18n directory (`{}`) found — nothing to check.",
config.dir
);
}
return 0;
}
if let Err(err) = Bundle::load_from_dir(&dir, &config) {
eprintln!("autumn i18n check: failed to load i18n bundle: {err}");
return 1;
}
let per_locale_keys = load_locale_keys(&dir);
let scan = scan_project(root);
let report = build_report(&scan, &config, &per_locale_keys);
match opts.format {
OutputFormat::Json => print_json(&report),
OutputFormat::Text => print_text(&report, opts.strict),
}
report.exit_code(opts.strict)
}
fn print_json(report: &Report) {
match serde_json::to_string_pretty(report) {
Ok(json) => println!("{json}"),
Err(err) => eprintln!("autumn i18n check: failed to serialize report: {err}"),
}
}
fn print_text(report: &Report, strict: bool) {
println!("autumn i18n check");
println!(
" default locale: {} fallback chain: {}",
report.default_locale,
report.fallback_chain.join(" -> ")
);
println!(
" scanned {} referenced key(s) across {} .rs file(s)",
report.referenced_keys, report.files_scanned
);
if report.dynamic.is_empty() {
println!(" dynamic (not checked): none");
} else {
println!(
" dynamic (not checked): {} call site(s) — keys built at runtime:",
report.dynamic.len()
);
for site in &report.dynamic {
println!(" {}:{}: {}", site.file, site.line, site.snippet);
}
}
if report.unused_suppressed_by_dynamic {
println!(
" note: Unused checking suppressed — a fully-dynamic key site (no static \
prefix) is present; Unused entries below are informational only and do not \
fail --strict."
);
}
for locale in &report.locales {
let tag = if locale.is_default {
format!("{} (default)", locale.locale)
} else {
locale.locale.clone()
};
println!("\n locale `{tag}`:");
print_key_list("Missing", &locale.missing, "✗");
if !locale.is_default {
print_key_list("Untranslated", &locale.untranslated, "⚠");
}
let unused_label = if report.unused_suppressed_by_dynamic {
"Unused (informational)"
} else {
"Unused"
};
print_key_list(unused_label, &locale.unused, "·");
}
println!();
if report.has_missing() {
println!("Result: FAIL — missing keys in one or more locales.");
} else if strict && report.has_warnings() {
println!("Result: FAIL (--strict) — untranslated/unused keys present.");
} else if report.has_warnings() {
println!("Result: PASS with warnings — untranslated/unused keys present.");
} else {
println!("Result: PASS — all referenced keys are translated.");
}
}
fn print_key_list(label: &str, keys: &[String], marker: &str) {
if keys.is_empty() {
println!(" {label}: (none)");
} else {
println!(" {label}:");
for key in keys {
println!(" {marker} {key}");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(default: &str, chain: &[&str]) -> I18nConfig {
I18nConfig {
default_locale: default.to_owned(),
supported_locales: vec![default.to_owned()],
fallback_chain: chain.iter().map(|s| (*s).to_owned()).collect(),
dir: "i18n".to_owned(),
locale_prefix_enabled: false,
locale_prefix_exclude: vec![],
locale_prefix_exclude_exact: vec![],
}
}
fn scan_with(referenced: &[&str]) -> ScanResult {
ScanResult {
referenced: keys(referenced),
..Default::default()
}
}
fn keys(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn scans_macro_method_and_call_forms() {
let src = r#"
fn view(locale: &Locale) {
let _ = t!(locale, "nav.home");
let _ = t!(locale, "greet.hi", name = who);
let _ = locale.t("nav.about");
let _ = locale.t_with("greet.count", &[("n", "1")]);
let _ = Locale::t(&locale, "nav.brand");
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(
result.referenced,
keys(&[
"greet.count",
"greet.hi",
"nav.about",
"nav.brand",
"nav.home",
])
);
assert!(result.dynamic.is_empty());
}
#[test]
fn nested_translation_call_in_args_is_recorded() {
let src = r#"
fn view(locale: &Locale) {
let _ = locale.t_with("message", &[("status", &locale.t("status.open"))]);
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(
result.referenced,
keys(&["message", "status.open"]),
"both outer and nested keys must be referenced: {:?}",
result.referenced
);
assert!(
result.dynamic.is_empty(),
"the `&locale.t(\"status.open\")` arg is a real call, not a dynamic site: {:?}",
result.dynamic
);
}
#[test]
fn deeply_nested_translation_calls_are_recorded() {
let src = r#"
fn view(locale: &Locale) {
let _ = t!(locale, "a", t!(locale, "b"));
let _ = locale.t_with("c", &[("x", &t!(locale, "d"))]);
let _ = Locale::t_with(&locale, "e", &[("y", &locale.t("f"))]);
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(
result.referenced,
keys(&["a", "b", "c", "d", "e", "f"]),
"every nested key must be recorded exactly once: {:?}",
result.referenced
);
assert!(result.dynamic.is_empty());
}
#[test]
fn generic_arg_before_key_does_not_shift_key_out_of_slot() {
let src = r#"
fn view(locale: &Locale) {
let _ = t!(locale_for::<A, B>(), "nav.home");
let _ = Locale::t(&get::<A, B>(), "nav.about");
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(
result.referenced,
keys(&["nav.about", "nav.home"]),
"the literal key must stay in its slot despite the multi-param \
generic in an earlier argument: {:?}",
result.referenced
);
assert!(
result.dynamic.is_empty(),
"no bogus dynamic site from a partial `<A` / `B>()` fragment: {:?}",
result.dynamic
);
}
#[test]
fn generic_arg_in_non_key_position_of_method_form_is_recorded() {
let src = r#"
fn view(locale: &Locale) {
let _ = locale.t_with("nav.count", &make::<A, B>());
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(
result.referenced,
keys(&["nav.count"]),
"the key at slot 0 must be recorded even with a generic in slot 1: {:?}",
result.referenced
);
assert!(result.dynamic.is_empty());
}
#[test]
fn free_function_t_is_not_a_translation_reference() {
let src = r#"
fn t(name: &str) -> Fixture {
Fixture::load(name)
}
fn view(locale: &Locale) {
let _ = t("fixture-name");
let _ = locale.t("nav.home");
let _ = Locale::t(&locale, "nav.title");
let _ = t!(locale, "nav.macro");
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(
result.referenced,
keys(&["nav.home", "nav.macro", "nav.title"]),
"the bare free `t(\"fixture-name\")` call must be ignored"
);
assert!(result.dynamic.is_empty());
}
#[test]
fn single_colon_field_value_t_call_is_not_an_associated_call() {
let src = r#"
fn view(locale: &Locale) {
let _ = Widget { label: t(locale, "fixture") };
let _ = Config { args: t_with("fixture", &[]) };
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert!(
result.referenced.is_empty(),
"a single-colon field-value `t(...)`/`t_with(...)` must not be recorded \
as a referenced key: {:?}",
result.referenced
);
assert!(
result.dynamic.is_empty(),
"a single-colon field-value call must not create a dynamic site \
(which would suppress unused checks): {:?}",
result.dynamic
);
}
#[test]
fn real_associated_call_with_two_colons_still_records_key() {
let src = r#"
fn view(loc: &Locale) {
let _ = Locale::t(&loc, "nav.home");
let _ = Locale::t_with(&loc, "nav.count", &[]);
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(
result.referenced,
keys(&["nav.count", "nav.home"]),
"a real `::` associated call must still record its key: {:?}",
result.referenced
);
assert!(result.dynamic.is_empty());
}
#[test]
fn macro_accepts_all_delimiters() {
let src = r#"
fn view(locale: &Locale) {
let _ = t!(locale, "nav.paren");
let _ = t!{ locale, "nav.home" };
let _ = t![locale, "nav.brace"];
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(
result.referenced,
keys(&["nav.brace", "nav.home", "nav.paren"])
);
assert!(result.dynamic.is_empty());
}
#[test]
fn dynamic_keys_are_reported_not_referenced() {
let src = r#"
fn view(locale: &Locale, section: &str) {
let _ = locale.t(&format!("nav.{section}"));
let _ = locale.t("nav.home");
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(result.referenced, keys(&["nav.home"]));
assert_eq!(result.dynamic.len(), 1);
assert_eq!(result.dynamic[0].file, "view.rs");
assert!(result.dynamic[0].snippet.contains("format"));
}
#[test]
fn wrapped_literal_key_recorded_as_referenced() {
let src = r#"
fn view(locale: &Locale, section: &str) {
let _ = locale.t(("nav.home"));
let _ = locale.t(&"nav.about");
let _ = locale.t(&("nav.contact"));
let _ = locale.t(&format!("nav.{section}"));
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(
result.referenced,
keys(&["nav.about", "nav.contact", "nav.home"]),
"wrapped literal keys must be recorded as referenced: {:?}",
result.referenced
);
assert_eq!(
result.dynamic.len(),
1,
"only the genuinely dynamic `format!` key is a dynamic site: {:?}",
result.dynamic
);
assert!(result.dynamic[0].snippet.contains("format"));
}
#[test]
fn group_followed_by_trailing_tokens_is_dynamic_not_referenced() {
let src = r#"
fn view(locale: &Locale) {
let _ = locale.t((" nav.home ").trim());
let _ = locale.t("nav.about");
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert!(
!result.referenced.contains(" nav.home "),
"the raw wrapped literal must not be a referenced key: {:?}",
result.referenced
);
assert!(
!result.referenced.contains("nav.home"),
"the trimmed literal must not be a referenced key either: {:?}",
result.referenced
);
assert_eq!(
result.referenced,
keys(&["nav.about"]),
"only the genuine literal key is referenced: {:?}",
result.referenced
);
assert_eq!(
result.dynamic.len(),
1,
"the `(...).trim()` site is dynamic: {:?}",
result.dynamic
);
assert!(result.dynamic[0].snippet.contains("trim"));
assert_eq!(
result.dynamic[0].key_prefix, "",
"no bogus literal prefix is lifted from inside the wrapped group: {:?}",
result.dynamic[0]
);
}
#[test]
fn format_group_followed_by_trailing_tokens_has_no_prefix() {
let src = r#"
fn view(locale: &Locale, state: &str) {
let _ = locale.t((format!("status.{state}")).to_uppercase());
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
assert_eq!(result.dynamic.len(), 1);
assert_eq!(
result.dynamic[0].key_prefix, "",
"a format! group with trailing tokens must not yield a prefix: {:?}",
result.dynamic[0]
);
assert!(result.referenced.is_empty());
}
#[test]
fn macro_metavariables_do_not_discard_literal_key() {
let src = r#"
macro_rules! nav {
($locale:expr, $n:expr) => {{
let _ = t!($locale, "nav.home");
let _ = $locale.t_with("nav.count", &[("n", $n)]);
}};
}
"#;
let mut result = ScanResult::default();
scan_source(src, "macros.rs", &mut result);
assert_eq!(result.referenced, keys(&["nav.count", "nav.home"]));
assert!(
result.dynamic.is_empty(),
"metavariables in non-key positions must not create dynamic sites: {:?}",
result.dynamic
);
}
#[test]
fn metavariable_with_generic_in_earlier_arg_keeps_key_slot() {
let src = r#"
macro_rules! nav {
($name:expr) => {
let _ = t!(locale_for::<A, B>(), "nav.home", name = $name);
};
}
"#;
let mut result = ScanResult::default();
scan_source(src, "macros.rs", &mut result);
assert_eq!(
result.referenced,
keys(&["nav.home"]),
"the literal key must stay in slot 1 despite the multi-param generic \
in an earlier argument on the metavariable fallback path: {:?}",
result.referenced
);
assert!(
result.dynamic.is_empty(),
"no bogus dynamic site from a `B>()` fragment on the fallback path: {:?}",
result.dynamic
);
}
#[test]
fn metavariable_in_key_slot_is_dynamic_not_referenced() {
let src = r"
macro_rules! tr {
($locale:expr, $key:expr) => {
let _ = t!($locale, $key);
};
}
";
let mut result = ScanResult::default();
scan_source(src, "tr.rs", &mut result);
assert!(
result.referenced.is_empty(),
"a metavariable key must not be a referenced key: {:?}",
result.referenced
);
assert_eq!(result.dynamic.len(), 1);
}
#[test]
fn dynamic_prefix_from_format_and_leading_literal() {
let src = r#"
fn view(locale: &Locale, state: &str, x: &str, key: &str) {
let _ = locale.t(&format!("status.{state}"));
let _ = locale.t(&format!("nav.{}", x));
let _ = locale.t(&format!("{x}"));
let _ = locale.t(&("footer.".to_owned() + x));
let _ = locale.t(key);
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
let prefixes: Vec<&str> = result
.dynamic
.iter()
.map(|d| d.key_prefix.as_str())
.collect();
assert_eq!(prefixes, vec!["status.", "nav.", "", "", ""]);
}
#[test]
fn dynamic_prefix_empty_for_transformed_leading_literal() {
let src = r#"
fn view(locale: &Locale, s: &str) {
let _ = locale.t(" nav.home ".trim());
let _ = locale.t(format!("status.{s}").to_uppercase());
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
let prefixes: Vec<&str> = result
.dynamic
.iter()
.map(|d| d.key_prefix.as_str())
.collect();
assert_eq!(prefixes, vec!["", ""]);
}
#[test]
fn transformed_leading_literal_suppresses_unused() {
let src = r#"
fn view(locale: &Locale) {
let _ = locale.t(" nav.home ".trim());
}
"#;
let mut scan = ScanResult::default();
scan_source(src, "view.rs", &mut scan);
assert_eq!(scan.dynamic.len(), 1);
assert_eq!(scan.dynamic[0].key_prefix, "");
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home"]));
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(report.unused_suppressed_by_dynamic);
assert!(!report.has_warnings());
assert_eq!(report.exit_code(true), 0);
}
#[test]
fn dynamic_prefix_descends_through_wrapping_groups() {
let src = r#"
fn view(locale: &Locale, state: &str, x: &str, y: &str, key: &str) {
let _ = locale.t(&(format!("status.{state}")));
let _ = locale.t(&(&format!("nav.{x}")));
let _ = locale.t((format!("footer.{y}")));
let _ = locale.t(&(key));
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
let prefixes: Vec<&str> = result
.dynamic
.iter()
.map(|d| d.key_prefix.as_str())
.collect();
assert_eq!(prefixes, vec!["status.", "nav.", "footer.", ""]);
}
#[test]
fn path_qualified_format_prefix_recognized() {
let src = r#"
fn view(locale: &Locale, state: &str, x: &str, a: &str, b: &str, key: &str) {
let _ = locale.t(&std::format!("status.{state}"));
let _ = locale.t(&::std::format!("nav.{x}"));
let _ = locale.t(&format!("footer.{a}"));
let _ = locale.t(&std::concat!("misc.", b));
let _ = locale.t(key);
}
"#;
let mut result = ScanResult::default();
scan_source(src, "view.rs", &mut result);
let prefixes: Vec<&str> = result
.dynamic
.iter()
.map(|d| d.key_prefix.as_str())
.collect();
assert_eq!(prefixes, vec!["status.", "nav.", "footer.", "", ""]);
}
#[test]
fn path_qualified_format_suppresses_only_matching_unused_keys() {
let src = r#"
fn view(locale: &Locale, state: &str) {
let _ = locale.t("nav.home");
let _ = locale.t(&std::format!("status.{state}"));
}
"#;
let mut scan = ScanResult::default();
scan_source(src, "view.rs", &mut scan);
assert_eq!(scan.dynamic.len(), 1);
assert_eq!(scan.dynamic[0].key_prefix, "status.");
let mut per_locale = BTreeMap::new();
per_locale.insert(
"en".to_owned(),
keys(&["nav.home", "status.open", "status.closed", "footer.legacy"]),
);
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(!report.unused_suppressed_by_dynamic);
let en = &report.locales[0];
assert_eq!(
en.unused,
vec!["footer.legacy".to_owned()],
"path-qualified prefix `status.` must suppress status.* but keep footer.legacy"
);
assert!(report.has_warnings());
assert_eq!(report.exit_code(false), 0);
assert_eq!(report.exit_code(true), 1);
}
#[test]
fn wrapped_dynamic_prefix_suppresses_only_matching_unused_keys() {
let src = r#"
fn view(locale: &Locale, state: &str) {
let _ = locale.t("nav.home");
let _ = locale.t(&(format!("status.{state}")));
}
"#;
let mut scan = ScanResult::default();
scan_source(src, "view.rs", &mut scan);
assert_eq!(scan.dynamic.len(), 1);
assert_eq!(scan.dynamic[0].key_prefix, "status.");
let mut per_locale = BTreeMap::new();
per_locale.insert(
"en".to_owned(),
keys(&["nav.home", "status.open", "status.closed", "footer.legacy"]),
);
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(!report.unused_suppressed_by_dynamic);
let en = &report.locales[0];
assert_eq!(
en.unused,
vec!["footer.legacy".to_owned()],
"wrapped prefix `status.` must suppress status.* but keep footer.legacy"
);
assert!(report.has_warnings());
assert_eq!(report.exit_code(false), 0);
assert_eq!(report.exit_code(true), 1);
}
#[test]
fn format_static_prefix_decodes_escaped_braces() {
assert_eq!(format_static_prefix("status.{state}"), "status.");
assert_eq!(format_static_prefix("nav.{}"), "nav.");
assert_eq!(format_static_prefix("{x}"), "");
assert_eq!(format_static_prefix("a{{b}}.{x}"), "a{b}.");
assert_eq!(format_static_prefix("plain"), "plain");
}
#[test]
fn dynamic_prefix_suppresses_only_matching_unused_keys() {
let src = r#"
fn view(locale: &Locale, state: &str) {
let _ = locale.t("nav.home");
let _ = locale.t(&format!("status.{state}"));
}
"#;
let mut scan = ScanResult::default();
scan_source(src, "view.rs", &mut scan);
let mut per_locale = BTreeMap::new();
per_locale.insert(
"en".to_owned(),
keys(&["nav.home", "status.open", "status.closed", "footer.legacy"]),
);
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(!report.unused_suppressed_by_dynamic);
let en = &report.locales[0];
assert_eq!(
en.unused,
vec!["footer.legacy".to_owned()],
"prefix `status.` must suppress status.* keys but keep footer.legacy"
);
assert!(report.has_warnings());
assert_eq!(report.exit_code(false), 0);
assert_eq!(report.exit_code(true), 1);
}
#[test]
fn associated_dynamic_key_records_key_arg_not_receiver() {
let src = r#"
fn view(locale: &Locale, state: &str) {
let _ = Locale::t(&locale, "nav.home");
let _ = Locale::t(&locale, &format!("status.{state}"));
let _ = Locale::t_with(&locale, &format!("status.{state}"), &[("n", "1")]);
}
"#;
let mut scan = ScanResult::default();
scan_source(src, "view.rs", &mut scan);
assert!(scan.referenced.contains("nav.home"));
assert_eq!(scan.dynamic.len(), 2);
assert!(scan.dynamic.iter().all(|d| d.key_prefix == "status."));
let mut per_locale = BTreeMap::new();
per_locale.insert(
"en".to_owned(),
keys(&["nav.home", "status.open", "status.closed", "footer.legacy"]),
);
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(!report.unused_suppressed_by_dynamic);
let en = &report.locales[0];
assert_eq!(
en.unused,
vec!["footer.legacy".to_owned()],
"associated dynamic prefix `status.` must suppress status.* but keep footer.legacy"
);
assert!(report.has_warnings());
assert_eq!(report.exit_code(false), 0);
assert_eq!(report.exit_code(true), 1);
}
#[test]
fn associated_fully_dynamic_key_still_suppresses_entirely() {
let src = r#"
fn view(locale: &Locale, key: &str) {
let _ = Locale::t(&locale, "nav.home");
let _ = Locale::t(&locale, key);
}
"#;
let mut scan = ScanResult::default();
scan_source(src, "view.rs", &mut scan);
assert_eq!(scan.dynamic.len(), 1);
assert_eq!(scan.dynamic[0].key_prefix, "");
assert!(scan.dynamic[0].snippet.contains("key"));
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home", "footer.legacy"]));
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(report.unused_suppressed_by_dynamic);
assert_eq!(report.exit_code(true), 0);
}
#[test]
fn fully_dynamic_site_suppresses_unused_entirely() {
let src = r#"
fn view(locale: &Locale, key: &str) {
let _ = locale.t("nav.home");
let _ = locale.t(&key);
}
"#;
let mut scan = ScanResult::default();
scan_source(src, "view.rs", &mut scan);
assert_eq!(scan.dynamic.len(), 1);
assert_eq!(scan.dynamic[0].key_prefix, "");
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home", "footer.legacy"]));
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(report.unused_suppressed_by_dynamic);
let en = &report.locales[0];
assert_eq!(en.unused, vec!["footer.legacy".to_owned()]);
assert!(!report.has_warnings());
assert_eq!(report.exit_code(false), 0);
assert_eq!(report.exit_code(true), 0);
}
#[test]
fn parse_ftl_keys_extracts_top_level_keys_only() {
let src =
"# comment\nnav.home = Home\nnav.about = About\n continued line\n\nfooter.tag = Hi\n";
assert_eq!(
parse_ftl_keys(src),
keys(&["footer.tag", "nav.about", "nav.home"])
);
}
#[test]
fn missing_key_flags_every_locale() {
let scan = scan_with(&["nav.home", "nav.about"]);
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home"]));
per_locale.insert("es".to_owned(), keys(&["nav.home"]));
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(report.has_missing());
assert_eq!(report.exit_code(false), 1);
let es = report.locales.iter().find(|l| l.locale == "es").unwrap();
assert_eq!(es.missing, vec!["nav.about".to_owned()]);
let en = report.locales.iter().find(|l| l.locale == "en").unwrap();
assert_eq!(en.missing, vec!["nav.about".to_owned()]);
}
#[test]
fn untranslated_key_is_a_warning_not_missing() {
let scan = scan_with(&["nav.home", "nav.about"]);
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home", "nav.about"]));
per_locale.insert("es".to_owned(), keys(&["nav.home"]));
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(!report.has_missing());
assert!(report.has_warnings());
assert_eq!(report.exit_code(false), 0);
assert_eq!(report.exit_code(true), 1);
let es = report.locales.iter().find(|l| l.locale == "es").unwrap();
assert!(es.missing.is_empty());
assert_eq!(es.untranslated, vec!["nav.about".to_owned()]);
}
#[test]
fn fallback_chain_suppresses_missing() {
let scan = scan_with(&["nav.home", "nav.about"]);
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home", "nav.about"]));
per_locale.insert("es".to_owned(), keys(&["nav.home"]));
let report = build_report(&scan, &cfg("en", &["es", "en"]), &per_locale);
let es = report.locales.iter().find(|l| l.locale == "es").unwrap();
assert!(es.missing.is_empty(), "fallback should supply nav.about");
assert!(!report.has_missing());
assert_eq!(report.exit_code(false), 0);
}
#[test]
fn intermediate_fallback_locale_supplies_key_so_not_untranslated() {
let scan = scan_with(&["nav.home"]);
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home"]));
per_locale.insert("pt".to_owned(), keys(&["nav.home"]));
per_locale.insert("pt-BR".to_owned(), keys(&[]));
let report = build_report(&scan, &cfg("en", &["pt", "en"]), &per_locale);
let pt_br = report.locales.iter().find(|l| l.locale == "pt-BR").unwrap();
assert!(
pt_br.missing.is_empty(),
"`pt` supplies nav.home via the chain: {:?}",
pt_br.missing
);
assert!(
!pt_br.untranslated.contains(&"nav.home".to_owned()),
"nav.home resolves to localized `pt`, not default `en`: {:?}",
pt_br.untranslated
);
let pt = report.locales.iter().find(|l| l.locale == "pt").unwrap();
assert!(pt.untranslated.is_empty(), "{:?}", pt.untranslated);
assert!(!report.has_missing());
assert!(!report.has_warnings());
assert_eq!(report.exit_code(true), 0, "no issues → --strict passes");
}
#[test]
fn default_only_key_is_untranslated_through_chain() {
let scan = scan_with(&["nav.home", "brand.tagline"]);
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home", "brand.tagline"]));
per_locale.insert("pt".to_owned(), keys(&["nav.home"]));
per_locale.insert("pt-BR".to_owned(), keys(&[]));
let report = build_report(&scan, &cfg("en", &["pt", "en"]), &per_locale);
let pt_br = report.locales.iter().find(|l| l.locale == "pt-BR").unwrap();
assert!(pt_br.missing.is_empty(), "{:?}", pt_br.missing);
assert!(
pt_br.untranslated.contains(&"brand.tagline".to_owned()),
"default-only key falls through to `en`: {:?}",
pt_br.untranslated
);
assert!(
!pt_br.untranslated.contains(&"nav.home".to_owned()),
"nav.home is still supplied by `pt`: {:?}",
pt_br.untranslated
);
let pt = report.locales.iter().find(|l| l.locale == "pt").unwrap();
assert_eq!(pt.untranslated, vec!["brand.tagline".to_owned()]);
assert!(!report.has_missing());
assert!(report.has_warnings());
assert_eq!(report.exit_code(false), 0);
assert_eq!(report.exit_code(true), 1);
}
#[test]
fn configured_locale_without_ftl_file_is_untranslated() {
let scan = scan_with(&["nav.home", "nav.about"]);
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home", "nav.about"]));
let config = I18nConfig {
default_locale: "en".to_owned(),
supported_locales: vec!["en".to_owned(), "es".to_owned()],
fallback_chain: Vec::new(),
dir: "i18n".to_owned(),
locale_prefix_enabled: false,
locale_prefix_exclude: vec![],
locale_prefix_exclude_exact: vec![],
};
let report = build_report(&scan, &config, &per_locale);
let es = report
.locales
.iter()
.find(|l| l.locale == "es")
.expect("configured `es` must appear in the report");
assert!(
es.missing.is_empty(),
"fallback supplies keys: {:?}",
es.missing
);
assert_eq!(
es.untranslated,
vec!["nav.about".to_owned(), "nav.home".to_owned()]
);
assert!(es.unused.is_empty());
assert!(!report.has_missing());
assert!(report.has_warnings());
assert_eq!(report.exit_code(false), 0);
assert_eq!(report.exit_code(true), 1);
}
#[test]
fn unused_key_is_a_warning_only() {
let scan = scan_with(&["nav.home"]);
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home", "footer.old"]));
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(!report.has_missing());
assert!(report.has_warnings());
assert_eq!(report.exit_code(false), 0, "unused is not a failure");
assert_eq!(report.exit_code(true), 1, "unused fails under --strict");
let en = &report.locales[0];
assert_eq!(en.unused, vec!["footer.old".to_owned()]);
}
#[test]
fn fluent_terms_are_not_reported_unused() {
let src = "nav.home = Home\n-brand = Autumn\n";
assert_eq!(parse_ftl_keys(src), keys(&["-brand", "nav.home"]));
let scan = scan_with(&["nav.home"]);
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home", "-brand"]));
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
let en = &report.locales[0];
assert!(
en.unused.is_empty(),
"Fluent term should not be Unused: {:?}",
en.unused
);
assert!(!report.has_warnings());
}
#[cfg(unix)]
#[test]
fn collect_rs_files_does_not_follow_symlink_cycles() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
std::fs::write(root.join("main.rs"), "fn main() {}").unwrap();
let sub = root.join("sub");
std::fs::create_dir(&sub).unwrap();
symlink(root, sub.join("loop")).unwrap();
let mut files = Vec::new();
collect_rs_files(root, &mut files);
assert_eq!(files.len(), 1, "cycle must not be followed: {files:?}");
assert!(files[0].ends_with("main.rs"));
}
#[test]
fn clean_project_passes() {
let scan = scan_with(&["nav.home"]);
let mut per_locale = BTreeMap::new();
per_locale.insert("en".to_owned(), keys(&["nav.home"]));
let report = build_report(&scan, &cfg("en", &[]), &per_locale);
assert!(!report.has_missing());
assert!(!report.has_warnings());
assert_eq!(report.exit_code(false), 0);
assert_eq!(report.exit_code(true), 0);
}
}