use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Report {
pub used_keys: Vec<String>,
pub used_key_locations: BTreeMap<String, Vec<LocationEntry>>,
pub dynamic_keys: Vec<DynamicKeyEntry>,
pub missing_by_locale: BTreeMap<String, Vec<String>>,
pub missing_key_locations_by_locale: BTreeMap<String, BTreeMap<String, Vec<LocationEntry>>>,
pub unused_by_locale: BTreeMap<String, Vec<String>>,
pub stats: ReportStats,
pub meta: ReportMeta,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocationEntry {
pub file: String,
pub line: usize,
pub column: usize,
pub char_pos: usize,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DynamicKeyEntry {
pub file: String,
pub line: usize,
pub expression: String,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocaleStats {
pub used: usize,
pub missing: usize,
pub unused: usize,
pub total_translations: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportStats {
pub used_keys_total: usize,
pub dynamic_keys_total: usize,
pub missing_total: usize,
pub unused_total: usize,
pub per_locale: BTreeMap<String, LocaleStats>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportMeta {
pub timestamp: String,
pub paths: Vec<String>,
pub exclude: Vec<String>,
pub locales: Vec<String>,
pub lang_paths: Vec<String>,
pub warnings: Vec<String>,
pub detailed_log_path: String,
pub dashboard_url: String,
}
impl Report {
pub fn to_pretty_json(&self) -> String {
serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
}
}
pub struct ReportRenderer;
impl ReportRenderer {
pub fn render_table(report: &Report, only_missing: bool, only_unused: bool) -> String {
let mut output = String::new();
output.push_str("I18n Audit Summary\n");
output.push_str(&format!("Used keys: {}\n", report.stats.used_keys_total));
output.push_str(&format!("Dynamic warnings: {}\n", report.stats.dynamic_keys_total));
output.push_str(&format!("Locales scanned: {}\n\n", report.meta.locales.join(", ")));
let mut headers = vec!["Locale", "Used"];
if !only_unused {
headers.push("Missing");
}
if !only_missing {
headers.push("Unused");
}
headers.push("Total translations");
output.push_str(&headers.join(" | "));
output.push('\n');
output.push_str(&"-".repeat(headers.join(" | ").len()));
output.push('\n');
for locale in &report.meta.locales {
let stats = report.stats.per_locale.get(locale).cloned().unwrap_or(LocaleStats {
used: 0,
missing: report.missing_by_locale.get(locale).map_or(0, Vec::len),
unused: report.unused_by_locale.get(locale).map_or(0, Vec::len),
total_translations: 0,
});
let mut cols = vec![locale.clone(), stats.used.to_string()];
if !only_unused {
cols.push(stats.missing.to_string());
}
if !only_missing {
cols.push(stats.unused.to_string());
}
cols.push(stats.total_translations.to_string());
output.push_str(&cols.join(" | "));
output.push('\n');
}
if !report.meta.detailed_log_path.is_empty() {
output.push_str(&format!("\nDetailed report log: {}\n", report.meta.detailed_log_path));
if !report.meta.dashboard_url.is_empty() {
output.push_str(&format!("Visit URL: {}\n", report.meta.dashboard_url));
}
}
if !report.meta.warnings.is_empty() {
output.push_str("\nLoader warnings:\n");
for warning in &report.meta.warnings {
output.push_str(&format!("- {}\n", warning));
}
}
output
}
}
pub struct ReportHtmlRenderer;
impl ReportHtmlRenderer {
pub fn render(report: &Report) -> String {
let locales = if report.meta.locales.is_empty() {
let mut merged = report
.stats
.per_locale
.keys()
.chain(report.missing_by_locale.keys())
.chain(report.unused_by_locale.keys())
.cloned()
.collect::<Vec<_>>();
merged.sort();
merged.dedup();
merged
} else {
report.meta.locales.clone()
};
let active_locale = locales.first().cloned().unwrap_or_default();
let mut summary_rows = String::new();
for locale in &locales {
let row = report.stats.per_locale.get(locale).cloned().unwrap_or(LocaleStats {
used: 0,
missing: report.missing_by_locale.get(locale).map_or(0, Vec::len),
unused: report.unused_by_locale.get(locale).map_or(0, Vec::len),
total_translations: 0,
});
summary_rows.push_str(&format!(
"<tr class=\"border-t border-slate-200\"><td class=\"px-3 py-2 font-medium\">{}</td><td class=\"px-3 py-2\">{}</td><td class=\"px-3 py-2\">{}</td><td class=\"px-3 py-2\">{}</td><td class=\"px-3 py-2\">{}</td></tr>",
escape_html(locale), row.used, row.missing, row.unused, row.total_translations
));
}
if summary_rows.is_empty() {
summary_rows = "<tr class=\"border-t border-slate-200\"><td colspan=\"5\" class=\"px-3 py-3 text-sm text-slate-500\">No locale summary rows found.</td></tr>".to_string();
}
let mut tabs = String::new();
let mut locale_panels = String::new();
for locale in &locales {
let quoted = quote_for_js(locale);
tabs.push_str(&format!(
"<button type=\"button\" class=\"px-3 py-1.5 rounded border text-sm\" :class=\"active === {0} ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-700 border-slate-300'\" @click=\"active = {0}\">{1}</button>",
quoted,
escape_html(locale)
));
let mut missing_list = String::new();
if let Some(keys) = report.missing_by_locale.get(locale) {
for key in keys {
missing_list.push_str(&format!("<li>{}</li>", escape_html(key)));
}
}
if missing_list.is_empty() {
missing_list = "<p class=\"text-sm text-slate-500\">No missing keys.</p>".to_string();
} else {
missing_list = format!("<ul class=\"list-disc pl-5 text-sm space-y-1\">{}</ul>", missing_list);
}
let mut unused_list = String::new();
if let Some(keys) = report.unused_by_locale.get(locale) {
for key in keys {
unused_list.push_str(&format!("<li>{}</li>", escape_html(key)));
}
}
if unused_list.is_empty() {
unused_list = "<p class=\"text-sm text-slate-500\">No unused keys.</p>".to_string();
} else {
unused_list = format!("<ul class=\"list-disc pl-5 text-sm space-y-1\">{}</ul>", unused_list);
}
let mut missing_locations_block = String::new();
if let Some(by_key) = report.missing_key_locations_by_locale.get(locale) {
for (key, locations) in by_key {
missing_locations_block.push_str("<div class=\"rounded border border-slate-200 bg-slate-50 p-2\">");
missing_locations_block.push_str(&format!(
"<p class=\"font-mono text-sm font-semibold\">{}</p><ul class=\"mt-2 space-y-1\">",
escape_html(key)
));
for location in locations {
let pointer = format!("{}:{}:{}", location.file, location.line, location.column);
missing_locations_block.push_str(&format!(
"<li class=\"flex items-center gap-2 text-sm\"><code class=\"bg-white px-1.5 py-0.5 rounded border border-slate-200\">{0}</code><button type=\"button\" class=\"px-2 py-0.5 rounded border border-slate-300 text-xs hover:bg-slate-100\" data-copy=\"{0}\">Copy</button></li>",
escape_html(&pointer)
));
}
missing_locations_block.push_str("</ul></div>");
}
}
if missing_locations_block.is_empty() {
missing_locations_block = "<p class=\"text-sm text-slate-500\">No missing key locations.</p>".to_string();
}
let missing_count = report.missing_by_locale.get(locale).map_or(0, Vec::len);
let unused_count = report.unused_by_locale.get(locale).map_or(0, Vec::len);
locale_panels.push_str(&format!(
"<div x-show=\"active === {0}\" class=\"space-y-4\"><div class=\"grid grid-cols-1 lg:grid-cols-2 gap-4\"><div class=\"rounded border border-slate-200 p-3\"><h3 class=\"font-semibold mb-2\">Missing Keys ({1})</h3><div class=\"max-h-72 overflow-auto\">{2}</div></div><div class=\"rounded border border-slate-200 p-3\"><h3 class=\"font-semibold mb-2\">Unused Keys ({3})</h3><div class=\"max-h-72 overflow-auto\">{4}</div></div></div><div class=\"rounded border border-slate-200 p-3\"><h3 class=\"font-semibold mb-2\">Missing Key Locations</h3>{5}</div></div>",
quoted, missing_count, missing_list, unused_count, unused_list, missing_locations_block
));
}
let mut used_locations_block = String::new();
for (key, locations) in &report.used_key_locations {
used_locations_block.push_str("<div class=\"rounded border border-slate-200 bg-slate-50 p-2\">");
used_locations_block.push_str(&format!(
"<p class=\"font-mono text-sm font-semibold\">{}</p><ul class=\"mt-2 space-y-1\">",
escape_html(key)
));
for location in locations {
let pointer = format!("{}:{}:{}", location.file, location.line, location.column);
used_locations_block.push_str(&format!(
"<li class=\"flex flex-wrap items-center gap-2 text-sm\"><code class=\"bg-white px-1.5 py-0.5 rounded border border-slate-200\">{0}</code><span class=\"text-slate-500\">char {1}</span><span class=\"text-slate-500\">source {2}</span><button type=\"button\" class=\"px-2 py-0.5 rounded border border-slate-300 text-xs hover:bg-slate-100\" data-copy=\"{0}\">Copy</button></li>",
escape_html(&pointer),
location.char_pos,
escape_html(&location.source)
));
}
used_locations_block.push_str("</ul></div>");
}
if used_locations_block.is_empty() {
used_locations_block = "<p class=\"text-sm text-slate-500\">No used key location entries found.</p>".to_string();
}
let mut dynamic_rows = String::new();
for warning in &report.dynamic_keys {
dynamic_rows.push_str(&format!(
"<tr class=\"border-t border-slate-200\"><td class=\"px-3 py-2 font-mono\">{}</td><td class=\"px-3 py-2\">{}</td><td class=\"px-3 py-2\">{}</td><td class=\"px-3 py-2 font-mono\">{}</td></tr>",
escape_html(&warning.file),
warning.line,
escape_html(&warning.source),
escape_html(&warning.expression)
));
}
let dynamic_block = if dynamic_rows.is_empty() {
"<p class=\"text-sm text-slate-500\">No dynamic warnings.</p>".to_string()
} else {
format!(
"<div class=\"overflow-x-auto\"><table class=\"min-w-full text-sm\"><thead><tr class=\"bg-slate-100 text-slate-700\"><th class=\"text-left px-3 py-2\">File</th><th class=\"text-left px-3 py-2\">Line</th><th class=\"text-left px-3 py-2\">Source</th><th class=\"text-left px-3 py-2\">Expression</th></tr></thead><tbody>{}</tbody></table></div>",
dynamic_rows
)
};
let raw_json = report.to_pretty_json();
format!(
"<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\">
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
<title>I18n Audit Dashboard</title>
<script src=\"https://cdn.tailwindcss.com\"></script>
</head>
<body class=\"bg-slate-50 text-slate-900 min-h-screen\">
<div class=\"max-w-7xl mx-auto p-6 space-y-6\">
<header class=\"space-y-2\">
<h1 class=\"text-3xl font-bold\">I18n Audit Dashboard</h1>
<p class=\"text-sm text-slate-600\">Static full inspection page for the latest audit payload.</p>
<p class=\"text-sm text-slate-600\">Run <span class=\"font-mono bg-slate-100 px-1 rounded\">i18n-audit-rust --format json</span> to refresh data.</p>
</header>
<section class=\"grid grid-cols-1 lg:grid-cols-3 gap-4\">
<div class=\"rounded-lg bg-white border border-slate-200 p-4\">
<p class=\"text-xs uppercase tracking-wide text-slate-500\">Timestamp</p>
<p class=\"font-mono text-sm mt-1\">{}</p>
</div>
<div class=\"rounded-lg bg-white border border-slate-200 p-4\">
<p class=\"text-xs uppercase tracking-wide text-slate-500\">Dashboard URL</p>
<p class=\"font-mono text-sm mt-1 break-all\">{}</p>
</div>
<div class=\"rounded-lg bg-white border border-slate-200 p-4\">
<p class=\"text-xs uppercase tracking-wide text-slate-500\">Detailed Log</p>
<p class=\"font-mono text-sm mt-1 break-all\">{}</p>
</div>
</section>
<section class=\"rounded-lg bg-white border border-slate-200 p-4\">
<h2 class=\"text-lg font-semibold mb-3\">Summary</h2>
<div class=\"overflow-x-auto\"><table class=\"min-w-full text-sm\"><thead><tr class=\"bg-slate-100 text-slate-700\"><th class=\"text-left px-3 py-2\">Locale</th><th class=\"text-left px-3 py-2\">Used</th><th class=\"text-left px-3 py-2\">Missing</th><th class=\"text-left px-3 py-2\">Unused</th><th class=\"text-left px-3 py-2\">Total</th></tr></thead><tbody>{}</tbody></table></div>
</section>
<section class=\"rounded-lg bg-white border border-slate-200 p-4\" x-data=\"{{ active: {} }}\">
<h2 class=\"text-lg font-semibold mb-3\">Per-locale Details</h2>
<p class=\"text-sm text-slate-600 mb-4\">Each tab shows missing keys, missing locations, and unused keys for that locale.</p>
<div class=\"flex flex-wrap gap-2 mb-4\">{}</div>
{}
</section>
<section class=\"rounded-lg bg-white border border-slate-200 p-4\">
<h2 class=\"text-lg font-semibold mb-2\">Dynamic Key Warnings</h2>
<p class=\"text-sm text-slate-600 mb-3\">These calls use non-literal expressions and cannot be resolved to concrete translation keys at scan time.</p>
{}
</section>
<section class=\"rounded-lg bg-white border border-slate-200 p-4\">
<h2 class=\"text-lg font-semibold mb-2\">Used Key Locations</h2>
<p class=\"text-sm text-slate-600 mb-3\">All detected literal translation usages with exact file, line, column, and char positions.</p>
<div class=\"max-h-[40vh] overflow-auto space-y-3 pr-1\">{}</div>
</section>
<section class=\"rounded-lg bg-white border border-slate-200 p-4\">
<h2 class=\"text-lg font-semibold mb-2\">Raw JSON Payload (Full)</h2>
<p class=\"text-sm text-slate-600 mb-3\">Complete payload used to build this static report.</p>
<pre class=\"bg-slate-900 text-slate-100 p-4 rounded overflow-auto max-h-[55vh] text-xs leading-relaxed\">{}</pre>
</section>
</div>
<script defer src=\"https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js\"></script>
<script>
document.querySelectorAll('button[data-copy]').forEach(function (button) {{
button.addEventListener('click', function () {{
const value = button.getAttribute('data-copy') || '';
navigator.clipboard.writeText(value);
const original = button.textContent;
button.textContent = 'Copied';
setTimeout(function () {{
button.textContent = original || 'Copy';
}}, 1200);
}});
}});
</script>
</body>
</html>",
escape_html(&report.meta.timestamp),
escape_html(&report.meta.dashboard_url),
escape_html(&report.meta.detailed_log_path),
summary_rows,
quote_for_js(&active_locale),
tabs,
locale_panels,
dynamic_block,
used_locations_block,
escape_html(&raw_json),
)
}
}
fn quote_for_js(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
fn escape_html(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}