use std::path::{Path, PathBuf};
use std::process::Command;
const BIN: &str = env!("CARGO_BIN_EXE_dead-poets");
const PO_HEADER: &str = "msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=UTF-8\\n\"\n\n";
fn fixture_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("dead-poets-e2e-{tag}"));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write(dir: &Path, rel: &str, body: &str) {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&path, body).unwrap();
}
fn run_scan_json(root: &Path, config: &Path) -> (i32, serde_json::Value) {
let output = Command::new(BIN)
.arg("scan")
.arg(root)
.arg("--config")
.arg(config)
.arg("--format")
.arg("json")
.output()
.expect("binary runs");
let code = output.status.code().expect("process exited normally");
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
let json = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON ({e}):\n{stdout}"));
(code, json)
}
fn has_msgid(bucket: &serde_json::Value, msgid: &str) -> bool {
bucket
.as_array()
.unwrap()
.iter()
.any(|k| k["msgid"] == msgid)
}
fn alive_via<'a>(json: &'a serde_json::Value, msgid: &str) -> Option<&'a str> {
json["alive"]
.as_array()
.unwrap()
.iter()
.find(|k| k["msgid"] == msgid)
.and_then(|k| k["alive_via"].as_str())
}
#[test]
fn example_repo_shape_produces_all_four_outcomes() {
let dir = fixture_dir("four-outcomes");
write(
&dir,
"locale/en/LC_MESSAGES/messages.po",
&format!(
"{PO_HEADER}\
msgid \"save_button\"\nmsgstr \"Save\"\n\n\
msgid \"cf_subtype_red\"\nmsgstr \"Red\"\n\n\
msgid \"Delete email\"\nmsgstr \"Delete\"\n\n\
msgid \"totally_unused_key\"\nmsgstr \"Orphan\"\n"
),
);
write(
&dir,
"src/app.js",
"i18n('save_button');\n\
i18n(`cf_subtype_${kind}`);\n\
i18n(varKey);\n\
const m = locale['Delete email'];\n",
);
write(
&dir,
"dp.toml",
"[scan]\n\
po_patterns = [\"**/*.po\"]\n\
source_extensions = [\"js\"]\n\
\n\
[[calls]]\n\
lang = \"js\"\n\
kind = \"function\"\n\
name = \"i18n\"\n\
\n\
[[calls]]\n\
lang = \"js\"\n\
kind = \"index\"\n\
name = \"locale\"\n\
\n\
[output]\n\
fail_on = \"dead\"\n",
);
let (code, json) = run_scan_json(&dir, &dir.join("dp.toml"));
assert_eq!(alive_via(&json, "save_button"), Some("literal"));
assert_eq!(alive_via(&json, "cf_subtype_red"), Some("guard"));
assert_eq!(alive_via(&json, "Delete email"), Some("literal"));
assert!(has_msgid(&json["dead"], "totally_unused_key"));
assert!(!has_msgid(&json["alive"], "totally_unused_key"));
assert_eq!(json["blind"]["js"], 1);
assert_eq!(json["summary"]["dead"], 1);
assert_eq!(code, 1, "dead present under fail_on=dead -> exit 1");
std::fs::remove_dir_all(&dir).ok();
}
fn run_scan_audit_json(root: &Path, config: &Path) -> (i32, serde_json::Value) {
let output = Command::new(BIN)
.arg("scan")
.arg(root)
.arg("--config")
.arg(config)
.arg("--format")
.arg("json")
.arg("--audit")
.output()
.expect("binary runs");
let code = output.status.code().expect("process exited normally");
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
let json = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON ({e}):\n{stdout}"));
(code, json)
}
#[test]
fn audit_scores_dead_bucket_without_changing_exit_code() {
let dir = fixture_dir("audit");
write(
&dir,
"locale/en/LC_MESSAGES/messages.po",
&format!(
"{PO_HEADER}\
msgid \"used_lit\"\nmsgstr \"x\"\n\n\
msgid \"Ghost phrase here\"\nmsgstr \"x\"\n\n\
msgid \"Deleted %d rows\"\nmsgstr \"x\"\n\n\
msgid \"Nowhere at all\"\nmsgstr \"x\"\n"
),
);
write(
&dir,
"src/app.js",
"i18n('used_lit');\n\
// Ghost phrase here is only mentioned in this comment\n\
const s = 'Deleted ' + n + ' rows';\n",
);
write(
&dir,
"dp.toml",
"[scan]\n\
po_patterns = [\"**/*.po\"]\n\
source_extensions = [\"js\"]\n\
\n\
[[calls]]\n\
lang = \"js\"\n\
kind = \"function\"\n\
name = \"i18n\"\n\
\n\
[output]\n\
fail_on = \"dead\"\n",
);
let cfg = dir.join("dp.toml");
let (plain_code, plain_json) = run_scan_json(&dir, &cfg);
let (audit_code, audit_json) = run_scan_audit_json(&dir, &cfg);
assert_eq!(
plain_code, audit_code,
"audit must not change the exit code"
);
assert_eq!(plain_json["summary"]["dead"], audit_json["summary"]["dead"]);
assert!(
plain_json.get("audit").is_none(),
"no audit object without the flag"
);
let audit = &audit_json["audit"];
assert_eq!(audit["dead_total"], 3);
assert_eq!(audit["substring"], 1);
assert_eq!(audit["skeleton"], 1);
assert_eq!(audit["no_trace"], 1);
let traced = audit["traced"].as_array().unwrap();
let tier = |id: &str| {
traced
.iter()
.find(|t| t["msgid"] == id)
.map(|t| t["trace"].as_str().unwrap())
};
assert_eq!(tier("Ghost phrase here"), Some("substring"));
assert_eq!(tier("Deleted %d rows"), Some("skeleton"));
assert_eq!(tier("Nowhere at all"), None);
std::fs::remove_dir_all(&dir).ok();
}
fn run_scan_json_args(root: &Path, config: &Path, extra: &[&str]) -> (i32, serde_json::Value) {
let output = Command::new(BIN)
.arg("scan")
.arg(root)
.arg("--config")
.arg(config)
.arg("--format")
.arg("json")
.args(extra)
.output()
.expect("binary runs");
let code = output.status.code().expect("process exited normally");
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
let json = serde_json::from_str(&stdout)
.unwrap_or_else(|e| panic!("stdout is not valid JSON ({e}):\n{stdout}"));
(code, json)
}
#[test]
fn max_dead_budget_gates_exit_code_only() {
let dir = fixture_dir("budget");
write(
&dir,
"locale/en/LC_MESSAGES/messages.po",
&format!(
"{PO_HEADER}\
msgid \"used_key\"\nmsgstr \"Used\"\n\n\
msgid \"dead_one\"\nmsgstr \"Dead\"\n\n\
msgid \"dead_two\"\nmsgstr \"Dead\"\n"
),
);
write(&dir, "src/app.js", "i18n('used_key');\n");
write(
&dir,
"dp.toml",
"[scan]\n\
po_patterns = [\"**/*.po\"]\n\
source_extensions = [\"js\"]\n\
\n\
[[calls]]\n\
lang = \"js\"\n\
kind = \"function\"\n\
name = \"i18n\"\n\
\n\
[output]\n\
fail_on = \"dead\"\n",
);
let cfg = dir.join("dp.toml");
let (bare_code, bare_json) = run_scan_json(&dir, &cfg);
assert_eq!(bare_code, 1);
assert_eq!(bare_json["summary"]["dead"], 2);
let (ok_code, ok_json) = run_scan_json_args(&dir, &cfg, &["--max-dead", "2"]);
assert_eq!(ok_code, 0, "2 dead within a budget of 2 -> pass");
assert_eq!(ok_json["summary"]["dead"], 2, "classification untouched");
assert_eq!(ok_json["budget"]["over"], false);
assert_eq!(ok_json["budget"]["limit"], 2.0);
let (over_code, over_json) = run_scan_json_args(&dir, &cfg, &["--max-dead", "1"]);
assert_eq!(over_code, 1, "2 dead over a budget of 1 -> fail");
assert_eq!(over_json["budget"]["over"], true);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn different_repo_works_by_swapping_config_only() {
let dir = fixture_dir("standalone");
write(
&dir,
"i18n/messages.po",
&format!("{PO_HEADER}msgid \"greeting\"\nmsgstr \"Hello\"\n"),
);
write(&dir, "lib/page.php", "<?php $lang->tr('greeting'); ?>\n");
write(
&dir,
"other.toml",
"[scan]\n\
po_patterns = [\"**/*.po\"]\n\
source_extensions = [\"php\"]\n\
\n\
[[calls]]\n\
lang = \"php\"\n\
kind = \"method\"\n\
name = \"tr\"\n\
receiver = [\"lang\"]\n\
\n\
[output]\n\
fail_on = \"dead\"\n",
);
let (code, json) = run_scan_json(&dir, &dir.join("other.toml"));
assert_eq!(alive_via(&json, "greeting"), Some("literal"));
assert_eq!(json["summary"]["dead"], 0);
assert_eq!(code, 0, "no dead keys -> exit 0");
std::fs::remove_dir_all(&dir).ok();
}