use std::fs;
use std::path::Path;
use crate::doctor::check::{CheckCategory, CheckResult, DoctorCheck};
const NAME: &str = "migrate_gate";
pub struct MigrateGateCheck;
impl DoctorCheck for MigrateGateCheck {
fn name(&self) -> &'static str {
NAME
}
fn category(&self) -> CheckCategory {
CheckCategory::Deploy
}
fn run(&self, root: &Path) -> CheckResult {
check_impl(root)
}
}
pub(crate) fn check_impl(root: &Path) -> CheckResult {
let has_migrations = root.join("migrations").is_dir() || root.join("src/migrations").is_dir();
if !has_migrations {
return CheckResult::ok(NAME, "no migrations directory — skipped");
}
let app_yaml = root.join(".do/app.yaml");
if !app_yaml.exists() {
return CheckResult::ok(NAME, "skipped — not a DO deploy project");
}
let yaml = match fs::read_to_string(&app_yaml) {
Ok(s) => s,
Err(e) => {
return CheckResult::error(NAME, format!("failed to read .do/app.yaml: {e}"));
}
};
if has_predeploy_migrate_job(&yaml) {
CheckResult::ok(NAME, "PRE_DEPLOY migrate job present")
} else {
CheckResult::error(NAME, "no PRE_DEPLOY migrate job in .do/app.yaml")
.with_details("Run `ferro do:init --force` to scaffold a migrate job, then commit.")
}
}
fn has_predeploy_migrate_job(yaml: &str) -> bool {
let mut in_jobs = false;
let mut current_job_predeploy = false;
let mut current_job_migrate = false;
for raw in yaml.lines() {
let line = raw.trim_end();
let trimmed = line.trim_start();
if trimmed == "jobs:" {
in_jobs = true;
continue;
}
if !in_jobs {
continue;
}
if !line.is_empty() && !line.starts_with(' ') && !line.starts_with('-') {
if current_job_predeploy && current_job_migrate {
return true;
}
in_jobs = false;
continue;
}
if trimmed.starts_with("- ") || trimmed == "-" {
if current_job_predeploy && current_job_migrate {
return true;
}
current_job_predeploy = false;
current_job_migrate = false;
}
if trimmed.contains("kind: PRE_DEPLOY") {
current_job_predeploy = true;
}
if trimmed.contains("db:migrate") {
current_job_migrate = true;
}
}
current_job_predeploy && current_job_migrate
}
#[cfg(test)]
mod tests {
use super::*;
use crate::doctor::check::CheckStatus;
use std::fs;
use tempfile::TempDir;
fn write(root: &Path, rel: &str, content: &str) {
let p = root.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, content).unwrap();
}
#[test]
fn skips_when_no_migrations_directory() {
let tmp = TempDir::new().unwrap();
let r = check_impl(tmp.path());
assert_eq!(r.status, CheckStatus::Ok);
assert!(r.message.contains("skipped"));
}
#[test]
fn skips_when_no_app_yaml() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
let r = check_impl(tmp.path());
assert_eq!(r.status, CheckStatus::Ok);
assert!(r.message.contains("skipped"));
}
#[test]
fn errors_when_app_yaml_has_no_jobs() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
write(
tmp.path(),
".do/app.yaml",
"name: foo\nservices:\n - name: web\n",
);
let r = check_impl(tmp.path());
assert_eq!(r.status, CheckStatus::Error);
assert_eq!(r.name, "migrate_gate");
assert!(r.message.contains("no PRE_DEPLOY migrate job"));
}
#[test]
fn errors_when_jobs_block_has_no_predeploy() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
write(
tmp.path(),
".do/app.yaml",
"jobs:\n - name: foo\n kind: POST_DEPLOY\n run_command: /usr/local/bin/app db:migrate\n",
);
let r = check_impl(tmp.path());
assert_eq!(r.status, CheckStatus::Error);
}
#[test]
fn errors_when_predeploy_present_but_no_migrate_command() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
write(
tmp.path(),
".do/app.yaml",
"jobs:\n - name: foo\n kind: PRE_DEPLOY\n run_command: /usr/local/bin/app seed\n",
);
let r = check_impl(tmp.path());
assert_eq!(r.status, CheckStatus::Error);
}
#[test]
fn ok_when_predeploy_migrate_job_present() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("migrations")).unwrap();
write(
tmp.path(),
".do/app.yaml",
"jobs:\n - name: migrate\n kind: PRE_DEPLOY\n run_command: /usr/local/bin/app db:migrate\n",
);
let r = check_impl(tmp.path());
assert_eq!(r.status, CheckStatus::Ok);
assert!(r.message.contains("PRE_DEPLOY migrate job present"));
}
#[test]
fn ok_when_migrations_under_src_migrations() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("src/migrations")).unwrap();
let r = check_impl(tmp.path());
assert_eq!(r.status, CheckStatus::Ok);
assert!(r.message.contains("skipped"));
}
#[test]
fn scanner_detects_pre_deploy_migrate_across_lines() {
let yaml = r#"
jobs:
- name: migrate
kind: PRE_DEPLOY
run_command: /usr/local/bin/app db:migrate
"#;
assert!(has_predeploy_migrate_job(yaml));
}
#[test]
fn scanner_rejects_when_kind_and_command_in_different_jobs() {
let yaml = r#"
jobs:
- name: first
kind: PRE_DEPLOY
run_command: /usr/local/bin/app seed
- name: second
kind: POST_DEPLOY
run_command: /usr/local/bin/app db:migrate
"#;
assert!(!has_predeploy_migrate_job(yaml));
}
}