ferro-cli 0.2.19

CLI for scaffolding Ferro web applications
Documentation
//! Local env parity (SCOPE §12.4): every key in `.env.example` is present in `.env`.

use crate::deploy::parse_env_entries;
use crate::doctor::check::{CheckResult, DoctorCheck};
use std::collections::HashSet;
use std::fs;
use std::path::Path;

pub struct LocalEnvParityCheck;

const NAME: &str = "local_env_parity";

impl DoctorCheck for LocalEnvParityCheck {
    fn name(&self) -> &'static str {
        NAME
    }
    fn run(&self, root: &Path) -> CheckResult {
        check_impl(root)
    }
}

pub(crate) fn check_impl(root: &Path) -> CheckResult {
    let example_path = root.join(".env.example");
    let env_path = root.join(".env");
    let example_exists = example_path.is_file();
    let env_exists = env_path.is_file();

    if !example_exists && !env_exists {
        return CheckResult::warn(NAME, ".env and .env.example both missing");
    }
    if !example_exists {
        return CheckResult::warn(NAME, ".env.example missing");
    }
    if !env_exists {
        return CheckResult::error(NAME, ".env missing — required by .env.example")
            .with_details("Copy .env.example to .env and fill in real values");
    }

    let example = fs::read_to_string(&example_path).unwrap_or_default();
    let env = fs::read_to_string(&env_path).unwrap_or_default();

    let example_keys: Vec<String> = parse_env_entries(&example)
        .into_iter()
        .map(|e| e.key)
        .collect();
    let env_keys: HashSet<String> = parse_env_entries(&env).into_iter().map(|e| e.key).collect();

    let missing: Vec<&String> = example_keys
        .iter()
        .filter(|k| !env_keys.contains(*k))
        .collect();

    if missing.is_empty() {
        CheckResult::ok(NAME, format!("{} keys present", example_keys.len()))
    } else {
        let list = missing
            .iter()
            .map(|s| s.as_str())
            .collect::<Vec<_>>()
            .join(", ");
        CheckResult::error(NAME, format!("{} key(s) missing from .env", missing.len()))
            .with_details(format!("missing: {list}"))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    fn write(root: &Path, name: &str, content: &str) {
        fs::write(root.join(name), content).unwrap();
    }

    #[test]
    fn name_is_local_env_parity() {
        assert_eq!(LocalEnvParityCheck.name(), "local_env_parity");
    }

    #[test]
    fn matching_keys_returns_ok() {
        let tmp = TempDir::new().unwrap();
        write(tmp.path(), ".env.example", "KEY1=\nKEY2=\n");
        write(tmp.path(), ".env", "KEY1=a\nKEY2=b\n");
        let r = check_impl(tmp.path());
        assert_eq!(r.status, crate::doctor::check::CheckStatus::Ok);
    }

    #[test]
    fn missing_key_returns_error_with_details() {
        let tmp = TempDir::new().unwrap();
        write(tmp.path(), ".env.example", "KEY1=\nKEY2=\n");
        write(tmp.path(), ".env", "KEY1=a\n");
        let r = check_impl(tmp.path());
        assert_eq!(r.status, crate::doctor::check::CheckStatus::Error);
        assert!(r.details.unwrap().contains("KEY2"));
    }
}