Skip to main content

ferro_cli/doctor/checks/
local_env_parity.rs

1//! Local env parity (SCOPE §12.4): every key in `.env.example` is present in `.env`.
2
3use crate::deploy::parse_env_entries;
4use crate::doctor::check::{CheckResult, DoctorCheck};
5use std::collections::HashSet;
6use std::fs;
7use std::path::Path;
8
9pub struct LocalEnvParityCheck;
10
11const NAME: &str = "local_env_parity";
12
13impl DoctorCheck for LocalEnvParityCheck {
14    fn name(&self) -> &'static str {
15        NAME
16    }
17    fn run(&self, root: &Path) -> CheckResult {
18        check_impl(root)
19    }
20}
21
22pub(crate) fn check_impl(root: &Path) -> CheckResult {
23    let example_path = root.join(".env.example");
24    let env_path = root.join(".env");
25    let example_exists = example_path.is_file();
26    let env_exists = env_path.is_file();
27
28    if !example_exists && !env_exists {
29        return CheckResult::warn(NAME, ".env and .env.example both missing");
30    }
31    if !example_exists {
32        return CheckResult::warn(NAME, ".env.example missing");
33    }
34    if !env_exists {
35        return CheckResult::error(NAME, ".env missing — required by .env.example")
36            .with_details("Copy .env.example to .env and fill in real values");
37    }
38
39    let example = fs::read_to_string(&example_path).unwrap_or_default();
40    let env = fs::read_to_string(&env_path).unwrap_or_default();
41
42    let example_keys: Vec<String> = parse_env_entries(&example)
43        .into_iter()
44        .map(|e| e.key)
45        .collect();
46    let env_keys: HashSet<String> = parse_env_entries(&env).into_iter().map(|e| e.key).collect();
47
48    let missing: Vec<&String> = example_keys
49        .iter()
50        .filter(|k| !env_keys.contains(*k))
51        .collect();
52
53    if missing.is_empty() {
54        CheckResult::ok(NAME, format!("{} keys present", example_keys.len()))
55    } else {
56        let list = missing
57            .iter()
58            .map(|s| s.as_str())
59            .collect::<Vec<_>>()
60            .join(", ");
61        CheckResult::error(NAME, format!("{} key(s) missing from .env", missing.len()))
62            .with_details(format!("missing: {list}"))
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use tempfile::TempDir;
70
71    fn write(root: &Path, name: &str, content: &str) {
72        fs::write(root.join(name), content).unwrap();
73    }
74
75    #[test]
76    fn name_is_local_env_parity() {
77        assert_eq!(LocalEnvParityCheck.name(), "local_env_parity");
78    }
79
80    #[test]
81    fn matching_keys_returns_ok() {
82        let tmp = TempDir::new().unwrap();
83        write(tmp.path(), ".env.example", "KEY1=\nKEY2=\n");
84        write(tmp.path(), ".env", "KEY1=a\nKEY2=b\n");
85        let r = check_impl(tmp.path());
86        assert_eq!(r.status, crate::doctor::check::CheckStatus::Ok);
87    }
88
89    #[test]
90    fn missing_key_returns_error_with_details() {
91        let tmp = TempDir::new().unwrap();
92        write(tmp.path(), ".env.example", "KEY1=\nKEY2=\n");
93        write(tmp.path(), ".env", "KEY1=a\n");
94        let r = check_impl(tmp.path());
95        assert_eq!(r.status, crate::doctor::check::CheckStatus::Error);
96        assert!(r.details.unwrap().contains("KEY2"));
97    }
98}