use std::collections::HashSet;
const INTERNAL_ONLY: &[&str] = &[
"TEST_DATABASE_URL",
"DOCKER_SMOKE",
"COMPOSE_AVAILABLE",
"HOME",
];
fn scan_source_for_env_vars() -> HashSet<String> {
let mut found = HashSet::new();
let roots = ["src"];
for root in roots {
walk(std::path::Path::new(root), &mut found);
}
found
}
fn walk(dir: &std::path::Path, found: &mut HashSet<String>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, found);
} else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
if let Ok(contents) = std::fs::read_to_string(&path) {
extract_env_var_keys(&contents, found);
}
}
}
}
fn extract_env_var_keys(src: &str, found: &mut HashSet<String>) {
for (idx, _) in src.match_indices("env::var(\"") {
let after = &src[idx + "env::var(\"".len()..];
if let Some(end) = after.find('"') {
let key = &after[..end];
if key
.chars()
.all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit())
{
found.insert(key.to_string());
}
}
}
}
fn env_example_keys() -> HashSet<String> {
let contents = std::fs::read_to_string(".env.example").expect(".env.example must exist");
let mut keys = HashSet::new();
for line in contents.lines() {
let line = line.trim_start();
let without_hash = line.strip_prefix('#').map(str::trim_start).unwrap_or(line);
if let Some(eq) = without_hash.find('=') {
let key = &without_hash[..eq];
if key
.chars()
.all(|c| c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit())
&& !key.is_empty()
{
keys.insert(key.to_string());
}
}
}
keys
}
#[test]
fn ac_29_every_source_env_var_is_in_env_example_or_allowlisted() {
let source_keys = scan_source_for_env_vars();
let example_keys = env_example_keys();
let allow: HashSet<String> = INTERNAL_ONLY.iter().map(|s| s.to_string()).collect();
let mut missing: Vec<&String> = source_keys
.iter()
.filter(|k| !example_keys.contains(*k) && !allow.contains(*k))
.collect();
missing.sort();
assert!(
missing.is_empty(),
"Every env var the runtime reads must appear in .env.example or in \
tests/env_example_test.rs::INTERNAL_ONLY. Missing: {missing:?} (AC-29)"
);
}
#[test]
fn ac_29_env_example_has_no_orphan_entries() {
let source_keys = scan_source_for_env_vars();
let example_keys = env_example_keys();
let dynamic_but_known: &[&str] = &[
"DATABASE_URL",
"OPEN_PINCERY_HOST",
"OPEN_PINCERY_PORT",
"OPEN_PINCERY_BOOTSTRAP_TOKEN",
"LLM_API_BASE_URL",
"LLM_API_KEY",
"LLM_MODEL",
"LLM_MAINTENANCE_MODEL",
"LLM_PRICE_INPUT_PER_MTOK",
"LLM_PRICE_OUTPUT_PER_MTOK",
"LLM_MAINTENANCE_PRICE_INPUT_PER_MTOK",
"LLM_MAINTENANCE_PRICE_OUTPUT_PER_MTOK",
"MAX_PROMPT_CHARS",
"ITERATION_CAP",
"STALE_WAKE_HOURS",
"WAKE_SUMMARY_LIMIT",
"EVENT_WINDOW_LIMIT",
"RUST_LOG",
];
let known: HashSet<String> = source_keys
.iter()
.cloned()
.chain(dynamic_but_known.iter().map(|s| s.to_string()))
.collect();
let mut orphans: Vec<&String> = example_keys
.iter()
.filter(|k| !known.contains(*k))
.collect();
orphans.sort();
assert!(
orphans.is_empty(),
".env.example contains keys no source file reads: {orphans:?} (AC-29)"
);
}
#[test]
fn ac_32_env_example_defaults_to_compose_reachable_host() {
let contents = std::fs::read_to_string(".env.example").expect(".env.example");
assert!(
contents.contains("OPEN_PINCERY_HOST=0.0.0.0"),
".env.example must default OPEN_PINCERY_HOST to 0.0.0.0 so compose networking works"
);
}
#[test]
fn ac_29_env_example_includes_openai_alternative() {
let contents = std::fs::read_to_string(".env.example").expect(".env.example");
assert!(
contents.contains("api.openai.com"),
".env.example must include a commented OpenAI alternative block (AC-29)"
);
}