Skip to main content

tideway_cli/commands/
migrate.rs

1//! Migrate command - run database migrations via the configured backend.
2
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use crate::cli::{MigrateArgs, MigrateBackend};
9use crate::env::{ensure_env, ensure_project_dir, read_env_map};
10use crate::{is_plan_mode, print_info, print_success, print_warning};
11
12pub fn run(args: MigrateArgs) -> Result<()> {
13    if is_plan_mode() {
14        print_info(&format!("Plan: would run migrations ({})", args.action));
15        return Ok(());
16    }
17    let project_dir = PathBuf::from(&args.path);
18    ensure_project_dir(&project_dir)?;
19
20    if !args.no_env {
21        ensure_env(&project_dir, args.fix_env)?;
22    }
23
24    if args.action == "init" {
25        let backend = resolve_backend(&project_dir, args.backend)?;
26        return match backend {
27            MigrateBackend::SeaOrm => init_sea_orm_migration(&project_dir),
28            MigrateBackend::Auto => Err(anyhow::anyhow!(
29                "Unable to detect migration backend; pass --backend"
30            )),
31        };
32    }
33
34    let backend = resolve_backend(&project_dir, args.backend)?;
35    match backend {
36        MigrateBackend::SeaOrm => run_sea_orm_cli(&project_dir, &args),
37        MigrateBackend::Auto => Err(anyhow::anyhow!(
38            "Unable to detect migration backend; pass --backend"
39        )),
40    }
41}
42
43fn resolve_backend(project_dir: &Path, backend: MigrateBackend) -> Result<MigrateBackend> {
44    match backend {
45        MigrateBackend::Auto => detect_backend(project_dir),
46        MigrateBackend::SeaOrm => Ok(MigrateBackend::SeaOrm),
47    }
48}
49
50fn detect_backend(project_dir: &Path) -> Result<MigrateBackend> {
51    let cargo_path = project_dir.join("Cargo.toml");
52    let contents = fs::read_to_string(&cargo_path)
53        .with_context(|| format!("Failed to read {}", cargo_path.display()))?;
54    let doc = contents
55        .parse::<toml_edit::DocumentMut>()
56        .context("Failed to parse Cargo.toml")?;
57
58    let deps = doc.get("dependencies");
59    let has_sea_orm = deps.and_then(|deps| deps.get("sea-orm")).is_some();
60    let has_tideway_db = deps
61        .and_then(|deps| deps.get("tideway"))
62        .and_then(|item| item.get("features"))
63        .and_then(|item| item.as_array())
64        .map(|arr| arr.iter().any(|v| v.as_str() == Some("database")))
65        .unwrap_or(false);
66
67    if has_sea_orm || has_tideway_db {
68        Ok(MigrateBackend::SeaOrm)
69    } else {
70        Err(anyhow::anyhow!(
71            "Could not detect migration backend (add sea-orm or pass --backend)"
72        ))
73    }
74}
75
76fn run_sea_orm_cli(project_dir: &Path, args: &MigrateArgs) -> Result<()> {
77    let migrations_dir = project_dir.join("migration");
78    if !migrations_dir.exists() {
79        print_warning("migration/ directory not found; sea-orm-cli may fail");
80    }
81
82    if args.action == "status"
83        || args.action == "up"
84        || args.action == "down"
85        || args.action == "reset"
86    {
87        ensure_database_url(project_dir)?;
88    }
89
90    let mut command = Command::new("sea-orm-cli");
91    command
92        .arg("migrate")
93        .arg(&args.action)
94        .current_dir(project_dir);
95
96    if !args.args.is_empty() {
97        command.args(&args.args);
98    }
99
100    if !args.no_env {
101        if let Some(env_map) = read_env_map(&project_dir.join(".env")) {
102            command.envs(env_map);
103        }
104    }
105
106    print_info(&format!("Running sea-orm-cli migrate {}...", args.action));
107    let status = command
108        .status()
109        .context("Failed to run sea-orm-cli (is it installed?)")?;
110
111    if status.success() {
112        print_success("Migrations completed");
113        Ok(())
114    } else {
115        Err(anyhow::anyhow!("sea-orm-cli exited with status {}", status))
116    }
117}
118
119fn ensure_database_url(project_dir: &Path) -> Result<()> {
120    if let Some(env_map) = read_env_map(&project_dir.join(".env")) {
121        if let Some(value) = env_map.get("DATABASE_URL") {
122            validate_database_url(value)?;
123            return Ok(());
124        }
125    }
126
127    if let Ok(value) = std::env::var("DATABASE_URL") {
128        validate_database_url(&value)?;
129        return Ok(());
130    }
131
132    Err(anyhow::anyhow!(
133        "DATABASE_URL is missing (set it in .env or the environment)"
134    ))
135}
136
137fn validate_database_url(value: &str) -> Result<()> {
138    if !value.contains("://") {
139        return Err(anyhow::anyhow!(
140            "DATABASE_URL looks invalid (missing scheme): {}",
141            value
142        ));
143    }
144
145    let lower = value.to_lowercase();
146    let valid = lower.starts_with("postgres://")
147        || lower.starts_with("postgresql://")
148        || lower.starts_with("sqlite:");
149
150    if !valid {
151        return Err(anyhow::anyhow!(
152            "DATABASE_URL scheme looks invalid: {}",
153            value
154        ));
155    }
156
157    Ok(())
158}
159
160fn init_sea_orm_migration(project_dir: &Path) -> Result<()> {
161    let migration_root = project_dir.join("migration");
162    if migration_root.exists() {
163        print_warning("migration/ already exists; skipping init");
164        return Ok(());
165    }
166
167    let mut command = Command::new("sea-orm-cli");
168    command.arg("migrate").arg("init").current_dir(project_dir);
169
170    if let Some(env_map) = read_env_map(&project_dir.join(".env")) {
171        command.envs(env_map);
172    }
173
174    print_info("Initializing SeaORM migration crate...");
175    let status = command
176        .status()
177        .context("Failed to run sea-orm-cli (is it installed?)")?;
178
179    if status.success() {
180        print_success("Migration crate initialized");
181        Ok(())
182    } else {
183        Err(anyhow::anyhow!("sea-orm-cli exited with status {}", status))
184    }
185}