Skip to main content

souk_core/ci/
install_hooks.rs

1//! Hook installation for various git hook managers.
2//!
3//! Detects which hook manager is in use (lefthook, husky, overcommit, hk,
4//! simple-git-hooks, or native git hooks) and generates the appropriate
5//! configuration to run `souk ci run` on pre-commit and pre-push.
6
7use std::fs;
8use std::path::Path;
9
10use crate::error::SoukError;
11
12/// Supported git hook managers.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum HookManager {
15    /// Native git hooks (`.git/hooks/`)
16    Native,
17    /// Lefthook (`lefthook.yml`)
18    Lefthook,
19    /// Husky (`.husky/`)
20    Husky,
21    /// Overcommit (`.overcommit.yml`)
22    Overcommit,
23    /// hk (`hk.toml`)
24    Hk,
25    /// simple-git-hooks (`.simple-git-hooks.json`)
26    SimpleGitHooks,
27}
28
29impl HookManager {
30    /// Returns the lowercase name of the hook manager.
31    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
49/// Auto-detect the hook manager in use at the given project root.
50///
51/// Checks for configuration files in priority order:
52/// 1. `lefthook.yml` or `lefthook.yaml`
53/// 2. `.husky/` directory
54/// 3. `.overcommit.yml`
55/// 4. `hk.toml`
56/// 5. `.simple-git-hooks.json`
57///
58/// Returns `None` if no hook manager is detected (caller should default to native).
59pub 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
75/// Install git hooks for the specified hook manager.
76///
77/// Creates or appends configuration files appropriate for the manager.
78/// Returns a human-readable description of what was done.
79pub 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
90/// The shebang and hook body for native git hooks.
91const NATIVE_HOOK_TEMPLATE: &str = "#!/bin/sh\nsouk ci run {hook}\n";
92
93/// Install native git hooks by writing scripts to `.git/hooks/`.
94fn 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        // Make executable on Unix
106        #[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
122/// YAML snippet to append to `lefthook.yml`.
123const 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
135/// Install hooks by appending configuration to `lefthook.yml`.
136fn 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        // Create a new lefthook.yml
143        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    // Check if souk hooks are already configured
153    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
166/// Husky hook script content (no shebang needed for Husky v9+).
167const HUSKY_HOOK_TEMPLATE: &str = "souk ci run {hook}\n";
168
169/// Install hooks by writing scripts into the `.husky/` directory.
170fn 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 file already exists, check if souk line is already there
181        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            // Append to existing hook
188            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        // Make executable on Unix
197        #[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
211/// YAML snippet for overcommit.
212const 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
226/// Install hooks for overcommit by appending a commented note to `.overcommit.yml`.
227///
228/// Overcommit uses a structured YAML format that requires careful merging,
229/// so we append configuration as a commented block for the user to integrate.
230fn 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
256/// TOML snippet for hk.
257const 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
267/// Install hooks for hk by appending a commented note to `hk.toml`.
268///
269/// hk uses a structured TOML format that requires careful merging,
270/// so we append configuration as a commented block for the user to integrate.
271fn 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
297/// JSON snippet for simple-git-hooks.
298const 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
307/// Install hooks for simple-git-hooks by updating `.simple-git-hooks.json`.
308///
309/// If the file exists, we attempt to merge our hook entries. If the file
310/// does not exist, we create it with the souk hooks.
311fn 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        // Try to merge into existing JSON
324        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        // Create new file
344        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        // Create .git directory to simulate a git repo
417        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        // Verify executable permission on Unix
436        #[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}