dead-poets 0.3.0

Find unused (dead) gettext PO/POT keys across a polyglot codebase (PHP, Twig, JS/TS) via real AST parsing.
Documentation
//! Configuration model (`dead-poets.toml`).
//!
//! Everything project-specific lives here, never in code: the engine is a
//! general crate, any repo its testbed. The schema must be expressive enough to
//! describe a project like `example_repo` without code changes — hence the
//! generalized `[[calls]]` model (function / method+receiver / filter).

use std::collections::HashSet;
use std::path::Path;

use anyhow::{Context, Result, anyhow};
use serde::Deserialize;

/// Top-level config. Missing tables fall back to their defaults.
#[derive(Debug, Deserialize, Default)]
pub struct Config {
    #[serde(default)]
    pub scan: Scan,
    /// Translation call sites to look for. `[[calls]]` array.
    #[serde(default)]
    pub calls: Vec<CallSpec>,
    /// `[guard]` — how dynamic-key fragments form keep-alive guards.
    #[serde(default)]
    pub guard: GuardCfg,
    #[serde(default)]
    pub output: Output,
    #[serde(default)]
    pub whitelist: Whitelist,
}

/// `[scan]` — where to find PO files and source.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Scan {
    /// Glob patterns for PO/POT catalogs.
    pub po_patterns: Vec<String>,
    /// Source file extensions to scan.
    pub source_extensions: Vec<String>,
    /// Extra directories to ignore (on top of `.gitignore`).
    pub ignore_dirs: Vec<String>,
    /// Source roots. Always a list, even for a single root, so multi-repo
    /// coverage is a config change, not a code change.
    pub source_roots: Vec<String>,
}

impl Default for Scan {
    fn default() -> Self {
        Self {
            po_patterns: vec!["**/*.po".into(), "**/*.pot".into()],
            source_extensions: ["php", "twig", "js", "ts", "jsx", "tsx"]
                .iter()
                .map(|s| s.to_string())
                .collect(),
            ignore_dirs: ["vendor", "node_modules", "cache", "var"]
                .iter()
                .map(|s| s.to_string())
                .collect(),
            source_roots: vec![".".into()],
        }
    }
}

/// What kind of call site this `[[calls]]` entry describes.
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum CallKind {
    /// Free function: `i18n('key')`.
    Function,
    /// Method with a receiver constraint: `$i18n->get('key')`.
    Method,
    /// Twig filter: `{{ 'key'|i18n }}`.
    Filter,
    /// Index access into a translation map: `locale['key']` (JS lang bundles).
    /// `name` holds the indexed object (`locale`); the subscript is the key.
    Index,
}

/// A single translation call site descriptor (`[[calls]]`).
#[derive(Debug, Deserialize, Clone)]
pub struct CallSpec {
    /// Language this matcher applies to (`php`, `js`, `twig`, ...).
    pub lang: String,
    pub kind: CallKind,
    /// What this matches, per `kind`:
    /// - `function` / `method` / `filter` → the call/filter name (`i18n`, `get`);
    /// - `index` → the indexed object identifier (`locale` in `locale['key']`),
    ///   while the subscript carries the key.
    pub name: String,
    /// For `method`: allowed receivers (e.g. `["i18n", "this.i18n"]`). The `$`
    /// and `->`/`.` are normalized away by the extractor.
    #[serde(default)]
    pub receiver: Option<Vec<String>>,
    /// Which argument holds the key (0-based). Defaults to 0.
    #[serde(default)]
    pub key_arg_index: usize,
}

/// `[guard]` — classification tuning for dynamic-key guards. Lives outside
/// `[output]` because it shapes *liveness* (which keys are Alive via a guard, and
/// the audit skeleton tier), not reporting.
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct GuardCfg {
    /// Minimum static-fragment length that may form a guard (and the smallest
    /// skeleton fragment the audit trusts).
    pub min_len: usize,
}

impl Default for GuardCfg {
    fn default() -> Self {
        Self { min_len: 3 }
    }
}

/// `[output]` — reporting and exit-code policy.
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct Output {
    pub format: OutputFormat,
    pub fail_on: FailOn,
    /// Migration trap: `min_guard_len` moved to `[guard] min_len`. A value here is
    /// rejected loudly by [`Config::load`] so the move is never silent (an ignored
    /// key would revert the guard length to its default).
    pub min_guard_len: Option<usize>,
    /// Dead-key budget (ratchet): fail only when the Dead bucket exceeds the cap.
    /// Absolute cap — fail when `dead_count > max_dead`. Mutually exclusive with
    /// `max_dead_ratio`. Absent → any dead fails (the historical default).
    pub max_dead: Option<usize>,
    /// Dead-key budget as a share of the whole PO universe (0.0–1.0) — fail when
    /// `dead_count / total_keys > max_dead_ratio`. Mutually exclusive with
    /// `max_dead`.
    pub max_dead_ratio: Option<f64>,
}

