1use std::collections::HashSet;
9use std::path::Path;
10
11use anyhow::{Context, Result, anyhow};
12use serde::Deserialize;
13
14#[derive(Debug, Deserialize, Default)]
16pub struct Config {
17 #[serde(default)]
18 pub scan: Scan,
19 #[serde(default)]
21 pub calls: Vec<CallSpec>,
22 #[serde(default)]
24 pub guard: GuardCfg,
25 #[serde(default)]
26 pub output: Output,
27 #[serde(default)]
28 pub whitelist: Whitelist,
29}
30
31#[derive(Debug, Deserialize)]
33#[serde(default)]
34pub struct Scan {
35 pub po_patterns: Vec<String>,
37 pub source_extensions: Vec<String>,
39 pub ignore_dirs: Vec<String>,
41 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#[derive(Debug, Deserialize, PartialEq, Eq, Clone, Copy)]
65#[serde(rename_all = "lowercase")]
66pub enum CallKind {
67 Function,
69 Method,
71 Filter,
73 Index,
76}
77
78#[derive(Debug, Deserialize, Clone)]
80pub struct CallSpec {
81 pub lang: String,
83 pub kind: CallKind,
84 pub name: String,
89 #[serde(default)]
92 pub receiver: Option<Vec<String>>,
93 #[serde(default)]
95 pub key_arg_index: usize,
96}
97
98#[derive(Debug, Deserialize)]
102#[serde(default)]
103pub struct GuardCfg {
104 pub min_len: usize,
107}
108
109impl Default for GuardCfg {
110 fn default() -> Self {
111 Self { min_len: 3 }
112 }
113}
114
115#[derive(Debug, Deserialize, Default)]
117#[serde(default)]
118pub struct Output {
119 pub format: OutputFormat,
120 pub fail_on: FailOn,
121 pub min_guard_len: Option<usize>,
125 pub max_dead: Option<usize>,
129 pub max_dead_ratio: Option<f64>,
133}
134
135#[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#[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#[derive(Debug, Deserialize, Default)]
157pub struct Whitelist {
158 #[serde(default)]
160 pub file: Option<String>,
161 #[serde(default)]
163 pub keys: Vec<String>,
164}
165
166impl Config {
167 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
184pub 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 #[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 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 #[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 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 #[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 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 #[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 let cfg: Config = toml::from_str("").unwrap();
303 assert_eq!(cfg.guard.min_len, 3);
304 }
305
306 #[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 #[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 #[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 #[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 #[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}