1use 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 Generate {
49 name: String,
51 dir: String,
53 },
54 Validate {
56 dir: String,
58 },
59 Preflight {
61 dir: String,
63 },
64}
65
66pub fn run(action: &MigrateAction, formatter: &OutputFormatter) -> Result<()> {
73 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
94pub fn resolve_database_url(explicit: Option<&str>) -> Result<String> {
101 if let Some(url) = explicit {
102 return Ok(url.to_string());
103 }
104
105 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 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
134pub fn resolve_migration_dir(explicit: Option<&str>) -> String {
136 if let Some(dir) = explicit {
137 return dir.to_string();
138 }
139
140 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 "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 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 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 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 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}