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