i18n-audit 0.1.0

Rust i18n audit library and CLI for scanning translation usage, missing keys, and unused keys
Documentation
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('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}