1use std::fs;
8use std::path::Path;
9
10use crate::error::SoukError;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum HookManager {
15 Native,
17 Lefthook,
19 Husky,
21 Overcommit,
23 Hk,
25 SimpleGitHooks,
27}
28
29impl HookManager {
30 pub fn name(&self) -> &str {
32 match self {
33 HookManager::Native => "native",
34 HookManager::Lefthook => "lefthook",
35 HookManager::Husky => "husky",
36 HookManager::Overcommit => "overcommit",
37 HookManager::Hk => "hk",
38 HookManager::SimpleGitHooks => "simple-git-hooks",
39 }
40 }
41}
42
43impl std::fmt::Display for HookManager {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 write!(f, "{}", self.name())
46 }
47}
48
49pub fn detect_hook_manager(project_root: &Path) -> Option<HookManager> {
60 if project_root.join("lefthook.yml").exists() || project_root.join("lefthook.yaml").exists() {
61 Some(HookManager::Lefthook)
62 } else if project_root.join(".husky").is_dir() {
63 Some(HookManager::Husky)
64 } else if project_root.join(".overcommit.yml").exists() {
65 Some(HookManager::Overcommit)
66 } else if project_root.join("hk.toml").exists() {
67 Some(HookManager::Hk)
68 } else if project_root.join(".simple-git-hooks.json").exists() {
69 Some(HookManager::SimpleGitHooks)
70 } else {
71 None
72 }
73}
74
75pub fn install_hooks(project_root: &Path, manager: &HookManager) -> Result<String, SoukError> {
80 match manager {
81 HookManager::Native => install_native_hooks(project_root),
82 HookManager::Lefthook => install_lefthook(project_root),
83 HookManager::Husky => install_husky(project_root),
84 HookManager::Overcommit => install_overcommit(project_root),
85 HookManager::Hk => install_hk(project_root),
86 HookManager::SimpleGitHooks => install_simple_git_hooks(project_root),
87 }
88}
89
90const NATIVE_HOOK_TEMPLATE: &str = "#!/bin/sh\nsouk ci run {hook}\n";
92
93fn install_native_hooks(project_root: &Path) -> Result<String, SoukError> {
95 let hooks_dir = project_root.join(".git").join("hooks");
96 fs::create_dir_all(&hooks_dir)?;
97
98 let mut actions = Vec::new();
99
100 for hook_name in &["pre-commit", "pre-push"] {
101 let hook_path = hooks_dir.join(hook_name);
102 let content = NATIVE_HOOK_TEMPLATE.replace("{hook}", hook_name);
103 fs::write(&hook_path, &content)?;
104
105 #[cfg(unix)]
107 {
108 use std::os::unix::fs::PermissionsExt;
109 let perms = fs::Permissions::from_mode(0o755);
110 fs::set_permissions(&hook_path, perms)?;
111 }
112
113 actions.push(format!("Created {}", hook_path.display()));
114 }
115
116 Ok(format!(
117 "Installed native git hooks:\n {}",
118 actions.join("\n ")
119 ))
120}
121
122const LEFTHOOK_SNIPPET: &str = r#"
124pre-commit:
125 commands:
126 souk-validate:
127 run: souk ci run pre-commit
128
129pre-push:
130 commands:
131 souk-validate:
132 run: souk ci run pre-push
133"#;
134
135fn install_lefthook(project_root: &Path) -> Result<String, SoukError> {
137 let config_path = if project_root.join("lefthook.yml").exists() {
138 project_root.join("lefthook.yml")
139 } else if project_root.join("lefthook.yaml").exists() {
140 project_root.join("lefthook.yaml")
141 } else {
142 project_root.join("lefthook.yml")
144 };
145
146 let existing = if config_path.exists() {
147 fs::read_to_string(&config_path)?
148 } else {
149 String::new()
150 };
151
152 if existing.contains("souk-validate") {
154 return Ok(format!(
155 "Lefthook hooks already configured in {}",
156 config_path.display()
157 ));
158 }
159
160 let new_content = format!("{existing}{LEFTHOOK_SNIPPET}");
161 fs::write(&config_path, new_content)?;
162
163 Ok(format!("Appended souk hooks to {}", config_path.display()))
164}
165
166const HUSKY_HOOK_TEMPLATE: &str = "souk ci run {hook}\n";
168
169fn install_husky(project_root: &Path) -> Result<String, SoukError> {
171 let husky_dir = project_root.join(".husky");
172 fs::create_dir_all(&husky_dir)?;
173
174 let mut actions = Vec::new();
175
176 for hook_name in &["pre-commit", "pre-push"] {
177 let hook_path = husky_dir.join(hook_name);
178 let content = HUSKY_HOOK_TEMPLATE.replace("{hook}", hook_name);
179
180 if hook_path.exists() {
182 let existing = fs::read_to_string(&hook_path)?;
183 if existing.contains("souk ci run") {
184 actions.push(format!("Already configured: {}", hook_path.display()));
185 continue;
186 }
187 let new_content = format!("{existing}\n{content}");
189 fs::write(&hook_path, new_content)?;
190 actions.push(format!("Appended to {}", hook_path.display()));
191 } else {
192 fs::write(&hook_path, &content)?;
193 actions.push(format!("Created {}", hook_path.display()));
194 }
195
196 #[cfg(unix)]
198 {
199 use std::os::unix::fs::PermissionsExt;
200 let perms = fs::Permissions::from_mode(0o755);
201 fs::set_permissions(&hook_path, perms)?;
202 }
203 }
204
205 Ok(format!(
206 "Installed Husky hooks:\n {}",
207 actions.join("\n ")
208 ))
209}
210
211const OVERCOMMIT_SNIPPET: &str = r#"
213# Add the following to your .overcommit.yml:
214#
215# PreCommit:
216# SoukValidate:
217# enabled: true
218# command: ['souk', 'ci', 'run', 'pre-commit']
219#
220# PrePush:
221# SoukValidate:
222# enabled: true
223# command: ['souk', 'ci', 'run', 'pre-push']
224"#;
225
226fn install_overcommit(project_root: &Path) -> Result<String, SoukError> {
231 let config_path = project_root.join(".overcommit.yml");
232
233 let existing = if config_path.exists() {
234 fs::read_to_string(&config_path)?
235 } else {
236 String::new()
237 };
238
239 if existing.contains("SoukValidate") {
240 return Ok(format!(
241 "Overcommit hooks already configured in {}",
242 config_path.display()
243 ));
244 }
245
246 let new_content = format!("{existing}{OVERCOMMIT_SNIPPET}");
247 fs::write(&config_path, new_content)?;
248
249 Ok(format!(
250 "Added souk hook configuration notes to {}. \
251 Please integrate the commented YAML into your overcommit config.",
252 config_path.display()
253 ))
254}
255
256const HK_SNIPPET: &str = r#"
258# Add the following to your hk.toml:
259#
260# [hooks.pre-commit.souk-validate]
261# run = "souk ci run pre-commit"
262#
263# [hooks.pre-push.souk-validate]
264# run = "souk ci run pre-push"
265"#;
266
267fn install_hk(project_root: &Path) -> Result<String, SoukError> {
272 let config_path = project_root.join("hk.toml");
273
274 let existing = if config_path.exists() {
275 fs::read_to_string(&config_path)?
276 } else {
277 String::new()
278 };
279
280 if existing.contains("souk-validate") {
281 return Ok(format!(
282 "hk hooks already configured in {}",
283 config_path.display()
284 ));
285 }
286
287 let new_content = format!("{existing}{HK_SNIPPET}");
288 fs::write(&config_path, new_content)?;
289
290 Ok(format!(
291 "Added souk hook configuration notes to {}. \
292 Please integrate the commented TOML into your hk config.",
293 config_path.display()
294 ))
295}
296
297const SIMPLE_GIT_HOOKS_NOTE: &str = r#"
299Merge the following into your .simple-git-hooks.json:
300
301{
302 "pre-commit": "souk ci run pre-commit",
303 "pre-push": "souk ci run pre-push"
304}
305"#;
306
307fn install_simple_git_hooks(project_root: &Path) -> Result<String, SoukError> {
312 let config_path = project_root.join(".simple-git-hooks.json");
313
314 if config_path.exists() {
315 let existing = fs::read_to_string(&config_path)?;
316 if existing.contains("souk ci run") {
317 return Ok(format!(
318 "simple-git-hooks already configured in {}",
319 config_path.display()
320 ));
321 }
322
323 let parsed: Result<serde_json::Value, _> = serde_json::from_str(&existing);
325 match parsed {
326 Ok(serde_json::Value::Object(mut map)) => {
327 map.entry("pre-commit").or_insert(serde_json::Value::String(
328 "souk ci run pre-commit".to_string(),
329 ));
330 map.entry("pre-push").or_insert(serde_json::Value::String(
331 "souk ci run pre-push".to_string(),
332 ));
333 let new_content = serde_json::to_string_pretty(&map)?;
334 fs::write(&config_path, format!("{new_content}\n"))?;
335 Ok(format!("Merged souk hooks into {}", config_path.display()))
336 }
337 _ => Ok(format!(
338 "Could not parse {}. {SIMPLE_GIT_HOOKS_NOTE}",
339 config_path.display()
340 )),
341 }
342 } else {
343 let hooks = serde_json::json!({
345 "pre-commit": "souk ci run pre-commit",
346 "pre-push": "souk ci run pre-push"
347 });
348 let content = serde_json::to_string_pretty(&hooks)?;
349 fs::write(&config_path, format!("{content}\n"))?;
350 Ok(format!("Created {}", config_path.display()))
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use tempfile::TempDir;
358
359 #[test]
360 fn detect_hook_manager_finds_lefthook_yml() {
361 let tmp = TempDir::new().unwrap();
362 fs::write(tmp.path().join("lefthook.yml"), "").unwrap();
363 assert_eq!(detect_hook_manager(tmp.path()), Some(HookManager::Lefthook));
364 }
365
366 #[test]
367 fn detect_hook_manager_finds_lefthook_yaml() {
368 let tmp = TempDir::new().unwrap();
369 fs::write(tmp.path().join("lefthook.yaml"), "").unwrap();
370 assert_eq!(detect_hook_manager(tmp.path()), Some(HookManager::Lefthook));
371 }
372
373 #[test]
374 fn detect_hook_manager_finds_husky() {
375 let tmp = TempDir::new().unwrap();
376 fs::create_dir(tmp.path().join(".husky")).unwrap();
377 assert_eq!(detect_hook_manager(tmp.path()), Some(HookManager::Husky));
378 }
379
380 #[test]
381 fn detect_hook_manager_finds_overcommit() {
382 let tmp = TempDir::new().unwrap();
383 fs::write(tmp.path().join(".overcommit.yml"), "").unwrap();
384 assert_eq!(
385 detect_hook_manager(tmp.path()),
386 Some(HookManager::Overcommit)
387 );
388 }
389
390 #[test]
391 fn detect_hook_manager_finds_hk() {
392 let tmp = TempDir::new().unwrap();
393 fs::write(tmp.path().join("hk.toml"), "").unwrap();
394 assert_eq!(detect_hook_manager(tmp.path()), Some(HookManager::Hk));
395 }
396
397 #[test]
398 fn detect_hook_manager_finds_simple_git_hooks() {
399 let tmp = TempDir::new().unwrap();
400 fs::write(tmp.path().join(".simple-git-hooks.json"), "{}").unwrap();
401 assert_eq!(
402 detect_hook_manager(tmp.path()),
403 Some(HookManager::SimpleGitHooks)
404 );
405 }
406
407 #[test]
408 fn detect_hook_manager_returns_none_for_empty_dir() {
409 let tmp = TempDir::new().unwrap();
410 assert_eq!(detect_hook_manager(tmp.path()), None);
411 }
412
413 #[test]
414 fn install_native_hooks_creates_hook_files() {
415 let tmp = TempDir::new().unwrap();
416 fs::create_dir(tmp.path().join(".git")).unwrap();
418
419 let result = install_native_hooks(tmp.path()).unwrap();
420 assert!(result.contains("Installed native git hooks"));
421
422 let pre_commit = tmp.path().join(".git/hooks/pre-commit");
423 let pre_push = tmp.path().join(".git/hooks/pre-push");
424
425 assert!(pre_commit.exists());
426 assert!(pre_push.exists());
427
428 let pre_commit_content = fs::read_to_string(&pre_commit).unwrap();
429 assert!(pre_commit_content.contains("#!/bin/sh"));
430 assert!(pre_commit_content.contains("souk ci run pre-commit"));
431
432 let pre_push_content = fs::read_to_string(&pre_push).unwrap();
433 assert!(pre_push_content.contains("souk ci run pre-push"));
434
435 #[cfg(unix)]
437 {
438 use std::os::unix::fs::PermissionsExt;
439 let perms = fs::metadata(&pre_commit).unwrap().permissions();
440 assert!(perms.mode() & 0o111 != 0, "pre-commit should be executable");
441 }
442 }
443
444 #[test]
445 fn install_husky_creates_hook_files() {
446 let tmp = TempDir::new().unwrap();
447
448 let result = install_husky(tmp.path()).unwrap();
449 assert!(result.contains("Installed Husky hooks"));
450
451 let pre_commit = tmp.path().join(".husky/pre-commit");
452 let pre_push = tmp.path().join(".husky/pre-push");
453
454 assert!(pre_commit.exists());
455 assert!(pre_push.exists());
456
457 let pre_commit_content = fs::read_to_string(&pre_commit).unwrap();
458 assert!(pre_commit_content.contains("souk ci run pre-commit"));
459
460 let pre_push_content = fs::read_to_string(&pre_push).unwrap();
461 assert!(pre_push_content.contains("souk ci run pre-push"));
462 }
463
464 #[test]
465 fn install_lefthook_creates_config() {
466 let tmp = TempDir::new().unwrap();
467
468 let result = install_lefthook(tmp.path()).unwrap();
469 assert!(result.contains("Appended souk hooks"));
470
471 let config = fs::read_to_string(tmp.path().join("lefthook.yml")).unwrap();
472 assert!(config.contains("souk-validate"));
473 assert!(config.contains("souk ci run pre-commit"));
474 assert!(config.contains("souk ci run pre-push"));
475 }
476
477 #[test]
478 fn install_lefthook_appends_to_existing() {
479 let tmp = TempDir::new().unwrap();
480 fs::write(
481 tmp.path().join("lefthook.yml"),
482 "# existing config\nsome-key: value\n",
483 )
484 .unwrap();
485
486 let result = install_lefthook(tmp.path()).unwrap();
487 assert!(result.contains("Appended souk hooks"));
488
489 let config = fs::read_to_string(tmp.path().join("lefthook.yml")).unwrap();
490 assert!(config.contains("# existing config"));
491 assert!(config.contains("souk-validate"));
492 }
493
494 #[test]
495 fn install_lefthook_skips_if_already_configured() {
496 let tmp = TempDir::new().unwrap();
497 fs::write(
498 tmp.path().join("lefthook.yml"),
499 "pre-commit:\n commands:\n souk-validate:\n run: souk ci run pre-commit\n",
500 )
501 .unwrap();
502
503 let result = install_lefthook(tmp.path()).unwrap();
504 assert!(result.contains("already configured"));
505 }
506
507 #[test]
508 fn install_overcommit_appends_note() {
509 let tmp = TempDir::new().unwrap();
510
511 let result = install_overcommit(tmp.path()).unwrap();
512 assert!(result.contains("Added souk hook configuration notes"));
513
514 let config = fs::read_to_string(tmp.path().join(".overcommit.yml")).unwrap();
515 assert!(config.contains("SoukValidate"));
516 }
517
518 #[test]
519 fn install_hk_appends_note() {
520 let tmp = TempDir::new().unwrap();
521
522 let result = install_hk(tmp.path()).unwrap();
523 assert!(result.contains("Added souk hook configuration notes"));
524
525 let config = fs::read_to_string(tmp.path().join("hk.toml")).unwrap();
526 assert!(config.contains("souk-validate"));
527 }
528
529 #[test]
530 fn install_simple_git_hooks_creates_new_file() {
531 let tmp = TempDir::new().unwrap();
532
533 let result = install_simple_git_hooks(tmp.path()).unwrap();
534 assert!(result.contains("Created"));
535
536 let config = fs::read_to_string(tmp.path().join(".simple-git-hooks.json")).unwrap();
537 let parsed: serde_json::Value = serde_json::from_str(&config).unwrap();
538 assert_eq!(parsed["pre-commit"], "souk ci run pre-commit");
539 assert_eq!(parsed["pre-push"], "souk ci run pre-push");
540 }
541
542 #[test]
543 fn install_simple_git_hooks_merges_into_existing() {
544 let tmp = TempDir::new().unwrap();
545 fs::write(
546 tmp.path().join(".simple-git-hooks.json"),
547 r#"{"commit-msg": "echo ok"}"#,
548 )
549 .unwrap();
550
551 let result = install_simple_git_hooks(tmp.path()).unwrap();
552 assert!(result.contains("Merged souk hooks"));
553
554 let config = fs::read_to_string(tmp.path().join(".simple-git-hooks.json")).unwrap();
555 let parsed: serde_json::Value = serde_json::from_str(&config).unwrap();
556 assert_eq!(parsed["pre-commit"], "souk ci run pre-commit");
557 assert_eq!(parsed["pre-push"], "souk ci run pre-push");
558 assert_eq!(parsed["commit-msg"], "echo ok");
559 }
560
561 #[test]
562 fn install_husky_appends_to_existing_hooks() {
563 let tmp = TempDir::new().unwrap();
564 let husky_dir = tmp.path().join(".husky");
565 fs::create_dir(&husky_dir).unwrap();
566 fs::write(husky_dir.join("pre-commit"), "echo 'existing hook'\n").unwrap();
567
568 let result = install_husky(tmp.path()).unwrap();
569 assert!(result.contains("Appended to"));
570
571 let content = fs::read_to_string(husky_dir.join("pre-commit")).unwrap();
572 assert!(content.contains("existing hook"));
573 assert!(content.contains("souk ci run pre-commit"));
574 }
575
576 #[test]
577 fn install_husky_skips_if_already_configured() {
578 let tmp = TempDir::new().unwrap();
579 let husky_dir = tmp.path().join(".husky");
580 fs::create_dir(&husky_dir).unwrap();
581 fs::write(husky_dir.join("pre-commit"), "souk ci run pre-commit\n").unwrap();
582
583 let result = install_husky(tmp.path()).unwrap();
584 assert!(result.contains("Already configured"));
585 }
586
587 #[test]
588 fn hook_manager_name_returns_expected_values() {
589 assert_eq!(HookManager::Native.name(), "native");
590 assert_eq!(HookManager::Lefthook.name(), "lefthook");
591 assert_eq!(HookManager::Husky.name(), "husky");
592 assert_eq!(HookManager::Overcommit.name(), "overcommit");
593 assert_eq!(HookManager::Hk.name(), "hk");
594 assert_eq!(HookManager::SimpleGitHooks.name(), "simple-git-hooks");
595 }
596
597 #[test]
598 fn hook_manager_display() {
599 assert_eq!(format!("{}", HookManager::Lefthook), "lefthook");
600 assert_eq!(format!("{}", HookManager::Native), "native");
601 }
602}