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 {
63 HOOK_PREFIXES
64 .iter()
65 .any(|(prefix, _)| filename.starts_with(prefix) && filename.ends_with(".sql"))
66}
67
68pub fn scan_hooks(locations: &[PathBuf]) -> Result<Vec<ResolvedHook>> {
78 let mut hooks = Vec::new();
79
80 for location in locations {
81 if !location.exists() {
82 continue;
83 }
84
85 let entries = std::fs::read_dir(location).map_err(|e| {
86 WaypointError::IoError(std::io::Error::new(
87 e.kind(),
88 format!(
89 "Failed to read hook directory '{}': {}",
90 location.display(),
91 e
92 ),
93 ))
94 })?;
95
96 let mut files: Vec<_> = entries
97 .filter_map(|e| e.ok())
98 .filter(|e| e.path().is_file())
99 .collect();
100
101 files.sort_by_key(|e| e.file_name());
103
104 for entry in files {
105 let path = entry.path();
106 let filename = match path.file_name().and_then(|n| n.to_str()) {
107 Some(name) => name.to_string(),
108 None => continue,
109 };
110
111 if !filename.ends_with(".sql") {
112 continue;
113 }
114
115 for (prefix, type_fn) in HOOK_PREFIXES {
117 if filename.starts_with(prefix) {
118 let rest = &filename[prefix.len()..filename.len() - 4]; if rest.is_empty() || rest.starts_with("__") {
121 let sql = std::fs::read_to_string(&path)?;
122 hooks.push(ResolvedHook {
123 hook_type: type_fn(),
124 script_name: filename.clone(),
125 sql,
126 });
127 break;
128 }
129 }
130 }
131 }
132 }
133
134 hooks.sort_by(|a, b| {
136 a.hook_type
137 .to_string()
138 .cmp(&b.hook_type.to_string())
139 .then_with(|| a.script_name.cmp(&b.script_name))
140 });
141
142 Ok(hooks)
143}
144
145pub fn load_config_hooks(config: &HooksConfig) -> Result<Vec<ResolvedHook>> {
147 let mut hooks = Vec::new();
148
149 let sections: &[(HookType, &[PathBuf])] = &[
150 (HookType::BeforeMigrate, &config.before_migrate),
151 (HookType::AfterMigrate, &config.after_migrate),
152 (HookType::BeforeEachMigrate, &config.before_each_migrate),
153 (HookType::AfterEachMigrate, &config.after_each_migrate),
154 ];
155
156 for (hook_type, paths) in sections {
157 for path in *paths {
158 let sql = std::fs::read_to_string(path).map_err(|e| {
159 WaypointError::IoError(std::io::Error::new(
160 e.kind(),
161 format!("Failed to read hook file '{}': {}", path.display(), e),
162 ))
163 })?;
164
165 let script_name = path
166 .file_name()
167 .and_then(|n| n.to_str())
168 .unwrap_or_else(|| path.to_str().unwrap_or("unknown"))
169 .to_string();
170
171 hooks.push(ResolvedHook {
172 hook_type: hook_type.clone(),
173 script_name,
174 sql,
175 });
176 }
177 }
178
179 Ok(hooks)
180}
181
182#[cfg(feature = "postgres")]
186pub async fn run_hooks(
187 client: &Client,
188 hooks: &[ResolvedHook],
189 phase: &HookType,
190 placeholders: &HashMap<String, String>,
191) -> Result<(usize, i32)> {
192 let mut total_ms = 0;
193 let mut count = 0;
194
195 for hook in hooks.iter().filter(|h| &h.hook_type == phase) {
196 log::info!("Running {} hook: {}", phase, hook.script_name);
197
198 let sql = replace_placeholders(&hook.sql, placeholders)?;
199
200 match db::execute_in_transaction(client, &sql).await {
201 Ok(exec_time) => {
202 total_ms += exec_time;
203 count += 1;
204 }
205 Err(e) => {
206 let reason = match &e {
207 WaypointError::DatabaseError(db_err) => crate::error::format_db_error(db_err),
208 other => other.to_string(),
209 };
210 return Err(WaypointError::HookFailed {
211 phase: phase.to_string(),
212 script: hook.script_name.clone(),
213 reason,
214 });
215 }
216 }
217 }
218
219 Ok((count, total_ms))
220}
221
222pub async fn run_hooks_db(
229 client: &DbClient,
230 hooks: &[ResolvedHook],
231 phase: &HookType,
232 placeholders: &HashMap<String, String>,
233) -> Result<(usize, i32)> {
234 let mut total_ms = 0;
235 let mut count = 0;
236
237 for hook in hooks.iter().filter(|h| &h.hook_type == phase) {
238 log::info!("Running {} hook: {}", phase, hook.script_name);
239
240 let sql = replace_placeholders(&hook.sql, placeholders)?;
241
242 let exec_result = match client.dialect_kind() {
243 crate::dialect::DialectKind::Postgres => client.execute_in_transaction(&sql).await,
244 crate::dialect::DialectKind::Mysql => client.execute_raw(&sql).await,
245 };
246
247 match exec_result {
248 Ok(exec_time) => {
249 total_ms += exec_time;
250 count += 1;
251 }
252 Err(e) => {
253 #[cfg(feature = "postgres")]
258 let reason = match &e {
259 WaypointError::DatabaseError(db_err) => crate::error::format_db_error(db_err),
260 other => other.to_string(),
261 };
262 #[cfg(not(feature = "postgres"))]
263 let reason = e.to_string();
264 return Err(WaypointError::HookFailed {
265 phase: phase.to_string(),
266 script: hook.script_name.clone(),
267 reason,
268 });
269 }
270 }
271 }
272
273 Ok((count, total_ms))
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use std::fs;
280
281 fn create_temp_dir(name: &str) -> PathBuf {
282 let dir = std::env::temp_dir().join(format!("waypoint_hooks_test_{}", name));
283 let _ = fs::remove_dir_all(&dir);
284 fs::create_dir_all(&dir).unwrap();
285 dir
286 }
287
288 #[test]
289 fn test_is_hook_file() {
290 assert!(is_hook_file("beforeMigrate.sql"));
291 assert!(is_hook_file("afterMigrate.sql"));
292 assert!(is_hook_file("beforeEachMigrate.sql"));
293 assert!(is_hook_file("afterEachMigrate.sql"));
294 assert!(is_hook_file("beforeMigrate__Disable_triggers.sql"));
295 assert!(is_hook_file("afterMigrate__Refresh_views.sql"));
296
297 assert!(!is_hook_file("V1__Create_table.sql"));
298 assert!(!is_hook_file("R__Create_view.sql"));
299 assert!(!is_hook_file("beforeMigrate.txt"));
300 assert!(!is_hook_file("random.sql"));
301 }
302
303 #[test]
304 fn test_scan_hooks_finds_callback_files() {
305 let dir = create_temp_dir("scan");
306 fs::write(dir.join("beforeMigrate.sql"), "SELECT 1;").unwrap();
307 fs::write(dir.join("afterMigrate__Refresh_views.sql"), "SELECT 2;").unwrap();
308 fs::write(dir.join("V1__Create_table.sql"), "CREATE TABLE t(id INT);").unwrap();
309 fs::write(dir.join("R__Create_view.sql"), "CREATE VIEW v AS SELECT 1;").unwrap();
310
311 let hooks = scan_hooks(std::slice::from_ref(&dir)).unwrap();
312
313 assert_eq!(hooks.len(), 2);
314
315 let before: Vec<_> = hooks
316 .iter()
317 .filter(|h| h.hook_type == HookType::BeforeMigrate)
318 .collect();
319 let after: Vec<_> = hooks
320 .iter()
321 .filter(|h| h.hook_type == HookType::AfterMigrate)
322 .collect();
323 assert_eq!(before.len(), 1);
324 assert_eq!(before[0].script_name, "beforeMigrate.sql");
325 assert_eq!(after.len(), 1);
326 assert_eq!(after[0].script_name, "afterMigrate__Refresh_views.sql");
327
328 let _ = fs::remove_dir_all(&dir);
329 }
330
331 #[test]
332 fn test_scan_hooks_multiple_sorted_alphabetically() {
333 let dir = create_temp_dir("multi");
334 fs::write(dir.join("beforeMigrate__B_second.sql"), "SELECT 2;").unwrap();
335 fs::write(dir.join("beforeMigrate__A_first.sql"), "SELECT 1;").unwrap();
336 fs::write(dir.join("beforeMigrate.sql"), "SELECT 0;").unwrap();
337
338 let hooks = scan_hooks(std::slice::from_ref(&dir)).unwrap();
339
340 assert_eq!(hooks.len(), 3);
341 assert_eq!(hooks[0].script_name, "beforeMigrate.sql");
342 assert_eq!(hooks[1].script_name, "beforeMigrate__A_first.sql");
343 assert_eq!(hooks[2].script_name, "beforeMigrate__B_second.sql");
344
345 let _ = fs::remove_dir_all(&dir);
346 }
347
348 #[test]
349 fn test_load_config_hooks() {
350 let dir = create_temp_dir("config");
351 let hook_file = dir.join("pre.sql");
352 fs::write(&hook_file, "SET work_mem = '256MB';").unwrap();
353
354 let config = HooksConfig {
355 before_migrate: vec![hook_file],
356 after_migrate: vec![],
357 before_each_migrate: vec![],
358 after_each_migrate: vec![],
359 };
360
361 let hooks = load_config_hooks(&config).unwrap();
362 assert_eq!(hooks.len(), 1);
363 assert_eq!(hooks[0].hook_type, HookType::BeforeMigrate);
364 assert_eq!(hooks[0].sql, "SET work_mem = '256MB';");
365
366 let _ = fs::remove_dir_all(&dir);
367 }
368
369 #[test]
370 fn test_load_config_hooks_missing_file() {
371 let config = HooksConfig {
372 before_migrate: vec![PathBuf::from("/nonexistent/hook.sql")],
373 after_migrate: vec![],
374 before_each_migrate: vec![],
375 after_each_migrate: vec![],
376 };
377
378 assert!(load_config_hooks(&config).is_err());
379 }
380}