1use std::collections::HashMap;
47use std::path::{Path, PathBuf};
48use std::process::Stdio;
49
50#[derive(Debug, Clone)]
52pub struct HookResult {
53 pub hook_name: String,
54 pub exit_code: Option<i32>,
55 pub stdout: String,
56 pub stderr: String,
57 pub elapsed: std::time::Duration,
58}
59
60impl HookResult {
61 pub fn success(&self) -> bool {
62 self.exit_code == Some(0)
63 }
64}
65
66#[derive(Debug, Clone)]
68pub(crate) struct ResolvedHook {
69 path: PathBuf,
70}
71
72pub fn hooks_dir(repo_root: &Path) -> PathBuf {
78 let config_path = repo_root.join(".suture").join("config");
80 let Some(content) = std::fs::read_to_string(&config_path).ok() else {
81 return repo_root.join(".suture").join("hooks");
82 };
83 let Ok(config) = toml::from_str::<HashMap<String, toml::Value>>(&content) else {
84 return repo_root.join(".suture").join("hooks");
85 };
86 let Some(toml::Value::String(path)) = config.get("core").and_then(|c| c.get("hooksPath"))
87 else {
88 return repo_root.join(".suture").join("hooks");
89 };
90
91 let path = PathBuf::from(&path);
92 if path.is_absolute() {
93 path
94 } else {
95 repo_root.join(&path)
96 }
97}
98
99pub(crate) fn find_hook(repo_root: &Path, hook_name: &str) -> Option<ResolvedHook> {
103 let dir = hooks_dir(repo_root);
104 let path = dir.join(hook_name);
105
106 if !path.exists() {
108 return None;
109 }
110
111 #[cfg(unix)]
112 {
113 use std::os::unix::fs::PermissionsExt;
114 if let Ok(meta) = path.metadata()
115 && meta.is_file()
116 && (meta.permissions().mode() & 0o111) != 0
117 {
118 return Some(ResolvedHook { path });
119 }
120 }
121
122 #[cfg(not(unix))]
123 {
124 if path.is_file()
126 && std::fs::metadata(&path)
127 .map(|m| m.len() > 0)
128 .unwrap_or(false)
129 {
130 return Some(ResolvedHook { path });
131 }
132 }
133
134 None
135}
136
137pub fn run_hook(
141 repo_root: &Path,
142 hook_name: &str,
143 env: &HashMap<String, String>,
144) -> Result<HookResult, HookError> {
145 let hook = find_hook(repo_root, hook_name).ok_or(HookError::NotFound(hook_name.to_string()))?;
146
147 let start = std::time::Instant::now();
148
149 let output = std::process::Command::new(&hook.path)
150 .envs(env)
151 .stdout(Stdio::piped())
152 .stderr(Stdio::piped())
153 .output()
154 .map_err(|e| HookError::ExecFailed {
155 hook: hook_name.to_string(),
156 path: hook.path.display().to_string(),
157 error: e.to_string(),
158 })?;
159
160 let elapsed = start.elapsed();
161 let exit_code = output.status.code();
162
163 Ok(HookResult {
164 hook_name: hook_name.to_string(),
165 exit_code,
166 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
167 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
168 elapsed,
169 })
170}
171
172pub fn run_hooks(
177 repo_root: &Path,
178 hook_name: &str,
179 env: &HashMap<String, String>,
180) -> Result<Vec<HookResult>, HookError> {
181 let dir = hooks_dir(repo_root);
182 let direct_hook = dir.join(hook_name);
183
184 let mut results = Vec::new();
185
186 if direct_hook.exists() {
187 match run_hook(repo_root, hook_name, env) {
189 Ok(result) => {
190 results.push(result);
191 }
192 Err(HookError::NotFound(_)) => {
193 }
195 Err(e) => {
196 return Err(e);
197 }
198 }
199 }
200
201 let hook_sub_dir = dir.join(format!("{}.d", hook_name));
203 if hook_sub_dir.is_dir() {
204 let mut entries: Vec<_> = std::fs::read_dir(&hook_sub_dir)
205 .map_err(|e| HookError::ExecFailed {
206 hook: hook_name.to_string(),
207 path: hook_sub_dir.display().to_string(),
208 error: e.to_string(),
209 })?
210 .filter_map(|entry| entry.ok())
211 .filter_map(|entry| {
212 let path = entry.path();
213 if path.is_file() { Some(path) } else { None }
214 })
215 .collect::<Vec<_>>();
216 entries.sort();
217
218 for path in entries {
219 let file_name = path
220 .file_name()
221 .map(|n| n.to_string_lossy().to_string())
222 .unwrap_or_default();
223 let sub_hook_name = format!("{}/{}", hook_name, file_name);
224
225 #[cfg(unix)]
227 {
228 use std::os::unix::fs::PermissionsExt;
229 let Ok(meta) = path.metadata() else {
230 continue;
231 };
232 if meta.is_file() && (meta.permissions().mode() & 0o111) == 0 {
233 continue; }
235 }
236
237 let start = std::time::Instant::now();
238 let output = std::process::Command::new(&path)
239 .envs(env)
240 .env("SUTURE_HOOK", &sub_hook_name)
241 .stdout(Stdio::piped())
242 .stderr(Stdio::piped())
243 .output()
244 .map_err(|e| HookError::ExecFailed {
245 hook: sub_hook_name.clone(),
246 path: path.display().to_string(),
247 error: e.to_string(),
248 })?;
249
250 let elapsed = start.elapsed();
251 let result = HookResult {
252 hook_name: sub_hook_name,
253 exit_code: output.status.code(),
254 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
255 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
256 elapsed,
257 };
258 if !result.success() {
259 return Err(HookError::ExecFailed {
260 hook: result.hook_name,
261 path: path.display().to_string(),
262 error: format!("hook exited with code {:?}", result.exit_code),
263 });
264 }
265 results.push(result);
266 }
267 }
268
269 Ok(results)
270}
271
272pub fn build_env(
277 repo_root: &Path,
278 hook_name: &str,
279 author: Option<&str>,
280 branch: Option<&str>,
281 head_hash: Option<&str>,
282 extra: HashMap<String, String>,
283) -> HashMap<String, String> {
284 let mut env = HashMap::new();
285
286 env.insert("SUTURE_HOOK".to_string(), hook_name.to_string());
288 env.insert(
289 "SUTURE_REPO".to_string(),
290 repo_root.to_string_lossy().to_string(),
291 );
292 env.insert(
293 "SUTURE_HOOK_DIR".to_string(),
294 hooks_dir(repo_root).to_string_lossy().to_string(),
295 );
296 env.insert("SUTURE_OPERATION".to_string(), hook_name.to_string());
297
298 if let Some(a) = author {
300 env.insert("SUTURE_AUTHOR".to_string(), a.to_string());
301 }
302
303 if let Some(b) = branch {
305 env.insert("SUTURE_BRANCH".to_string(), b.to_string());
306 }
307
308 if let Some(h) = head_hash {
310 env.insert("SUTURE_HEAD".to_string(), h.to_string());
311 }
312
313 for (k, v) in extra {
315 env.insert(k, v);
316 }
317
318 env
319}
320
321pub fn format_hook_result(result: &HookResult) -> String {
323 let status = if result.success() { "passed" } else { "FAILED" };
324 format!(
325 "{}: {} ({})",
326 result.hook_name,
327 status,
328 result.exit_code.unwrap_or(-1)
329 )
330}
331
332#[derive(Debug, thiserror::Error)]
334pub enum HookError {
335 #[error("hook not found: {0}")]
336 NotFound(String),
337 #[error("hook '{hook}' exec failed: {path}: {error}")]
338 ExecFailed {
339 hook: String,
340 path: String,
341 error: String,
342 },
343 #[error("I/O error: {0}")]
344 Io(#[from] std::io::Error),
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350 use std::fs;
351
352 fn make_hook(dir: &Path, name: &str, content: &str) -> PathBuf {
353 let path = dir.join(name);
354 fs::write(&path, content).unwrap();
355 #[cfg(unix)]
356 {
357 use std::os::unix::fs::PermissionsExt;
358 fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
359 }
360 path
361 }
362
363 #[test]
364 fn test_find_hook_exists_and_executable() {
365 let tmp = tempfile::tempdir().unwrap();
366 let hook_dir = tmp.path().join(".suture").join("hooks");
367 fs::create_dir_all(&hook_dir).unwrap();
368 make_hook(&hook_dir, "pre-commit", "#!/bin/sh\nexit 0");
369
370 let hook = find_hook(tmp.path(), "pre-commit");
371 assert!(hook.is_some());
372 assert_eq!(hook.unwrap().path, hook_dir.join("pre-commit"));
373 }
374
375 #[test]
376 fn test_find_hook_not_exists() {
377 let tmp = tempfile::tempdir().unwrap();
378 let hook = find_hook(tmp.path(), "pre-commit");
379 assert!(hook.is_none());
380 }
381
382 #[test]
383 fn test_find_hook_not_executable() {
384 let tmp = tempfile::tempdir().unwrap();
385 let hook_dir = tmp.path().join(".suture").join("hooks");
386 fs::create_dir_all(&hook_dir).unwrap();
387 let path = hook_dir.join("pre-commit");
388 fs::write(&path, "#!/bin/sh\nexit 0").unwrap();
389 #[cfg(unix)]
390 {
391 use std::os::unix::fs::PermissionsExt;
392 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
393 }
394
395 let hook = find_hook(tmp.path(), "pre-commit");
396 #[cfg(unix)]
397 {
398 assert!(hook.is_none());
399 }
400 #[cfg(not(unix))]
401 {
402 assert!(hook.is_some());
403 }
404 }
405
406 #[test]
407 fn test_run_hook_success() {
408 let tmp = tempfile::tempdir().unwrap();
409 let hook_dir = tmp.path().join(".suture").join("hooks");
410 fs::create_dir_all(&hook_dir).unwrap();
411 make_hook(
412 &hook_dir,
413 "pre-commit",
414 "#!/bin/sh\necho 'hook ran'\nexit 0",
415 );
416
417 let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
418 let result = run_hook(tmp.path(), "pre-commit", &env).unwrap();
419 assert!(result.success());
420 assert_eq!(result.stdout.trim(), "hook ran");
421 }
422
423 #[test]
424 fn test_run_hook_failure() {
425 let tmp = tempfile::tempdir().unwrap();
426 let hook_dir = tmp.path().join(".suture").join("hooks");
427 fs::create_dir_all(&hook_dir).unwrap();
428 make_hook(
429 &hook_dir,
430 "pre-commit",
431 "#!/bin/sh\necho 'failing' >&2\nexit 1",
432 );
433
434 let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
435 let result = run_hook(tmp.path(), "pre-commit", &env).unwrap();
436 assert!(!result.success());
437 assert_eq!(result.exit_code, Some(1));
438 assert!(result.stderr.contains("failing"));
439 }
440
441 #[test]
442 fn test_run_hook_not_found() {
443 let tmp = tempfile::tempdir().unwrap();
444 let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
445 let err = run_hook(tmp.path(), "pre-commit", &env);
446 assert!(matches!(err, Err(HookError::NotFound(_))));
447 }
448
449 #[test]
450 fn test_build_env_basic() {
451 let tmp = tempfile::tempdir().unwrap();
452 let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
453
454 assert_eq!(env.get("SUTURE_HOOK").unwrap(), "pre-commit");
455 assert!(
456 env.get("SUTURE_REPO")
457 .unwrap()
458 .contains(tmp.path().to_str().unwrap())
459 );
460 assert_eq!(env.get("SUTURE_OPERATION").unwrap(), "pre-commit");
461 assert!(!env.contains_key("SUTURE_AUTHOR"));
463 assert!(!env.contains_key("SUTURE_BRANCH"));
464 assert!(!env.contains_key("SUTURE_HEAD"));
465 }
466
467 #[test]
468 fn test_build_env_with_author_branch() {
469 let tmp = tempfile::tempdir().unwrap();
470 let env = build_env(
471 tmp.path(),
472 "pre-commit",
473 Some("Alice"),
474 Some("main"),
475 Some("abc123"),
476 HashMap::new(),
477 );
478
479 assert_eq!(env.get("SUTURE_AUTHOR").unwrap(), "Alice");
480 assert_eq!(env.get("SUTURE_BRANCH").unwrap(), "main");
481 assert_eq!(env.get("SUTURE_HEAD").unwrap(), "abc123");
482 }
483
484 #[test]
485 fn test_build_env_with_extras() {
486 let tmp = tempfile::tempdir().unwrap();
487 let mut extras = HashMap::new();
488 extras.insert("CUSTOM_VAR".to_string(), "value".to_string());
489 let env = build_env(tmp.path(), "pre-push", None, None, None, extras);
490
491 assert_eq!(env.get("CUSTOM_VAR").unwrap(), "value");
492 assert_eq!(env.get("SUTURE_HOOK").unwrap(), "pre-push");
493 }
494
495 #[test]
496 fn test_format_hook_result() {
497 let result = HookResult {
498 hook_name: "pre-commit".to_string(),
499 exit_code: Some(0),
500 stdout: "all good".to_string(),
501 stderr: String::new(),
502 elapsed: std::time::Duration::from_millis(5),
503 };
504 let formatted = format_hook_result(&result);
505 assert!(formatted.contains("passed"));
506 }
507
508 #[test]
509 fn test_format_hook_result_failure() {
510 let result = HookResult {
511 hook_name: "pre-commit".to_string(),
512 exit_code: Some(1),
513 stdout: String::new(),
514 stderr: "error!".to_string(),
515 elapsed: std::time::Duration::from_millis(3),
516 };
517 let formatted = format_hook_result(&result);
518 assert!(formatted.contains("FAILED"));
519 }
520
521 #[test]
522 fn test_hooks_dir_default() {
523 let tmp = tempfile::tempdir().unwrap();
524 let dir = hooks_dir(tmp.path());
525 assert!(dir.to_string_lossy().contains(".suture"));
526 assert!(dir.to_string_lossy().contains("hooks"));
527 }
528
529 #[test]
530 fn test_hooks_dir_from_config() {
531 let tmp = tempfile::tempdir().unwrap();
532 let suture_dir = tmp.path().join(".suture");
533 fs::create_dir_all(&suture_dir).unwrap();
534
535 let config = r#"
536[core]
537hooksPath = "my-hooks"
538"#;
539 fs::write(suture_dir.join("config"), config).unwrap();
540
541 let dir = hooks_dir(tmp.path());
542 assert!(dir.to_string_lossy().contains("my-hooks"));
543 }
544
545 #[test]
546 fn test_hooks_dir_from_config_absolute() {
547 let tmp = tempfile::tempdir().unwrap();
548 let suture_dir = tmp.path().join(".suture");
549 fs::create_dir_all(&suture_dir).unwrap();
550
551 let config = r#"
552[core]
553hooksPath = "/tmp/custom-hooks"
554"#;
555 fs::write(suture_dir.join("config"), config).unwrap();
556
557 let dir = hooks_dir(tmp.path());
558 assert!(dir.to_string_lossy().contains("/tmp/custom-hooks"));
559 }
560
561 #[test]
562 fn test_run_hooks_directory() {
563 let tmp = tempfile::tempdir().unwrap();
564 let hook_dir = tmp.path().join(".suture").join("hooks");
565 fs::create_dir_all(&hook_dir).unwrap();
566 let hook_subdir = hook_dir.join("pre-commit.d");
567 fs::create_dir_all(&hook_subdir).unwrap();
568
569 make_hook(&hook_subdir, "01-check", "#!/bin/sh\nexit 0");
570 make_hook(&hook_subdir, "02-lint", "#!/bin/sh\nexit 0");
571 make_hook(&hook_subdir, "03-test", "#!/bin/sh\nexit 0");
572
573 let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
574 let results = run_hooks(tmp.path(), "pre-commit", &env).unwrap();
575 assert_eq!(results.len(), 3);
576 assert!(results.iter().all(|r| r.success()));
577 }
578
579 #[test]
580 fn test_run_hooks_directory_failure_stops() {
581 let tmp = tempfile::tempdir().unwrap();
582 let hook_dir = tmp.path().join(".suture").join("hooks");
583 fs::create_dir_all(&hook_dir).unwrap();
584 let hook_subdir = hook_dir.join("pre-commit.d");
585 fs::create_dir_all(&hook_subdir).unwrap();
586
587 make_hook(&hook_subdir, "01-pass", "#!/bin/sh\nexit 0");
588 make_hook(&hook_subdir, "02-fail", "#!/bin/sh\nexit 1");
589
590 let env = build_env(tmp.path(), "pre-commit", None, None, None, HashMap::new());
591 let err = run_hooks(tmp.path(), "pre-commit", &env);
592 assert!(err.is_err());
593 }
594}