git-snip 0.3.0

Snip local Git branches that do not exist on the remote.
Documentation
use std::fmt::{Display, Formatter, Result};
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::str::FromStr;

use anyhow::{bail, Context};

/// Hook types used in git.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GitHookType {
    PostMerge,
    PostRewrite,
}

/// Converts HookType to a string. This is used to create the hook file name.
/// For example, HookType::PreCommit will be converted to "pre-commit".
impl Display for GitHookType {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        match self {
            GitHookType::PostMerge => write!(f, "post-merge"),
            GitHookType::PostRewrite => write!(f, "post-rewrite"),
        }
    }
}

impl FromStr for GitHookType {
    type Err = ();

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s {
            "post-merge" => Ok(GitHookType::PostMerge),
            "post-rewrite" => Ok(GitHookType::PostRewrite),
            _ => Err(()),
        }
    }
}

/// Representation of a git hook script.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GitHook(String);

impl GitHook {
    /// Create a new instance of GitHook.
    #[allow(dead_code)]
    pub fn new<S: Into<String>>(hook: S) -> Self {
        Self(hook.into())
    }

    /// Convert the hook script to a byte slice.
    pub fn as_bytes(&self) -> &[u8] {
        self.0.as_bytes()
    }

    /// Return the hook as a String, consuming self.
    #[allow(dead_code)]
    pub fn into_string(self) -> String {
        self.0
    }
}

impl Default for GitHook {
    /// Returns the standard git-snip hook script.
    fn default() -> Self {
        Self(
            r#"#!/bin/sh
HEAD_BRANCH=$(git rev-parse --abbrev-ref HEAD)
case "$HEAD_BRANCH" in
    'main'|'master'|'develop') ;;
        *) exit ;;
esac

git snip run --yes

"#
            .to_string(),
        )
    }
}

impl AsRef<str> for GitHook {
    fn as_ref(&self) -> &str {
        &self.0
    }
}

impl std::ops::Deref for GitHook {
    type Target = str;
    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

impl Display for GitHook {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        self.0.fmt(f)
    }
}

/// Install a hook script in the given git directory.
pub fn install(
    git_dir: &Path,
    hook: &GitHook,
    hook_type: GitHookType,
    force: bool,
) -> anyhow::Result<()> {
    let hook_path = git_dir.join("hooks").join(hook_type.to_string());

    if !force && hook_path.exists() {
        bail!("Hook already exists at {}", hook_path.to_string_lossy());
    }

    let mut file = File::create(&hook_path).context("Failed to create hook file")?;
    file.write_all(hook.as_bytes())
        .context("Failed to write hook script")?;

    #[cfg(unix)]
    {
        use std::fs::Permissions;
        use std::os::unix::fs::PermissionsExt;
        file.set_permissions(Permissions::from_mode(0o755))
            .context("Failed to set permissions on hook file")?;
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hook_type_display() {
        // GIVEN hook types
        // WHEN converting to string
        // THEN the string representation is correct
        assert_eq!(GitHookType::PostMerge.to_string(), "post-merge");
        assert_eq!(GitHookType::PostRewrite.to_string(), "post-rewrite");
    }

    #[test]
    fn test_hook_type_from_str() {
        // GIVEN string representations of hook types
        // WHEN parsing them
        // THEN the correct hook types are returned
        assert_eq!("post-merge".parse(), Ok(GitHookType::PostMerge));
        assert_eq!("post-rewrite".parse(), Ok(GitHookType::PostRewrite));
        assert!("other-hook".parse::<GitHookType>().is_err());
    }

    #[test]
    fn test_hook_as_bytes() {
        // GIVEN a GitHook instance
        // WHEN converting to bytes
        let hook = GitHook::new("echo 'Hello, world!'");

        // THEN the bytes match the expected string
        assert_eq!(hook.as_bytes(), b"echo 'Hello, world!'");
    }

    #[test]
    fn test_into_string() {
        // GIVEN a GitHook instance
        // WHEN converting to String
        let hook = GitHook::new("echo 'Hello, world!'");
        // THEN the string matches the expected value
        assert_eq!(hook.into_string(), "echo 'Hello, world!'");
    }

    #[test]
    fn test_as_ref_and_deref() {
        // GIVEN a GitHook instance
        let hook = GitHook::new("echo 'Hi'");
        // WHEN using as_ref and deref
        // THEN the results are as expected
        assert_eq!(hook.as_ref(), "echo 'Hi'");
        assert_eq!(&*hook, "echo 'Hi'");
    }

    #[test]
    fn test_display() {
        // GIVEN a GitHook instance
        // WHEN formatting it as a string
        let hook = GitHook::new("echo 'Hi'");

        // THEN the display output is correct
        assert_eq!(hook.to_string(), "echo 'Hi'");
    }

    #[test]
    fn test_default() {
        // GIVEN the default GitHook
        let hook = GitHook::default();

        // THEN it contains the expected script
        assert!(hook.as_ref().contains("git snip run --yes"));
    }

    #[test]
    fn test_install() {
        // GIVEN a directory with a hooks subdirectory
        let tempdir = tempfile::tempdir().unwrap();
        let hooks_dir = tempdir.path().join("hooks");
        std::fs::create_dir(&hooks_dir).unwrap();
        let mock_script = String::from("echo 'Hello, world!'");

        // WHEN installing the hook
        let hook = GitHook::new(mock_script);
        let result = install(tempdir.path(), &hook, GitHookType::PostMerge, false);

        // THEN the installation should be successful
        assert!(result.is_ok());
        let hook_path = hooks_dir.join(GitHookType::PostMerge.to_string());
        let hook_script = std::fs::read_to_string(hook_path).unwrap();
        assert_eq!(hook_script, hook.into_string());
    }

    #[test]
    fn test_install_already_exists() {
        // GIVEN a directory with an existing hook file
        let tempdir = tempfile::tempdir().unwrap();
        let hooks_dir = tempdir.path().join("hooks");
        std::fs::create_dir(&hooks_dir).unwrap();
        let hook_path = hooks_dir.join(GitHookType::PostMerge.to_string());
        std::fs::File::create(&hook_path).unwrap();

        // WHEN installing the hook without force
        let hook = GitHook::default();
        let result = install(tempdir.path(), &hook, GitHookType::PostMerge, false);

        // THEN the installation should fail
        assert!(result.is_err());
    }

    #[test]
    fn test_install_force_overwrites_existing() {
        // GIVEN a directory with an existing hook file
        let tempdir = tempfile::tempdir().unwrap();
        let hooks_dir = tempdir.path().join("hooks");
        std::fs::create_dir(&hooks_dir).unwrap();
        let hook_path = hooks_dir.join(GitHookType::PostMerge.to_string());
        std::fs::write(&hook_path, "old content").unwrap();

        // WHEN installing the hook with force
        let hook = GitHook::new("new content");
        let result = install(tempdir.path(), &hook, GitHookType::PostMerge, true);

        // THEN the installation should succeed and overwrite the existing hook
        assert!(result.is_ok());
        let hook_script = std::fs::read_to_string(hook_path).unwrap();
        assert_eq!(hook_script, "new content");
    }
}