Skip to main content

dead_poets/
config.rs

1//! Configuration model (`dead-poets.toml`).
2//!
3//! Everything project-specific lives here, never in code: the engine is a
4//! general crate, any repo its testbed. The schema must be expressive enough to
5//! describe a project like `example_repo` without code changes — hence the
6//! generalized `[[calls]]` model (function / method+receiver / filter).
7
8use std::collections::HashSet;
9use std::path::Path;
10
11use anyhow::{Context, Result, anyhow};
12use serde::Deserialize;
13
14/// Top-level config. Missing tables fall back to their defaults.
15#[derive(Debug, Deserialize, Default)]
16pub struct Config {
17    #[serde(default)]
18    pub scan: Scan,
19    /// Translation call sites to look for. `[[calls]]` array.
20    #[serde(default)]
21    pub calls: Vec<CallSpec>,
22    /// `[guard]` — how dynamic-key fragments form keep-alive guards.
23    #[serde(default)]
24    pub guard: GuardCfg,
25    #[serde(default)]
26    pub output: Output,
27    #[serde(default)]
28    pub whitelist: Whitelist,
29}
30
31/// `[scan]` — where to find PO files and source.
32#[derive(Debug, Deserialize)]
33#[serde(default)]
34pub struct Scan {
35    /// Glob patterns for PO/POT catalogs.
36    pub po_patterns: Vec<String>,
37    /// Source file extensions to scan.
38    pub source_extensions: Vec<String>,
39    /// Extra directories to ignore (on top of `.gitignore`).
40    pub ignore_dirs: Vec<String>,
41    /// Source roots. Always a list, even for a single root, so multi-repo
42    /// coverage is a config change, not a code change.
43    pub source_roots: Vec<String>,
44}
45
46impl Default for Scan {
47    fn default() -> Self {
48        Self {
49            po_patterns: vec!["**/*.po".into(), "**/*.pot".into()],
50            source_extensions: ["php", "twig", "js", "ts", "jsx", "tsx"]
51                .iter()
52                .map(|s| s.to_string())
53                .collect(),
54            ignore_dirs: ["vendor", "node_modules", "cache", "var"]
55                .iter()
56                .map(|s| s.to_string())
57                .collect(),
58            source_roots: vec![".".into()],
59        }
60    }
61}
62
63/// What kind of call site this `[[calls]]` entry describes.
64#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
65#[serde(rename_all = "lowercase")]
66pub enum CallKind {
67    /// Free function: `i18n('key')`.
68    Function,
69    /// Method with a receiver constraint: `$i18n->get('key')`.
70    Method,
71    /// Twig filter: `{{ 'key'|i18n }}`.
72    Filter,
73    /// Index access into a translation map: `locale['key']` (JS lang bundles).
74    /// `name` holds the indexed object (`locale`); the subscript is the key.
75    Index,
76}
77
78/// A single translation call site descriptor (`[[calls]]`).
79#[derive(Debug, Deserialize, Clone)]
80pub struct CallSpec {
81    /// Language this matcher applies to (`php`, `js`, `twig`, ...).
82    pub lang: String,
83    pub kind: CallKind,
84    /// What this matches, per `kind`:
85    /// - `function` / `method` / `filter` → the call/filter name (`i18n`, `get`);
86    /// - `index` → the indexed object identifier (`locale` in `locale['key']`),
87    ///   while the subscript carries the key.
88    pub name: String,
89    /// For `method`: allowed receivers (e.g. `["i18n", "this.i18n"]`). The `$`
90    /// and `->`/`.` are normalized away by the extractor.
91    #[serde(default)]
92    pub receiver: Option<Vec<String>>,
93    /// Which argument holds the key (0-based). Defaults to 0.
94    #[serde(default)]
95    pub key_arg_index: usize,
96}
97
98/// `[guard]` — classification tuning for dynamic-key guards. Lives outside
99/// `[output]` because it shapes *liveness* (which keys are Alive via a guard, and
100/// the audit skeleton tier), not reporting.
101#[derive(Debug, Deserialize)]
102#[serde(default)]
103pub struct GuardCfg {
104    /// Minimum static-fragment length that may form a guard (and the smallest
105    /// skeleton fragment the audit trusts).
106    pub min_len: usize,
107}
108
109impl Default for GuardCfg {
110    fn default() -> Self {
111        Self { min_len: 3 }
112    }
113}
114
115/// `[output]` — reporting and exit-code policy.
116#[derive(Debug, Deserialize, Default)]
117#[serde(default)]
118pub struct Output {
119    pub format: OutputFormat,
120    pub fail_on: FailOn,
121    /// Migration trap: `min_guard_len` moved to `[guard] min_len`. A value here is
122    /// rejected loudly by [`Config::load`] so the move is never silent (an ignored
123    /// key would revert the guard length to its default).
124    pub min_guard_len: Option<usize>,
125    /// Dead-key budget (ratchet): fail only when the Dead bucket exceeds the cap.
126    /// Absolute cap — fail when `dead_count > max_dead`. Mutually exclusive with
127    /// `max_dead_ratio`. Absent → any dead fails (the historical default).
128    pub max_dead: Option<usize>,
129    /// Dead-key budget as a share of the whole PO universe (0.0–1.0) — fail when
130    /// `dead_count / total_keys > max_dead_ratio`. Mutually exclusive with
131    /// `max_dead`.
132    pub max_dead_ratio: Option<f64>,
133}
134
135/// Report format.
136#[derive(Debug, Deserialize, Default, PartialEq, Eq, Clone, Copy)]
137#[serde(rename_all = "lowercase")]
138pub enum OutputFormat {
139    #[default]
140    Text,
141    Json,
142}
143
144/// Exit-code policy.
145#[derive(Debug, Deserialize, Default, PartialEq, Eq, Clone, Copy)]
146#[serde(rename_all = "kebab-case")]
147pub enum FailOn {
148    Never,
149    #[default]
150    Dead,
151    DeadOrBlind,
152}
153
154/// `[whitelist]` — keys to always treat as alive (dynamic keys static analysis
155/// can't see: DB/config/external).
156#[derive(Debug, Deserialize, Default)]
157pub struct Whitelist {
158    /// Path to a file with one key per line (relative to the config dir).
159    #[serde(default)]
160    pub file: Option<String>,
161    /// Inline keys.
162    #[serde(default)]
163    pub keys: Vec<String>,
164}
165
166impl Config {
167    /// Load and parse a config file.
168    pub fn load(path: &Path) -> Result<Self> {
169        let text = std::fs::read_to_string(path)
170            .with_context(|| format!("cannot read config file: {}", path.display()))?;
171        let cfg: Config =
172            toml::from_str(&text).with_context(|| format!("invalid config: {}", path.display()))?;
173        if cfg.output.min_guard_len.is_some() {
174            return Err(anyhow!(
175                "{}: `min_guard_len` moved from [output] to [guard] min_len — \
176                 update your config (a guard length is no longer an output knob)",
177                path.display()
178            ));
179        }
180        Ok(cfg)
181    }
182}
183
184/// Merge inline whitelist keys with keys read from the whitelist file (if any),
185/// resolving the file relative to `base_dir`. A missing file is a hard, clearly
186/// labelled error so a typo'd path never silently disables the whitelist.
187pub fn resolve_whitelist(wl: &Whitelist, base_dir: &Path) -> Result<HashSet<String>> {
188    let mut set: HashSet<String> = wl.keys.iter().cloned().collect();
189    if let Some(file) = &wl.file {
190        let path = base_dir.join(file);
191        let content = std::fs::read_to_string(&path)
192            .with_context(|| format!("whitelist file not found: {}", path.display()))?;
193        for line in content.lines() {
194            let key = line.trim();
195            if !key.is_empty() && !key.starts_with('#') {
196                set.insert(key.to_string());
197            }
198        }
199    }
200    Ok(set)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    /// The example_repo config must deserialize into three correct
208    /// call specs, and the omitted `source_roots` must default to `["."]`.
209    #[test]
210    fn parses_example_repo_config() {
211        let toml_src = r#"
212            [scan]
213            po_patterns = ["app/libs/locale/locales/*/LC_MESSAGES/messages.po"]
214            source_extensions = ["php", "twig", "js", "ts", "jsx", "tsx"]
215            ignore_dirs = ["vendor", "node_modules", "cache", "build"]
216
217            [[calls]]
218            lang = "php"
219            kind = "method"
220            name = "get"
221            receiver = ["i18n", "this.i18n"]
222            key_arg_index = 0
223
224            [[calls]]
225            lang = "js"
226            kind = "function"
227            name = "i18n"
228            key_arg_index = 0
229
230            [[calls]]
231            lang = "twig"
232            kind = "filter"
233            name = "i18n"
234
235            [output]
236            mode = "review"
237            format = "text"
238            fail_on = "dead"
239        "#;
240        let cfg: Config = toml::from_str(toml_src).expect("should deserialize");
241
242        // source_roots omitted -> default ["."]
243        assert_eq!(cfg.scan.source_roots, vec![".".to_string()]);
244
245        assert_eq!(cfg.calls.len(), 3);
246
247        let php = &cfg.calls[0];
248        assert_eq!(php.lang, "php");
249        assert_eq!(php.kind, CallKind::Method);
250        assert_eq!(php.name, "get");
251        assert_eq!(
252            php.receiver.as_deref(),
253            Some(["i18n".to_string(), "this.i18n".to_string()].as_slice())
254        );
255        assert_eq!(php.key_arg_index, 0);
256
257        assert_eq!(cfg.calls[1].kind, CallKind::Function);
258        assert_eq!(cfg.calls[1].name, "i18n");
259
260        let twig = &cfg.calls[2];
261        assert_eq!(twig.kind, CallKind::Filter);
262        assert!(twig.receiver.is_none());
263    }
264
265    /// Omitted fields fall back to documented defaults.
266    #[test]
267    fn defaults_apply_when_fields_missing() {
268        let cfg: Config = toml::from_str("").expect("empty config is valid");
269        assert_eq!(cfg.guard.min_len, 3);
270        assert_eq!(cfg.output.fail_on, FailOn::Dead);
271        assert_eq!(cfg.output.format, OutputFormat::Text);
272        assert_eq!(cfg.scan.source_roots, vec![".".to_string()]);
273        // a [[calls]] entry without key_arg_index defaults to 0
274        let cfg2: Config =
275            toml::from_str("[[calls]]\nlang=\"js\"\nkind=\"function\"\nname=\"i18n\"").unwrap();
276        assert_eq!(cfg2.calls[0].key_arg_index, 0);
277    }
278
279    /// The dead-key budget knobs parse when present and default to `None`.
280    #[test]
281    fn dead_budget_knobs_parse() {
282        let cfg: Config = toml::from_str("[output]\nmax_dead = 2900").unwrap();
283        assert_eq!(cfg.output.max_dead, Some(2900));
284        assert_eq!(cfg.output.max_dead_ratio, None);
285
286        let cfg: Config = toml::from_str("[output]\nmax_dead_ratio = 0.15").unwrap();
287        assert_eq!(cfg.output.max_dead_ratio, Some(0.15));
288        assert_eq!(cfg.output.max_dead, None);
289
290        // Absent in an empty config -> both None.
291        let cfg: Config = toml::from_str("").unwrap();
292        assert_eq!(cfg.output.max_dead, None);
293        assert_eq!(cfg.output.max_dead_ratio, None);
294    }
295
296    /// `[guard] min_len` parses and overrides the default.
297    #[test]
298    fn guard_min_len_parses() {
299        let cfg: Config = toml::from_str("[guard]\nmin_len = 5").unwrap();
300        assert_eq!(cfg.guard.min_len, 5);
301        // Absent -> default 3.
302        let cfg: Config = toml::from_str("").unwrap();
303        assert_eq!(cfg.guard.min_len, 3);
304    }
305
306    /// A config still carrying `[output] min_guard_len` is rejected loudly, so the
307    /// move to `[guard] min_len` is never a silent behaviour change.
308    #[test]
309    fn legacy_min_guard_len_is_loud_error() {
310        let dir = std::env::temp_dir().join("dead-poets-cfg-migrate");
311        std::fs::create_dir_all(&dir).unwrap();
312        let path = dir.join("dp.toml");
313        std::fs::write(&path, "[output]\nmin_guard_len = 4\n").unwrap();
314
315        let err = Config::load(&path).unwrap_err();
316        let msg = format!("{err:#}");
317        assert!(msg.contains("min_guard_len"), "names the moved key: {msg}");
318        assert!(msg.contains("[guard]"), "points to the new home: {msg}");
319
320        std::fs::remove_dir_all(&dir).ok();
321    }
322
323    /// `dead-or-blind` is a valid fail_on value (kebab-case mapping).
324    #[test]
325    fn fail_on_kebab_case_roundtrips() {
326        let cfg: Config = toml::from_str("[output]\nfail_on = \"dead-or-blind\"").unwrap();
327        assert_eq!(cfg.output.fail_on, FailOn::DeadOrBlind);
328    }
329
330    /// An unknown fail_on value is a configuration error.
331    #[test]
332    fn unknown_fail_on_is_error() {
333        let err = toml::from_str::<Config>("[output]\nfail_on = \"sometimes\"");
334        assert!(err.is_err(), "unknown fail_on must fail to parse");
335    }
336
337    /// File keys and inline keys merge into one set.
338    #[test]
339    fn whitelist_merges_file_and_inline() {
340        let dir = std::env::temp_dir().join("dead-poets-wl-test");
341        std::fs::create_dir_all(&dir).unwrap();
342        let file = dir.join("wl.txt");
343        std::fs::write(&file, "from.file.1\n# comment\n\nfrom.file.2\n").unwrap();
344
345        let wl = Whitelist {
346            file: Some("wl.txt".to_string()),
347            keys: vec!["inline.1".to_string()],
348        };
349        let set = resolve_whitelist(&wl, &dir).unwrap();
350        assert_eq!(set.len(), 3);
351        assert!(set.contains("from.file.1"));
352        assert!(set.contains("from.file.2"));
353        assert!(set.contains("inline.1"));
354
355        std::fs::remove_dir_all(&dir).ok();
356    }
357
358    /// A missing whitelist file produces a clear error mentioning the path.
359    #[test]
360    fn whitelist_missing_file_is_clear_error() {
361        let wl = Whitelist {
362            file: Some("nope.txt".to_string()),
363            keys: vec![],
364        };
365        let err = resolve_whitelist(&wl, Path::new("/tmp/definitely-missing-dir-xyz")).unwrap_err();
366        let msg = format!("{err:#}");
367        assert!(
368            msg.contains("nope.txt"),
369            "error should name the path: {msg}"
370        );
371    }
372}