Skip to main content

ferro_cli/doctor/checks/
deploy_env_parity.rs

1//! Deploy env parity (SCOPE ยง12.5): every key in `.env.production` appears
2//! as a commented entry in `.do/app.yaml`'s envs scaffold.
3
4use crate::deploy::env_production::read_env_production_keys;
5use crate::doctor::check::{CheckResult, DoctorCheck};
6use std::fs;
7use std::path::Path;
8
9pub struct DeployEnvParityCheck;
10
11const NAME: &str = "deploy_env_parity";
12
13impl DoctorCheck for DeployEnvParityCheck {
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 env_prod = root.join(".env.production");
24    let app_yaml = root.join(".do/app.yaml");
25
26    if !env_prod.is_file() || !app_yaml.is_file() {
27        return CheckResult::ok(NAME, "skipped (.env.production or .do/app.yaml missing)");
28    }
29
30    let keys = match read_env_production_keys(&env_prod) {
31        Ok(k) => k,
32        Err(e) => return CheckResult::error(NAME, format!("failed to read .env.production: {e}")),
33    };
34
35    let yaml = fs::read_to_string(&app_yaml).unwrap_or_default();
36    let missing = missing_keys(&keys, &yaml);
37
38    if missing.is_empty() {
39        CheckResult::ok(NAME, format!("{} keys scaffolded", keys.len()))
40    } else {
41        CheckResult::warn(
42            NAME,
43            format!("{} key(s) missing from .do/app.yaml", missing.len()),
44        )
45        .with_details(format!("missing: {}", missing.join(", ")))
46    }
47}
48
49/// Return keys from `keys` that do not appear as a commented `# - KEY` line
50/// (or any line containing the bare token) in the yaml body.
51fn missing_keys(keys: &[String], yaml: &str) -> Vec<String> {
52    keys.iter()
53        .filter(|k| !yaml_contains_key(yaml, k))
54        .cloned()
55        .collect()
56}
57
58fn yaml_contains_key(yaml: &str, key: &str) -> bool {
59    for line in yaml.lines() {
60        let trimmed = line.trim_start();
61        // Look only inside comment scaffolding lines.
62        let body = match trimmed.strip_prefix('#') {
63            Some(rest) => rest.trim_start(),
64            None => continue,
65        };
66        // Match `- KEY` or just KEY token; require word-boundary equality.
67        let token = body.trim_start_matches('-').trim();
68        if token == key {
69            return true;
70        }
71    }
72    false
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use tempfile::TempDir;
79
80    #[test]
81    fn name_is_deploy_env_parity() {
82        assert_eq!(DeployEnvParityCheck.name(), "deploy_env_parity");
83    }
84
85    #[test]
86    fn skips_when_files_absent() {
87        let tmp = TempDir::new().unwrap();
88        let r = check_impl(tmp.path());
89        assert_eq!(r.status, crate::doctor::check::CheckStatus::Ok);
90        assert!(r.message.contains("skipped"));
91    }
92
93    #[test]
94    fn ok_when_all_keys_present() {
95        let tmp = TempDir::new().unwrap();
96        fs::write(
97            tmp.path().join(".env.production"),
98            "APP_ENV=x\nDATABASE_URL=y\n",
99        )
100        .unwrap();
101        fs::create_dir(tmp.path().join(".do")).unwrap();
102        fs::write(
103            tmp.path().join(".do/app.yaml"),
104            "envs:\n  # - APP_ENV\n  # - DATABASE_URL\n",
105        )
106        .unwrap();
107        let r = check_impl(tmp.path());
108        assert_eq!(r.status, crate::doctor::check::CheckStatus::Ok);
109    }
110
111    #[test]
112    fn warn_on_missing_key() {
113        let tmp = TempDir::new().unwrap();
114        fs::write(
115            tmp.path().join(".env.production"),
116            "APP_ENV=x\nDATABASE_URL=y\n",
117        )
118        .unwrap();
119        fs::create_dir(tmp.path().join(".do")).unwrap();
120        fs::write(tmp.path().join(".do/app.yaml"), "envs:\n  # - APP_ENV\n").unwrap();
121        let r = check_impl(tmp.path());
122        assert_eq!(r.status, crate::doctor::check::CheckStatus::Warn);
123        assert!(r.details.unwrap().contains("DATABASE_URL"));
124    }
125}