Skip to main content

fraiseql_cli/commands/
migrate.rs

1//! `fraiseql migrate` - Database migration wrapper
2//!
3//! Wraps confiture for database migrations, providing a unified CLI
4//! experience without requiring users to install confiture separately.
5
6use std::{path::Path, process::Command};
7
8use anyhow::{Context, Result};
9use tracing::info;
10
11use crate::output::OutputFormatter;
12
13/// Migration subcommand
14#[derive(Debug, Clone)]
15#[non_exhaustive]
16pub enum MigrateAction {
17    /// Apply pending migrations
18    Up {
19        /// Database connection URL
20        database_url: String,
21        /// Migration directory
22        dir:          String,
23    },
24    /// Roll back migrations
25    Down {
26        /// Database connection URL
27        database_url: String,
28        /// Migration directory
29        dir:          String,
30        /// Number of steps to roll back
31        steps:        u32,
32    },
33    /// Show migration status
34    Status {
35        /// Database connection URL
36        database_url: String,
37        /// Migration directory
38        dir:          String,
39    },
40    /// Create a new migration file
41    Create {
42        /// Migration name
43        name: String,
44        /// Migration directory
45        dir:  String,
46    },
47    /// Generate a new migration from schema diff
48    Generate {
49        /// Migration name
50        name: String,
51        /// Migration directory
52        dir:  String,
53    },
54    /// Validate migration files for naming, idempotency, and drift
55    Validate {
56        /// Migration directory
57        dir: String,
58    },
59    /// Pre-deploy safety check on pending migrations
60    Preflight {
61        /// Migration directory
62        dir: String,
63    },
64}
65
66/// Run the migrate command
67///
68/// # Errors
69///
70/// Returns an error if `confiture` is not installed, or if the underlying
71/// `confiture` subprocess fails (non-zero exit status or spawn failure).
72pub fn run(action: &MigrateAction, formatter: &OutputFormatter) -> Result<()> {
73    // Check if confiture is installed
74    if !is_confiture_installed() {
75        print_install_instructions(formatter);
76        anyhow::bail!("confiture is not installed. See instructions above.");
77    }
78
79    match action {
80        MigrateAction::Up { database_url, dir } => run_up(database_url, dir, formatter),
81        MigrateAction::Down {
82            database_url,
83            dir,
84            steps,
85        } => run_down(database_url, dir, *steps, formatter),
86        MigrateAction::Status { database_url, dir } => run_status(database_url, dir),
87        MigrateAction::Create { name, dir } => run_create(name, dir, formatter),
88        MigrateAction::Generate { name, dir } => run_generate(name, dir, formatter),
89        MigrateAction::Validate { dir } => run_validate(dir),
90        MigrateAction::Preflight { dir } => run_preflight(dir, formatter),
91    }
92}
93
94/// Resolve the database URL: use explicit flag, or fall back to fraiseql.toml
95///
96/// # Errors
97///
98/// Returns an error if `fraiseql.toml` exists but cannot be read or parsed, or
99/// if no database URL can be found from any source (flag, TOML, or `DATABASE_URL`).
100pub fn resolve_database_url(explicit: Option<&str>) -> Result<String> {
101    if let Some(url) = explicit {
102        return Ok(url.to_string());
103    }
104
105    // Try loading from fraiseql.toml
106    let toml_path = Path::new("fraiseql.toml");
107    if toml_path.exists() {
108        let content = std::fs::read_to_string(toml_path).context("Failed to read fraiseql.toml")?;
109        let parsed: toml::Value =
110            toml::from_str(&content).context("Failed to parse fraiseql.toml")?;
111
112        if let Some(url) = parsed
113            .get("database")
114            .and_then(|db| db.get("url"))
115            .and_then(toml::Value::as_str)
116        {
117            info!("Using database URL from fraiseql.toml");
118            return Ok(url.to_string());
119        }
120    }
121
122    // Try DATABASE_URL env var
123    if let Ok(url) = std::env::var("DATABASE_URL") {
124        info!("Using DATABASE_URL environment variable");
125        return Ok(url);
126    }
127
128    anyhow::bail!(
129        "No database URL provided. Use --database, set [database].url in fraiseql.toml, \
130         or set DATABASE_URL environment variable."
131    )
132}
133
134/// Resolve the migration directory: use explicit flag, or auto-discover
135pub fn resolve_migration_dir(explicit: Option<&str>) -> String {
136    if let Some(dir) = explicit {
137        return dir.to_string();
138    }
139
140    // Auto-discover common directory names
141    for candidate in &["db/0_schema", "db/migrations", "migrations"] {
142        if Path::new(candidate).is_dir() {
143            info!("Auto-discovered migration directory: {candidate}");
144            return (*candidate).to_string();
145        }
146    }
147
148    // Default
149    "db/0_schema".to_string()
150}
151
152fn is_confiture_installed() -> bool {
153    Command::new("confiture")
154        .arg("--version")
155        .stdout(std::process::Stdio::null())
156        .stderr(std::process::Stdio::null())
157        .status()
158        .is_ok_and(|s| s.success())
159}
160
161fn print_install_instructions(formatter: &OutputFormatter) {
162    formatter.progress("confiture is not installed.");
163    formatter.progress("");
164    formatter.progress("Install it with one of:");
165    formatter.progress("  cargo install confiture          # From crates.io");
166    formatter.progress("  brew install confiture            # macOS (if available)");
167    formatter.progress("");
168    formatter.progress("Learn more: https://github.com/fraiseql/confiture");
169}
170
171fn run_up(database_url: &str, dir: &str, formatter: &OutputFormatter) -> Result<()> {
172    info!("Running migrations up from {dir}");
173    formatter.progress(&format!("Applying migrations from {dir}..."));
174
175    // SECURITY: Pass database URL via environment variable, not argv, so it
176    // is not visible to other users via `ps aux` or `/proc/<pid>/cmdline`.
177    let status = Command::new("confiture")
178        .args(["up", "--source", dir])
179        .env("DATABASE_URL", database_url)
180        .status()
181        .context("Failed to execute confiture")?;
182
183    if status.success() {
184        formatter.progress("Migrations applied successfully.");
185        Ok(())
186    } else {
187        anyhow::bail!("Migration failed. Check the output above for details.")
188    }
189}
190
191fn run_down(database_url: &str, dir: &str, steps: u32, formatter: &OutputFormatter) -> Result<()> {
192    info!("Rolling back {steps} migration(s) from {dir}");
193    formatter.progress(&format!("Rolling back {steps} migration(s)..."));
194
195    let steps_str = steps.to_string();
196    let status = Command::new("confiture")
197        .args(["down", "--source", dir, "--steps", &steps_str])
198        .env("DATABASE_URL", database_url)
199        .status()
200        .context("Failed to execute confiture")?;
201
202    if status.success() {
203        formatter.progress("Rollback completed successfully.");
204        Ok(())
205    } else {
206        anyhow::bail!("Rollback failed. Check the output above for details.")
207    }
208}
209
210fn run_status(database_url: &str, dir: &str) -> Result<()> {
211    info!("Checking migration status for {dir}");
212
213    let status = Command::new("confiture")
214        .args(["status", "--source", dir])
215        .env("DATABASE_URL", database_url)
216        .status()
217        .context("Failed to execute confiture")?;
218
219    if status.success() {
220        Ok(())
221    } else {
222        anyhow::bail!("Failed to get migration status.")
223    }
224}
225
226fn run_create(name: &str, dir: &str, formatter: &OutputFormatter) -> Result<()> {
227    info!("Creating migration: {name} in {dir}");
228
229    // Ensure directory exists
230    std::fs::create_dir_all(dir).context(format!("Failed to create migration directory: {dir}"))?;
231
232    let status = Command::new("confiture")
233        .args(["create", name, "--source", dir])
234        .status()
235        .context("Failed to execute confiture")?;
236
237    if status.success() {
238        formatter.progress(&format!("Migration created in {dir}/"));
239        Ok(())
240    } else {
241        anyhow::bail!("Failed to create migration.")
242    }
243}
244
245fn run_generate(name: &str, dir: &str, formatter: &OutputFormatter) -> Result<()> {
246    info!("Generating migration: {name} in {dir}");
247
248    // Ensure directory exists
249    std::fs::create_dir_all(dir).context(format!("Failed to create migration directory: {dir}"))?;
250
251    formatter.progress(&format!("Generating migration '{name}' in {dir}..."));
252
253    // SECURITY: No database URL involved — generation is a pure file operation.
254    let status = Command::new("confiture")
255        .args(["migrate", "generate", name, "--migrations-dir", dir])
256        .status()
257        .context("Failed to execute confiture")?;
258
259    if status.success() {
260        formatter.progress(&format!("Migration generated in {dir}/"));
261        Ok(())
262    } else {
263        anyhow::bail!("Failed to generate migration.")
264    }
265}
266
267fn run_validate(dir: &str) -> Result<()> {
268    info!("Validating migrations in {dir}");
269
270    let status = Command::new("confiture")
271        .args(["migrate", "validate", "--source", dir])
272        .status()
273        .context("Failed to execute confiture")?;
274
275    if status.success() {
276        Ok(())
277    } else {
278        anyhow::bail!("Migration validation failed. Check the output above for details.")
279    }
280}
281
282fn run_preflight(dir: &str, formatter: &OutputFormatter) -> Result<()> {
283    info!("Running preflight checks for {dir}");
284    formatter.progress(&format!("Running preflight checks on {dir}..."));
285
286    let status = Command::new("confiture")
287        .args(["migrate", "preflight", "--migrations-dir", dir])
288        .status()
289        .context("Failed to execute confiture")?;
290
291    if status.success() {
292        formatter.progress("Preflight checks passed.");
293        Ok(())
294    } else {
295        anyhow::bail!("Preflight checks failed. Check the output above for details.")
296    }
297}