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}
48
49/// Run the migrate command
50///
51/// # Errors
52///
53/// Returns an error if `confiture` is not installed, or if the underlying
54/// `confiture` subprocess fails (non-zero exit status or spawn failure).
55pub fn run(action: &MigrateAction, formatter: &OutputFormatter) -> Result<()> {
56    // Check if confiture is installed
57    if !is_confiture_installed() {
58        print_install_instructions(formatter);
59        anyhow::bail!("confiture is not installed. See instructions above.");
60    }
61
62    match action {
63        MigrateAction::Up { database_url, dir } => run_up(database_url, dir, formatter),
64        MigrateAction::Down {
65            database_url,
66            dir,
67            steps,
68        } => run_down(database_url, dir, *steps, formatter),
69        MigrateAction::Status { database_url, dir } => run_status(database_url, dir),
70        MigrateAction::Create { name, dir } => run_create(name, dir, formatter),
71    }
72}
73
74/// Resolve the database URL: use explicit flag, or fall back to fraiseql.toml
75///
76/// # Errors
77///
78/// Returns an error if `fraiseql.toml` exists but cannot be read or parsed, or
79/// if no database URL can be found from any source (flag, TOML, or `DATABASE_URL`).
80pub fn resolve_database_url(explicit: Option<&str>) -> Result<String> {
81    if let Some(url) = explicit {
82        return Ok(url.to_string());
83    }
84
85    // Try loading from fraiseql.toml
86    let toml_path = Path::new("fraiseql.toml");
87    if toml_path.exists() {
88        let content = std::fs::read_to_string(toml_path).context("Failed to read fraiseql.toml")?;
89        let parsed: toml::Value =
90            toml::from_str(&content).context("Failed to parse fraiseql.toml")?;
91
92        if let Some(url) = parsed
93            .get("database")
94            .and_then(|db| db.get("url"))
95            .and_then(toml::Value::as_str)
96        {
97            info!("Using database URL from fraiseql.toml");
98            return Ok(url.to_string());
99        }
100    }
101
102    // Try DATABASE_URL env var
103    if let Ok(url) = std::env::var("DATABASE_URL") {
104        info!("Using DATABASE_URL environment variable");
105        return Ok(url);
106    }
107
108    anyhow::bail!(
109        "No database URL provided. Use --database, set [database].url in fraiseql.toml, \
110         or set DATABASE_URL environment variable."
111    )
112}
113
114/// Resolve the migration directory: use explicit flag, or auto-discover
115pub fn resolve_migration_dir(explicit: Option<&str>) -> String {
116    if let Some(dir) = explicit {
117        return dir.to_string();
118    }
119
120    // Auto-discover common directory names
121    for candidate in &["db/0_schema", "db/migrations", "migrations"] {
122        if Path::new(candidate).is_dir() {
123            info!("Auto-discovered migration directory: {candidate}");
124            return (*candidate).to_string();
125        }
126    }
127
128    // Default
129    "db/0_schema".to_string()
130}
131
132fn is_confiture_installed() -> bool {
133    Command::new("confiture")
134        .arg("--version")
135        .stdout(std::process::Stdio::null())
136        .stderr(std::process::Stdio::null())
137        .status()
138        .is_ok_and(|s| s.success())
139}
140
141fn print_install_instructions(formatter: &OutputFormatter) {
142    formatter.progress("confiture is not installed.");
143    formatter.progress("");
144    formatter.progress("Install it with one of:");
145    formatter.progress("  cargo install confiture          # From crates.io");
146    formatter.progress("  brew install confiture            # macOS (if available)");
147    formatter.progress("");
148    formatter.progress("Learn more: https://github.com/fraiseql/confiture");
149}
150
151fn run_up(database_url: &str, dir: &str, formatter: &OutputFormatter) -> Result<()> {
152    info!("Running migrations up from {dir}");
153    formatter.progress(&format!("Applying migrations from {dir}..."));
154
155    // SECURITY: Pass database URL via environment variable, not argv, so it
156    // is not visible to other users via `ps aux` or `/proc/<pid>/cmdline`.
157    let status = Command::new("confiture")
158        .args(["up", "--source", dir])
159        .env("DATABASE_URL", database_url)
160        .status()
161        .context("Failed to execute confiture")?;
162
163    if status.success() {
164        formatter.progress("Migrations applied successfully.");
165        Ok(())
166    } else {
167        anyhow::bail!("Migration failed. Check the output above for details.")
168    }
169}
170
171fn run_down(database_url: &str, dir: &str, steps: u32, formatter: &OutputFormatter) -> Result<()> {
172    info!("Rolling back {steps} migration(s) from {dir}");
173    formatter.progress(&format!("Rolling back {steps} migration(s)..."));
174
175    let steps_str = steps.to_string();
176    let status = Command::new("confiture")
177        .args(["down", "--source", dir, "--steps", &steps_str])
178        .env("DATABASE_URL", database_url)
179        .status()
180        .context("Failed to execute confiture")?;
181
182    if status.success() {
183        formatter.progress("Rollback completed successfully.");
184        Ok(())
185    } else {
186        anyhow::bail!("Rollback failed. Check the output above for details.")
187    }
188}
189
190fn run_status(database_url: &str, dir: &str) -> Result<()> {
191    info!("Checking migration status for {dir}");
192
193    let status = Command::new("confiture")
194        .args(["status", "--source", dir])
195        .env("DATABASE_URL", database_url)
196        .status()
197        .context("Failed to execute confiture")?;
198
199    if status.success() {
200        Ok(())
201    } else {
202        anyhow::bail!("Failed to get migration status.")
203    }
204}
205
206fn run_create(name: &str, dir: &str, formatter: &OutputFormatter) -> Result<()> {
207    info!("Creating migration: {name} in {dir}");
208
209    // Ensure directory exists
210    std::fs::create_dir_all(dir).context(format!("Failed to create migration directory: {dir}"))?;
211
212    let status = Command::new("confiture")
213        .args(["create", name, "--source", dir])
214        .status()
215        .context("Failed to execute confiture")?;
216
217    if status.success() {
218        formatter.progress(&format!("Migration created in {dir}/"));
219        Ok(())
220    } else {
221        anyhow::bail!("Failed to create migration.")
222    }
223}
224
225#[allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    // These tests mutate process-global state (cwd and env vars) and must not
231    // run in parallel with each other.
232    static GLOBAL_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
233
234    #[test]
235    fn test_resolve_migration_dir_explicit() {
236        assert_eq!(resolve_migration_dir(Some("custom/dir")), "custom/dir");
237    }
238
239    #[test]
240    fn test_resolve_migration_dir_default() {
241        // When no auto-discoverable dirs exist, falls back to default
242        let dir = resolve_migration_dir(None);
243        // Should return some string (either auto-discovered or default)
244        assert!(!dir.is_empty());
245    }
246
247    #[test]
248    fn test_resolve_database_url_explicit() {
249        let url = resolve_database_url(Some("postgres://localhost/test")).unwrap();
250        assert_eq!(url, "postgres://localhost/test");
251    }
252
253    #[test]
254    fn test_resolve_database_url_no_source() {
255        let _guard = GLOBAL_STATE_LOCK
256            .lock()
257            .expect("GLOBAL_STATE_LOCK poisoned; a previous test panicked mid-migration");
258
259        let tmp = tempfile::tempdir().unwrap();
260        let original = std::env::current_dir().unwrap();
261        std::env::set_current_dir(tmp.path()).unwrap();
262
263        temp_env::with_vars([("DATABASE_URL", None::<&str>)], || {
264            let result = resolve_database_url(None);
265            assert!(result.is_err(), "expected Err when no database URL is available");
266        });
267
268        std::env::set_current_dir(original).unwrap();
269    }
270
271    #[test]
272    fn test_resolve_database_url_from_env() {
273        let _guard = GLOBAL_STATE_LOCK
274            .lock()
275            .expect("GLOBAL_STATE_LOCK poisoned; a previous test panicked mid-migration");
276
277        let tmp = tempfile::tempdir().unwrap();
278        let original = std::env::current_dir().unwrap();
279        std::env::set_current_dir(tmp.path()).unwrap();
280
281        temp_env::with_vars([("DATABASE_URL", Some("postgres://env/test"))], || {
282            let url = resolve_database_url(None).unwrap();
283            assert_eq!(url, "postgres://env/test");
284        });
285
286        std::env::set_current_dir(original).unwrap();
287    }
288}