Skip to main content

ferro_cli/doctor/checks/
migrate_gate.rs

1//! Doctor check: ensures projects with migrations have a PRE_DEPLOY migrate
2//! job configured in `.do/app.yaml`. Prevents the failure mode where a deploy
3//! starts with a stale schema because the runtime migration runner swallowed
4//! the error.
5
6use std::fs;
7use std::path::Path;
8
9use crate::doctor::check::{CheckCategory, CheckResult, DoctorCheck};
10
11const NAME: &str = "migrate_gate";
12
13/// Migrate-gate check: errors if migrations exist but no PRE_DEPLOY migrate
14/// job is configured in `.do/app.yaml`.
15pub 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
57/// Line-scan for a `jobs:` section containing an entry with both
58/// `kind: PRE_DEPLOY` and a `run_command` referencing `db:migrate`.
59/// Implemented as a line scan (no `serde_yaml` dep) — only presence is
60/// detected, no schema validation.
61fn 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        // jobs: at top level
70        if trimmed == "jobs:" {
71            in_jobs = true;
72            continue;
73        }
74        if !in_jobs {
75            continue;
76        }
77        // Exit jobs: section when we hit another top-level key
78        // (a non-indented line that is not blank and not the jobs: header).
79        if !line.is_empty() && !line.starts_with(' ') && !line.starts_with('-') {
80            // top-level key encountered — flush current job
81            if current_job_predeploy && current_job_migrate {
82                return true;
83            }
84            in_jobs = false;
85            continue;
86        }
87        // New job entry starts with "- " (possibly indented)
88        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        // No app.yaml — still skip
195        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}