1use std::collections::HashMap;
4use std::fmt;
5use std::path::PathBuf;
6
7#[cfg(feature = "postgres")]
8use tokio_postgres::Client;
9
10use crate::config::HooksConfig;
11#[cfg(feature = "postgres")]
12use crate::db;
13use crate::db::DbClient;
14use crate::error::{Result, WaypointError};
15use crate::placeholder::replace_placeholders;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum HookType {
20 BeforeMigrate,
22 AfterMigrate,
24 BeforeEachMigrate,
26 AfterEachMigrate,
28}
29
30impl fmt::Display for HookType {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 match self {
33 HookType::BeforeMigrate => write!(f, "beforeMigrate"),
34 HookType::AfterMigrate => write!(f, "afterMigrate"),
35 HookType::BeforeEachMigrate => write!(f, "beforeEachMigrate"),
36 HookType::AfterEachMigrate => write!(f, "afterEachMigrate"),
37 }
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct ResolvedHook {
44 pub hook_type: HookType,
46 pub script_name: String,
48 pub sql: String,
50}
51
52type HookPrefixEntry = (&'static str, fn() -> HookType);
54const HOOK_PREFIXES: &[HookPrefixEntry] = &[
55 ("beforeEachMigrate", || HookType::BeforeEachMigrate),
56 ("afterEachMigrate", || HookType::AfterEachMigrate),
57 ("beforeMigrate", || HookType::BeforeMigrate),
58 ("afterMigrate", || HookType::AfterMigrate),
59];
60
61pub fn is_hook_file(filename: &str) -> bool {
69 let Some(stem) = filename.strip_suffix(".sql") else {
70 return false;
71 };
72 HOOK_PREFIXES.iter().any(|(prefix, _)| {
73 stem.strip_prefix(prefix)
74 .is_some_and(|rest| rest.is_empty() || rest.starts_with("__"))
75 })
76}
77
78pub fn scan_hooks(locations: &[PathBuf]) -> Result<Vec<ResolvedHook>> {
88 let mut hooks = Vec::new();
89
90 for location in locations {
91 if !location.exists() {
92 continue;
93 }
94
95 let entries = std::fs::read_dir(location).map_err(|e| {
96 WaypointError::IoError(std::io::Error::new(
97 e.kind(),
98 format!(
99 "Failed to read hook directory '{}': {}",
100 location.display(),
101 e
102 ),
103 ))
104 })?;
105
106 let mut files: Vec<_> = entries
107 .filter_map(|e| e.ok())
108 .filter(|e| e.path().is_file())
109 .collect();
110
111 files.sort_by_key(|e| e.file_name());
113
114 for entry in files {
115 let path = entry.path();
116 let filename = match path.file_name().and_then(|n| n.to_str()) {
117 Some(name) => name.to_string(),
118 None => continue,
119 };
120
121 if !filename.ends_with(".sql") {
122 continue;
123 }
124
125 for (prefix, type_fn) in HOOK_PREFIXES {
127 if filename.starts_with(prefix) {
128 let rest = &filename[prefix.len()..filename.len() - 4]; if rest.is_empty() || rest.starts_with("__") {
131 let sql = std::fs::read_to_string(&path)?;
132 hooks.push(ResolvedHook {
133 hook_type: type_fn(),
134 script_name: filename.clone(),
135 sql,
136 });
137 break;
138 }
139 }
140 }
141 }
142 }
143
144 hooks.sort_by(|a, b| {
146 a.hook_type
147 .to_string()
148 .cmp(&b.hook_type.to_string())
149 .then_with(|| a.script_name.cmp(&b.script_name))
150 });
151
152 Ok(hooks)
153}
154
155pub fn load_config_hooks(config: &HooksConfig) -> Result<Vec<ResolvedHook>> {
157 let mut hooks = Vec::new();
158
159 let sections: &[(HookType, &[PathBuf])] = &[
160 (HookType::BeforeMigrate, &config.before_migrate),
161 (HookType::AfterMigrate, &config.after_migrate),
162 (HookType::BeforeEachMigrate, &config.before_each_migrate),
163 (HookType::AfterEachMigrate, &config.after_each_migrate),
164 ];
165
166 for (hook_type, paths) in sections {
167 for path in *paths {
168 let sql = std::fs::read_to_string(path).map_err(|e| {
169 WaypointError::IoError(std::io::Error::new(
170 e.kind(),
171 format!("Failed to read hook file '{}': {}", path.display(), e),
172 ))
173 })?;
174
175 let script_name = path
176 .file_name()
177 .and_then(|n| n.to_str())
178 .unwrap_or_else(|| path.to_str().unwrap_or("unknown"))
179 .to_string();
180
181 hooks.push(ResolvedHook {
182 hook_type: hook_type.clone(),
183 script_name,
184 sql,
185 });
186 }
187 }
188
189 Ok(hooks)
190}
191
192#[cfg(feature = "postgres")]
196pub async fn run_hooks(
197 client: &Client,
198 hooks: &[ResolvedHook],
199 phase: &HookType,
200 placeholders: &HashMap<String, String>,
201) -> Result<(usize, i32)> {
202 let mut total_ms = 0;
203 let mut count = 0;
204
205 for hook in hooks.iter().filter(|h| &h.hook_type == phase) {
206 log::info!("Running {} hook: {}", phase, hook.script_name);
207
208 let sql = replace_placeholders(&hook.sql, placeholders)?;
209
210 match db::execute_in_transaction(client, &sql).await {
211 Ok(exec_time) => {
212 total_ms += exec_time;
213 count += 1;
214 }
215 Err(e) => {
216 let reason = match &e {
217 WaypointError::DatabaseError(db_err) => crate::error::format_db_error(db_err),
218 other => other.to_string(),
219 };
220 return Err(WaypointError::HookFailed {
221 phase: phase.to_string(),
222 script: hook.script_name.clone(),
223 reason,
224 });
225 }
226 }
227 }
228
229 Ok((count, total_ms))
230}
231
232pub async fn run_hooks_db(
239 client: &DbClient,
240 hooks: &[ResolvedHook],
241 phase: &HookType,
242 placeholders: &HashMap<String, String>,
243) -> Result<(usize, i32)> {
244 let mut total_ms = 0;
245 let mut count = 0;
246
247 for hook in hooks.iter().filter(|h| &h.hook_type == phase) {
248 log::info!("Running {} hook: {}", phase, hook.script_name);
249
250 let sql = replace_placeholders(&hook.sql, placeholders)?;
251
252 let exec_result = match client.dialect_kind() {
253 crate::dialect::DialectKind::Postgres => client.execute_in_transaction(&sql).await,
254 crate::dialect::DialectKind::Mysql => client.execute_raw(&sql).await,
255 };
256
257 match exec_result {
258 Ok(exec_time) => {
259 total_ms += exec_time;
260 count += 1;
261 }
262 Err(e) => {
263 #[cfg(feature = "postgres")]
268 let reason = match &e {
269 WaypointError::DatabaseError(db_err) => crate::error::format_db_error(db_err),
270 other => other.to_string(),
271 };
272 #[cfg(not(feature = "postgres"))]
273 let reason = e.to_string();
274 return Err(WaypointError::HookFailed {
275 phase: phase.to_string(),
276 script: hook.script_name.clone(),
277 reason,
278 });
279 }
280 }
281 }
282
283 Ok((count, total_ms))
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use std::fs;
290
291 fn create_temp_dir(name: &str) -> PathBuf {
292 let dir = std::env::temp_dir().join(format!("waypoint_hooks_test_{}", name));
293 let _ = fs::remove_dir_all(&dir);
294 fs::create_dir_all(&dir).unwrap();
295 dir
296 }
297
298 #[test]
299 fn test_is_hook_file() {
300 assert!(is_hook_file("beforeMigrate.sql"));
301 assert!(is_hook_file("afterMigrate.sql"));
302 assert!(is_hook_file("beforeEachMigrate.sql"));
303 assert!(is_hook_file("afterEachMigrate.sql"));
304 assert!(is_hook_file("beforeMigrate__Disable_triggers.sql"));
305 assert!(is_hook_file("afterMigrate__Refresh_views.sql"));
306
307 assert!(!is_hook_file("V1__Create_table.sql"));
308 assert!(!is_hook_file("R__Create_view.sql"));
309 assert!(!is_hook_file("beforeMigrate.txt"));
310 assert!(!is_hook_file("random.sql"));
311
312 assert!(!is_hook_file("beforeMigrate_typo.sql"));
316 assert!(!is_hook_file("beforeMigrateXYZ.sql"));
317 assert!(!is_hook_file("afterMigrate-extra.sql"));
318 }
319
320 #[test]
321 fn test_is_hook_file_agrees_with_scan_hooks() {
322 let dir = create_temp_dir("agree");
323 let names = [
324 "beforeMigrate.sql",
325 "beforeMigrate__ok.sql",
326 "beforeMigrate_typo.sql",
327 "afterMigrateXYZ.sql",
328 "V1__Real.sql",
329 ];
330 for n in &names {
331 fs::write(dir.join(n), "SELECT 1;").unwrap();
332 }
333
334 let collected: std::collections::HashSet<String> = scan_hooks(std::slice::from_ref(&dir))
335 .unwrap()
336 .into_iter()
337 .map(|h| h.script_name)
338 .collect();
339
340 for n in &names {
341 assert_eq!(
342 is_hook_file(n),
343 collected.contains(*n),
344 "{n}: is_hook_file says {}, scan_hooks says {}",
345 is_hook_file(n),
346 collected.contains(*n)
347 );
348 }
349
350 let _ = fs::remove_dir_all(&dir);
351 }
352
353 #[test]
354 fn test_scan_hooks_finds_callback_files() {
355 let dir = create_temp_dir("scan");
356 fs::write(dir.join("beforeMigrate.sql"), "SELECT 1;").unwrap();
357 fs::write(dir.join("afterMigrate__Refresh_views.sql"), "SELECT 2;").unwrap();
358 fs::write(dir.join("V1__Create_table.sql"), "CREATE TABLE t(id INT);").unwrap();
359 fs::write(dir.join("R__Create_view.sql"), "CREATE VIEW v AS SELECT 1;").unwrap();
360
361 let hooks = scan_hooks(std::slice::from_ref(&dir)).unwrap();
362
363 assert_eq!(hooks.len(), 2);
364
365 let before: Vec<_> = hooks
366 .iter()
367 .filter(|h| h.hook_type == HookType::BeforeMigrate)
368 .collect();
369 let after: Vec<_> = hooks
370 .iter()
371 .filter(|h| h.hook_type == HookType::AfterMigrate)
372 .collect();
373 assert_eq!(before.len(), 1);
374 assert_eq!(before[0].script_name, "beforeMigrate.sql");
375 assert_eq!(after.len(), 1);
376 assert_eq!(after[0].script_name, "afterMigrate__Refresh_views.sql");
377
378 let _ = fs::remove_dir_all(&dir);
379 }
380
381 #[test]
382 fn test_scan_hooks_multiple_sorted_alphabetically() {
383 let dir = create_temp_dir("multi");
384 fs::write(dir.join("beforeMigrate__B_second.sql"), "SELECT 2;").unwrap();
385 fs::write(dir.join("beforeMigrate__A_first.sql"), "SELECT 1;").unwrap();
386 fs::write(dir.join("beforeMigrate.sql"), "SELECT 0;").unwrap();
387
388 let hooks = scan_hooks(std::slice::from_ref(&dir)).unwrap();
389
390 assert_eq!(hooks.len(), 3);
391 assert_eq!(hooks[0].script_name, "beforeMigrate.sql");
392 assert_eq!(hooks[1].script_name, "beforeMigrate__A_first.sql");
393 assert_eq!(hooks[2].script_name, "beforeMigrate__B_second.sql");
394
395 let _ = fs::remove_dir_all(&dir);
396 }
397
398 #[test]
399 fn test_load_config_hooks() {
400 let dir = create_temp_dir("config");
401 let hook_file = dir.join("pre.sql");
402 fs::write(&hook_file, "SET work_mem = '256MB';").unwrap();
403
404 let config = HooksConfig {
405 before_migrate: vec![hook_file],
406 after_migrate: vec![],
407 before_each_migrate: vec![],
408 after_each_migrate: vec![],
409 };
410
411 let hooks = load_config_hooks(&config).unwrap();
412 assert_eq!(hooks.len(), 1);
413 assert_eq!(hooks[0].hook_type, HookType::BeforeMigrate);
414 assert_eq!(hooks[0].sql, "SET work_mem = '256MB';");
415
416 let _ = fs::remove_dir_all(&dir);
417 }
418
419 #[test]
420 fn test_load_config_hooks_missing_file() {
421 let config = HooksConfig {
422 before_migrate: vec![PathBuf::from("/nonexistent/hook.sql")],
423 after_migrate: vec![],
424 before_each_migrate: vec![],
425 after_each_migrate: vec![],
426 };
427
428 assert!(load_config_hooks(&config).is_err());
429 }
430}