/// Report format.
#[derive(Debug, Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
    #[default]
    Text,
    Json,
}

/// Exit-code policy.
#[derive(Debug, Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum FailOn {
    Never,
    #[default]
    Dead,
    DeadOrBlind,
}

/// `[whitelist]` — keys to always treat as alive (dynamic keys static analysis
/// can't see: DB/config/external).
#[derive(Debug, Deserialize, Default)]
pub struct Whitelist {
    /// Path to a file with one key per line (relative to the config dir).
    #[serde(default)]
    pub file: Option<String>,
    /// Inline keys.
    #[serde(default)]
    pub keys: Vec<String>,
}

impl Config {
    /// Load and parse a config file.
    pub fn load(path: &Path) -> Result<Self> {
        let text = std::fs::read_to_string(path)
            .with_context(|| format!("cannot read config file: {}", path.display()))?;
        let cfg: Config =
            toml::from_str(&text).with_context(|| format!("invalid config: {}", path.display()))?;
        if cfg.output.min_guard_len.is_some() {
            return Err(anyhow!(
                "{}: `min_guard_len` moved from [output] to [guard] min_len — \
                 update your config (a guard length is no longer an output knob)",
                path.display()
            ));
        }
        Ok(cfg)
    }
}

/// Merge inline whitelist keys with keys read from the whitelist file (if any),
/// resolving the file relative to `base_dir`. A missing file is a hard, clearly
/// labelled error so a typo'd path never silently disables the whitelist.
pub fn resolve_whitelist(wl: &Whitelist, base_dir: &Path) -> Result<HashSet<String>> {
    let mut set: HashSet<String> = wl.keys.iter().cloned().collect();
    if let Some(file) = &wl.file {
        let path = base_dir.join(file);
        let content = std::fs::read_to_string(&path)
            .with_context(|| format!("whitelist file not found: {}", path.display()))?;
        for line in content.lines() {
            let key = line.trim();
            if !key.is_empty() && !key.starts_with('#') {
                set.insert(key.to_string());
            }
        }
    }
    Ok(set)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The example_repo config must deserialize into three correct
    /// call specs, and the omitted `source_roots` must default to `["."]`.
    #[test]
    fn parses_example_repo_config() {
        let toml_src = r#"
            [scan]
            po_patterns = ["app/libs/locale/locales/*/LC_MESSAGES/messages.po"]
            source_extensions = ["php", "twig", "js", "ts", "jsx", "tsx"]
            ignore_dirs = ["vendor", "node_modules", "cache", "build"]

            [[calls]]
            lang = "php"
            kind = "method"
            name = "get"
            receiver = ["i18n", "this.i18n"]
            key_arg_index = 0

            [[calls]]
            lang = "js"
            kind = "function"
            name = "i18n"
            key_arg_index = 0

            [[calls]]
            lang = "twig"
            kind = "filter"
            name = "i18n"

            [output]
            mode = "review"
            format = "text"
            fail_on = "dead"
        "#;
        let cfg: Config = toml::from_str(toml_src).expect("should deserialize");

        // source_roots omitted -> default ["."]
        assert_eq!(cfg.scan.source_roots, vec![".".to_string()]);

        assert_eq!(cfg.calls.len(), 3);

        let php = &cfg.calls[0];
        assert_eq!(php.lang, "php");
        assert_eq!(php.kind, CallKind::Method);
        assert_eq!(php.name, "get");
        assert_eq!(
            php.receiver.as_deref(),
            Some(["i18n".to_string(), "this.i18n".to_string()].as_slice())
        );
        assert_eq!(php.key_arg_index, 0);

        assert_eq!(cfg.calls[1].kind, CallKind::Function);
        assert_eq!(cfg.calls[1].name, "i18n");

        let twig = &cfg.calls[2];
        assert_eq!(twig.kind, CallKind::Filter);
        assert!(twig.receiver.is_none());
    }

    /// Omitted fields fall back to documented defaults.
    #[test]
    fn defaults_apply_when_fields_missing() {
        let cfg: Config = toml::from_str("").expect("empty config is valid");
        assert_eq!(cfg.guard.min_len, 3);
        assert_eq!(cfg.output.fail_on, FailOn::Dead);
        assert_eq!(cfg.output.format, OutputFormat::Text);
        assert_eq!(cfg.scan.source_roots, vec![".".to_string()]);
        // a [[calls]] entry without key_arg_index defaults to 0
        let cfg2: Config =
            toml::from_str("[[calls]]\nlang=\"js\"\nkind=\"function\"\nname=\"i18n\"").unwrap();
        assert_eq!(cfg2.calls[0].key_arg_index, 0);
    }

    /// The dead-key budget knobs parse when present and default to `None`.
    #[test]
    fn dead_budget_knobs_parse() {
        let cfg: Config = toml::from_str("[output]\nmax_dead = 2900").unwrap();
        assert_eq!(cfg.output.max_dead, Some(2900));
        assert_eq!(cfg.output.max_dead_ratio, None);

        let cfg: Config = toml::from_str("[output]\nmax_dead_ratio = 0.15").unwrap();
        assert_eq!(cfg.output.max_dead_ratio, Some(0.15));
        assert_eq!(cfg.output.max_dead, None);

        // Absent in an empty config -> both None.
        let cfg: Config = toml::from_str("").unwrap();
        assert_eq!(cfg.output.max_dead, None);
        assert_eq!(cfg.output.max_dead_ratio, None);
    }

    /// `[guard] min_len` parses and overrides the default.
    #[test]
    fn guard_min_len_parses() {
        let cfg: Config = toml::from_str("[guard]\nmin_len = 5").unwrap();
        assert_eq!(cfg.guard.min_len, 5);
        // Absent -> default 3.
        let cfg: Config = toml::from_str("").unwrap();
        assert_eq!(cfg.guard.min_len, 3);
    }

    /// A config still carrying `[output] min_guard_len` is rejected loudly, so the
    /// move to `[guard] min_len` is never a silent behaviour change.
    #[test]
    fn legacy_min_guard_len_is_loud_error() {
        let dir = std::env::temp_dir().join("dead-poets-cfg-migrate");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("dp.toml");
        std::fs::write(&path, "[output]\nmin_guard_len = 4\n").unwrap();

        let err = Config::load(&path).unwrap_err();
        let msg = format!("{err:#}");
        assert!(msg.contains("min_guard_len"), "names the moved key: {msg}");
        assert!(msg.contains("[guard]"), "points to the new home: {msg}");

        std::fs::remove_dir_all(&dir).ok();
    }

    /// `dead-or-blind` is a valid fail_on value (kebab-case mapping).
    #[test]
    fn fail_on_kebab_case_roundtrips() {
        let cfg: Config = toml::from_str("[output]\nfail_on = \"dead-or-blind\"").unwrap();
        assert_eq!(cfg.output.fail_on, FailOn::DeadOrBlind);
    }

    /// An unknown fail_on value is a configuration error.
    #[test]
    fn unknown_fail_on_is_error() {
        let err = toml::from_str::<Config>("[output]\nfail_on = \"sometimes\"");
        assert!(err.is_err(), "unknown fail_on must fail to parse");
    }

    /// File keys and inline keys merge into one set.
    #[test]
    fn whitelist_merges_file_and_inline() {
        let dir = std::env::temp_dir().join("dead-poets-wl-test");
        std::fs::create_dir_all(&dir).unwrap();
        let file = dir.join("wl.txt");
        std::fs::write(&file, "from.file.1\n# comment\n\nfrom.file.2\n").unwrap();

        let wl = Whitelist {
            file: Some("wl.txt".to_string()),
            keys: vec!["inline.1".to_string()],
        };
        let set = resolve_whitelist(&wl, &dir).unwrap();
        assert_eq!(set.len(), 3);
        assert!(set.contains("from.file.1"));
        assert!(set.contains("from.file.2"));
        assert!(set.contains("inline.1"));

        std::fs::remove_dir_all(&dir).ok();
    }

    /// A missing whitelist file produces a clear error mentioning the path.
    #[test]
    fn whitelist_missing_file_is_clear_error() {
        let wl = Whitelist {
            file: Some("nope.txt".to_string()),
            keys: vec![],
        };
        let err = resolve_whitelist(&wl, Path::new("/tmp/definitely-missing-dir-xyz")).unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("nope.txt"),
            "error should name the path: {msg}"
        );
    }
}