fraiseql_cli/commands/
migrate.rs1use std::{path::Path, process::Command};
7
8use anyhow::{Context, Result};
9use tracing::info;
10
11#[derive(Debug, Clone)]
13pub enum MigrateAction {
14 Up {
16 database_url: String,
18 dir: String,
20 },
21 Down {
23 database_url: String,
25 dir: String,
27 steps: u32,
29 },
30 Status {
32 database_url: String,
34 dir: String,
36 },
37 Create {
39 name: String,
41 dir: String,
43 },
44}
45
46pub fn run(action: &MigrateAction) -> Result<()> {
48 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
66pub fn resolve_database_url(explicit: Option<&str>) -> Result<String> {
68 if let Some(url) = explicit {
69 return Ok(url.to_string());
70 }
71
72 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 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
101pub fn resolve_migration_dir(explicit: Option<&str>) -> String {
103 if let Some(dir) = explicit {
104 return dir.to_string();
105 }
106
107 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 "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 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 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 let dir = resolve_migration_dir(None);
232 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}