ferro_cli/doctor/checks/
migrate_gate.rs1use std::fs;
7use std::path::Path;
8
9use crate::doctor::check::{CheckCategory, CheckResult, DoctorCheck};
10
11const NAME: &str = "migrate_gate";
12
13pub struct MigrateGateCheck;
16
17impl DoctorCheck for MigrateGateCheck {
18 fn name(&self) -> &'static str {
19 NAME
20 }
21
22 fn category(&self) -> CheckCategory {
23 CheckCategory::Deploy
24 }
25
26 fn run(&self, root: &Path) -> CheckResult {
27 check_impl(root)
28 }
29}
30
31pub(crate) fn check_impl(root: &Path) -> CheckResult {
32 let has_migrations = root.join("migrations").is_dir() || root.join("src/migrations").is_dir();
33 if !has_migrations {
34 return CheckResult::ok(NAME, "no migrations directory — skipped");
35 }
36
37 let app_yaml = root.join(".do/app.yaml");
38 if !app_yaml.exists() {
39 return CheckResult::ok(NAME, "skipped — not a DO deploy project");
40 }
41
42 let yaml = match fs::read_to_string(&app_yaml) {
43 Ok(s) => s,
44 Err(e) => {
45 return CheckResult::error(NAME, format!("failed to read .do/app.yaml: {e}"));
46 }
47 };
48
49 if has_predeploy_migrate_job(&yaml) {
50 CheckResult::ok(NAME, "PRE_DEPLOY migrate job present")
51 } else {
52 CheckResult::error(NAME, "no PRE_DEPLOY migrate job in .do/app.yaml")
53 .with_details("Run `ferro do:init --force` to scaffold a migrate job, then commit.")
54 }
55}
56
57fn has_predeploy_migrate_job(yaml: &str) -> bool {
62 let mut in_jobs = false;
63 let mut current_job_predeploy = false;
64 let mut current_job_migrate = false;
65 for raw in yaml.lines() {
66 let line = raw.trim_end();
67 let trimmed = line.trim_start();
68
69 if trimmed == "jobs:" {
71 in_jobs = true;
72 continue;
73 }
74 if !in_jobs {
75 continue;
76 }
77 if !line.is_empty() && !line.starts_with(' ') && !line.starts_with('-') {
80 if current_job_predeploy && current_job_migrate {
82 return true;
83 }
84 in_jobs = false;
85 continue;
86 }
87 if trimmed.starts_with("- ") || trimmed == "-" {
89 if current_job_predeploy && current_job_migrate {
90 return true;
91 }
92 current_job_predeploy = false;
93 current_job_migrate = false;
94 }
95 if trimmed.contains("kind: PRE_DEPLOY") {
96 current_job_predeploy = true;
97 }
98 if trimmed.contains("db:migrate") {
99 current_job_migrate = true;
100 }
101 }
102 current_job_predeploy && current_job_migrate
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108 use crate::doctor::check::CheckStatus;
109 use std::fs;
110 use tempfile::TempDir;
111
112 fn write(root: &Path, rel: &str, content: &str) {
113 let p = root.join(rel);
114 fs::create_dir_all(p.parent().unwrap()).unwrap();
115 fs::write(p, content).unwrap();
116 }
117
118 #[test]
119 fn skips_when_no_migrations_directory() {
120 let tmp = TempDir::new().unwrap();
121 let r = check_impl(tmp.path());
122 assert_eq!(r.status, CheckStatus::Ok);
123 assert!(r.message.contains("skipped"));
124 }
125
126 #[test]
127 fn skips_when_no_app_yaml() {
128 let tmp = TempDir::new().unwrap();
129 fs::create_dir_all(tmp.path().join("migrations")).unwrap();
130 let r = check_impl(tmp.path());
131 assert_eq!(r.status, CheckStatus::Ok);
132 assert!(r.message.contains("skipped"));
133 }
134
135 #[test]
136 fn errors_when_app_yaml_has_no_jobs() {
137 let tmp = TempDir::new().unwrap();
138 fs::create_dir_all(tmp.path().join("migrations")).unwrap();
139 write(
140 tmp.path(),
141 ".do/app.yaml",
142 "name: foo\nservices:\n - name: web\n",
143 );
144 let r = check_impl(tmp.path());
145 assert_eq!(r.status, CheckStatus::Error);
146 assert_eq!(r.name, "migrate_gate");
147 assert!(r.message.contains("no PRE_DEPLOY migrate job"));
148 }
149
150 #[test]
151 fn errors_when_jobs_block_has_no_predeploy() {
152 let tmp = TempDir::new().unwrap();
153 fs::create_dir_all(tmp.path().join("migrations")).unwrap();
154 write(
155 tmp.path(),
156 ".do/app.yaml",
157 "jobs:\n - name: foo\n kind: POST_DEPLOY\n run_command: /usr/local/bin/app db:migrate\n",
158 );
159 let r = check_impl(tmp.path());
160 assert_eq!(r.status, CheckStatus::Error);
161 }
162
163 #[test]
164 fn errors_when_predeploy_present_but_no_migrate_command() {
165 let tmp = TempDir::new().unwrap();
166 fs::create_dir_all(tmp.path().join("migrations")).unwrap();
167 write(
168 tmp.path(),
169 ".do/app.yaml",
170 "jobs:\n - name: foo\n kind: PRE_DEPLOY\n run_command: /usr/local/bin/app seed\n",
171 );
172 let r = check_impl(tmp.path());
173 assert_eq!(r.status, CheckStatus::Error);
174 }
175
176 #[test]
177 fn ok_when_predeploy_migrate_job_present() {
178 let tmp = TempDir::new().unwrap();
179 fs::create_dir_all(tmp.path().join("migrations")).unwrap();
180 write(
181 tmp.path(),
182 ".do/app.yaml",
183 "jobs:\n - name: migrate\n kind: PRE_DEPLOY\n run_command: /usr/local/bin/app db:migrate\n",
184 );
185 let r = check_impl(tmp.path());
186 assert_eq!(r.status, CheckStatus::Ok);
187 assert!(r.message.contains("PRE_DEPLOY migrate job present"));
188 }
189
190 #[test]
191 fn ok_when_migrations_under_src_migrations() {
192 let tmp = TempDir::new().unwrap();
193 fs::create_dir_all(tmp.path().join("src/migrations")).unwrap();
194 let r = check_impl(tmp.path());
196 assert_eq!(r.status, CheckStatus::Ok);
197 assert!(r.message.contains("skipped"));
198 }
199
200 #[test]
201 fn scanner_detects_pre_deploy_migrate_across_lines() {
202 let yaml = r#"
203jobs:
204 - name: migrate
205 kind: PRE_DEPLOY
206 run_command: /usr/local/bin/app db:migrate
207"#;
208 assert!(has_predeploy_migrate_job(yaml));
209 }
210
211 #[test]
212 fn scanner_rejects_when_kind_and_command_in_different_jobs() {
213 let yaml = r#"
214jobs:
215 - name: first
216 kind: PRE_DEPLOY
217 run_command: /usr/local/bin/app seed
218 - name: second
219 kind: POST_DEPLOY
220 run_command: /usr/local/bin/app db:migrate
221"#;
222 assert!(!has_predeploy_migrate_job(yaml));
223 }
224}