fraiseql_cli/commands/
migrate.rs1use std::{path::Path, process::Command};
7
8use anyhow::{Context, Result};
9use tracing::info;
10
11use crate::output::OutputFormatter;
12
13#[derive(Debug, Clone)]
15#[non_exhaustive]
16pub enum MigrateAction {
17 Up {
19 database_url: String,
21 dir: String,
23 },
24 Down {
26 database_url: String,
28 dir: String,
30 steps: u32,
32 },
33 Status {
35 database_url: String,
37 dir: String,
39 },
40 Create {
42 name: String,
44 dir: String,
46 },
47}
48
49pub fn run(action: &MigrateAction, formatter: &OutputFormatter) -> Result<()> {
56 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
74pub fn resolve_database_url(explicit: Option<&str>) -> Result<String> {
81 if let Some(url) = explicit {
82 return Ok(url.to_string());
83 }
84
85 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 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
114pub fn resolve_migration_dir(explicit: Option<&str>) -> String {
116 if let Some(dir) = explicit {
117 return dir.to_string();
118 }
119
120 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 "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 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 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)] #[cfg(test)]
227mod tests {
228 use super::*;
229
230 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 let dir = resolve_migration_dir(None);
243 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}