use std::collections::HashSet;
use std::path::Path;
use anyhow::{Context, Result};
use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub scan: Scan,
#[serde(default)]
pub calls: Vec<CallSpec>,
#[serde(default)]
pub output: Output,
#[serde(default)]
pub whitelist: Whitelist,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Scan {
pub po_patterns: Vec<String>,
pub source_extensions: Vec<String>,
pub ignore_dirs: Vec<String>,
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()],
}
}
}
#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum CallKind {
Function,
Method,
Filter,
Index,
}
#[derive(Debug, Deserialize, Clone)]
pub struct CallSpec {
pub lang: String,
pub kind: CallKind,
pub name: String,
#[serde(default)]
pub receiver: Option<Vec<String>>,
#[serde(default)]
pub key_arg_index: usize,
}
#[derive(Debug, Deserialize)]
#[serde(default)]
pub struct Output {
pub mode: OutputMode,
pub format: OutputFormat,
pub fail_on: FailOn,
pub min_guard_len: usize,
}
impl Default for Output {
fn default() -> Self {
Self {
mode: OutputMode::default(),
format: OutputFormat::default(),
fail_on: FailOn::default(),
min_guard_len: 3,
}
}
}
#[derive(Debug, Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum OutputMode {
#[default]
Review,
}
#[derive(Debug, Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum OutputFormat {
#[default]
Text,
Json,
}
#[derive(Debug, Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "kebab-case")]
pub enum FailOn {
Never,
#[default]
Dead,
DeadOrBlind,
}
#[derive(Debug, Deserialize, Default)]
pub struct Whitelist {
#[serde(default)]
pub file: Option<String>,
#[serde(default)]
pub keys: Vec<String>,
}
impl Config {
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()))?;
Ok(cfg)
}
}
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::*;
#[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");
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());
}
#[test]
fn defaults_apply_when_fields_missing() {
let cfg: Config = toml::from_str("").expect("empty config is valid");
assert_eq!(cfg.output.min_guard_len, 3);
assert_eq!(cfg.output.fail_on, FailOn::Dead);
assert_eq!(cfg.output.format, OutputFormat::Text);
assert_eq!(cfg.output.mode, OutputMode::Review);
assert_eq!(cfg.scan.source_roots, vec![".".to_string()]);
let cfg2: Config =
toml::from_str("[[calls]]\nlang=\"js\"\nkind=\"function\"\nname=\"i18n\"").unwrap();
assert_eq!(cfg2.calls[0].key_arg_index, 0);
}
#[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);
}
#[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");
}
#[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();
}
#[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}"
);
}
}