1use std::path::{Path, PathBuf};
12
13use anyhow::Result;
14
15use crate::audit::{self, AuditReport};
16use crate::config::{Config, resolve_whitelist};
17use crate::liveness::{self, LivenessReport};
18use crate::{po, scan};
19
20#[derive(Debug)]
26pub struct Outcome {
27 pub config: Config,
28 pub report: LivenessReport,
29 pub audit: Option<AuditReport>,
30}
31
32pub fn run(config_path: &Path, project_root: &Path, run_audit: bool) -> Result<Outcome> {
39 let config = Config::load(config_path)?;
40
41 let config_dir = config_path
43 .parent()
44 .filter(|p| !p.as_os_str().is_empty())
45 .unwrap_or_else(|| Path::new("."));
46 let whitelist = resolve_whitelist(&config.whitelist, config_dir)?;
47
48 let roots: Vec<PathBuf> = config
50 .scan
51 .source_roots
52 .iter()
53 .map(|r| project_root.join(r))
54 .collect();
55
56 log::info!("scanning {} root(s) for PO catalogs", roots.len());
57 let index = po::load_index(&roots, &config.scan.po_patterns, &config.scan.ignore_dirs)?;
58 log::info!("PO universe: {} unique keys", index.len());
59
60 let usage = scan::scan_sources(
61 &roots,
62 &config.scan.source_extensions,
63 &config.scan.ignore_dirs,
64 &config.calls,
65 config.guard.min_len,
66 )?;
67 log::info!(
68 "usage: {} literals, {} guards, {} blind sites",
69 usage.literals.len(),
70 usage.guards.len(),
71 usage.blind.values().sum::<usize>(),
72 );
73
74 let report = liveness::classify(&index, &usage, &whitelist);
75
76 let audit = if run_audit {
79 let dead: Vec<&po::PoKey> = report.dead().map(|v| &v.key).collect();
80 log::info!("auditing {} dead keys against raw source", dead.len());
81 Some(audit::audit(
82 &dead,
83 &roots,
84 &config.scan.source_extensions,
85 &config.scan.ignore_dirs,
86 config.guard.min_len,
87 )?)
88 } else {
89 None
90 };
91
92 Ok(Outcome {
93 config,
94 report,
95 audit,
96 })
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102
103 fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
104 let path = dir.join(name);
105 if let Some(parent) = path.parent() {
106 std::fs::create_dir_all(parent).unwrap();
107 }
108 std::fs::write(&path, body).unwrap();
109 path
110 }
111
112 fn temp_dir(tag: &str) -> PathBuf {
113 let dir = std::env::temp_dir().join(format!("dead-poets-engine-{tag}"));
114 std::fs::remove_dir_all(&dir).ok();
115 std::fs::create_dir_all(&dir).unwrap();
116 dir
117 }
118
119 const HEADER: &str = "msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n\n";
120
121 #[test]
125 fn run_classifies_used_and_dead() {
126 let dir = temp_dir("classify");
127 write(
128 &dir,
129 "messages.po",
130 &format!("{HEADER}msgid \"used\"\nmsgstr \"u\"\n\nmsgid \"gone\"\nmsgstr \"g\"\n"),
131 );
132 write(&dir, "app.php", "<?php i18n('used'); ?>");
133 let cfg_path = write(
134 &dir,
135 "dp.toml",
136 "[scan]\npo_patterns = [\"**/*.po\"]\nsource_extensions = [\"php\"]\n\n\
137 [[calls]]\nlang = \"php\"\nkind = \"function\"\nname = \"i18n\"\n",
138 );
139
140 let out = run(&cfg_path, &dir, false).unwrap();
141 assert_eq!(out.report.alive_count(), 1);
142 assert_eq!(out.report.dead_count(), 1);
143 assert!(out.report.dead().any(|v| v.key.msgid == "gone"));
144 assert!(out.audit.is_none(), "no audit without run_audit");
145
146 let out = run(&cfg_path, &dir, true).unwrap();
147 assert!(out.audit.is_some(), "run_audit populates the audit");
148
149 std::fs::remove_dir_all(&dir).ok();
150 }
151}