ferro_cli/doctor/checks/
deploy_env_parity.rs1use 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
49fn 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 let body = match trimmed.strip_prefix('#') {
63 Some(rest) => rest.trim_start(),
64 None => continue,
65 };
66 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}