cc-hook 0.1.0

A cross-platform CLI that installs a git commit-msg hook to enforce Conventional Commits
use std::fs;
use std::path::{Path, PathBuf};

/// Returns the path where the `commit-msg` hook should be installed.
pub fn hook_path(git_root: &Path) -> PathBuf {
    git_root.join(".git").join("hooks").join("commit-msg")
}

/// Generates the content of the `commit-msg` hook script.
///
/// The hook is a shell script that invokes `cc-hook validate` with the commit message file.
/// This makes the hook cross-platform — the actual validation runs as a compiled binary,
/// not a Bash script.
pub fn generate_hook_script() -> String {
    r#"#!/bin/sh
# Hook para validar Conventional Commits
# Gerado automaticamente pelo cc-hook
cc-hook validate "$1"
"#
    .to_string()
}

/// Installs the `commit-msg` hook into the given git repository root.
///
/// If a hook already exists, it is backed up with a timestamp suffix before being replaced.
/// Returns the path to the backup file if one was created.
pub fn install_hook(git_root: &Path) -> Result<Option<PathBuf>, HookInstallError> {
    let hooks_dir = git_root.join(".git").join("hooks");
    let hook_file = hooks_dir.join("commit-msg");

    // Create hooks directory if it doesn't exist
    fs::create_dir_all(&hooks_dir)
        .map_err(|e| HookInstallError::CreateDirFailed(e.to_string()))?;

    // Backup existing hook if present
    let backup_path = if hook_file.exists() {
        let timestamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();
        let backup = hooks_dir.join(format!("commit-msg.backup.{timestamp}"));
        fs::copy(&hook_file, &backup)
            .map_err(|e| HookInstallError::WriteFailed(e.to_string()))?;
        Some(backup)
    } else {
        None
    };

    // Write the new hook
    fs::write(&hook_file, generate_hook_script())
        .map_err(|e| HookInstallError::WriteFailed(e.to_string()))?;

    // Set executable permission on Unix
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let perms = std::fs::Permissions::from_mode(0o755);
        fs::set_permissions(&hook_file, perms)
            .map_err(|e| HookInstallError::WriteFailed(e.to_string()))?;
    }

    Ok(backup_path)
}

/// Errors that can occur during hook installation.
#[derive(Debug, PartialEq)]
pub enum HookInstallError {
    /// The hooks directory could not be created.
    CreateDirFailed(String),
    /// The hook file could not be written.
    WriteFailed(String),
}

impl std::fmt::Display for HookInstallError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HookInstallError::CreateDirFailed(e) => write!(f, "failed to create hooks dir: {e}"),
            HookInstallError::WriteFailed(e) => write!(f, "failed to write hook file: {e}"),
        }
    }
}

impl std::error::Error for HookInstallError {}

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

    fn create_temp_dir() -> tempfile::TempDir {
        tempfile::tempdir().expect("failed to create temp dir")
    }

    /// Sets up a fake git repo structure and returns the repo root path.
    fn setup_fake_repo(tmp: &tempfile::TempDir) -> PathBuf {
        let root = tmp.path().to_path_buf();
        fs::create_dir_all(root.join(".git").join("hooks")).unwrap();
        root
    }

    // -------------------------------------------------------
    // hook_path
    // -------------------------------------------------------

    #[test]
    fn hook_path_points_to_commit_msg() {
        let path = hook_path(Path::new("/some/repo"));
        assert_eq!(path, PathBuf::from("/some/repo/.git/hooks/commit-msg"));
    }

    // -------------------------------------------------------
    // generate_hook_script
    // -------------------------------------------------------

    #[test]
    fn hook_script_contains_shebang() {
        let script = generate_hook_script();
        assert!(script.starts_with("#!/"));
    }

    #[test]
    fn hook_script_invokes_cc_hook_validate() {
        let script = generate_hook_script();
        assert!(
            script.contains("cc-hook validate") || script.contains("cc-hook\" validate"),
            "hook script should invoke `cc-hook validate`"
        );
    }

    #[test]
    fn hook_script_passes_commit_msg_file_arg() {
        let script = generate_hook_script();
        assert!(
            script.contains("$1"),
            "hook script should pass the commit message file path ($1)"
        );
    }

    // -------------------------------------------------------
    // install_hook — fresh install (no existing hook)
    // -------------------------------------------------------

    #[test]
    fn install_creates_hook_file() {
        let tmp = create_temp_dir();
        let root = setup_fake_repo(&tmp);

        let result = install_hook(&root);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), None); // no backup needed

        let hook = hook_path(&root);
        assert!(hook.exists(), "hook file should exist after install");
    }

    #[test]
    fn installed_hook_has_correct_content() {
        let tmp = create_temp_dir();
        let root = setup_fake_repo(&tmp);

        install_hook(&root).unwrap();

        let content = fs::read_to_string(hook_path(&root)).unwrap();
        let expected = generate_hook_script();
        assert_eq!(content, expected);
    }

    #[test]
    fn install_creates_hooks_dir_if_missing() {
        let tmp = create_temp_dir();
        let root = tmp.path().to_path_buf();
        fs::create_dir(root.join(".git")).unwrap();
        // Hooks dir intentionally not created

        let result = install_hook(&root);
        assert!(result.is_ok());
        assert!(root.join(".git").join("hooks").exists());
    }

    // -------------------------------------------------------
    // install_hook — overwrite existing hook (backup)
    // -------------------------------------------------------

    #[test]
    fn install_backs_up_existing_hook() {
        let tmp = create_temp_dir();
        let root = setup_fake_repo(&tmp);

        let hook = hook_path(&root);
        fs::write(&hook, "#!/bin/bash\necho old hook").unwrap();

        let result = install_hook(&root);
        assert!(result.is_ok());
        let backup = result.unwrap();
        assert!(backup.is_some(), "should return backup path");
        let backup_path = backup.unwrap();
        assert!(backup_path.exists(), "backup file should exist");

        let backup_content = fs::read_to_string(&backup_path).unwrap();
        assert_eq!(backup_content, "#!/bin/bash\necho old hook");
    }

    #[test]
    fn install_overwrites_existing_hook_with_new_content() {
        let tmp = create_temp_dir();
        let root = setup_fake_repo(&tmp);

        let hook = hook_path(&root);
        fs::write(&hook, "#!/bin/bash\necho old hook").unwrap();

        install_hook(&root).unwrap();

        let content = fs::read_to_string(&hook).unwrap();
        assert_ne!(content, "#!/bin/bash\necho old hook");
        assert_eq!(content, generate_hook_script());
    }

    // -------------------------------------------------------
    // install_hook — Unix permissions
    // -------------------------------------------------------

    #[cfg(unix)]
    #[test]
    fn installed_hook_is_executable() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = create_temp_dir();
        let root = setup_fake_repo(&tmp);

        install_hook(&root).unwrap();

        let hook = hook_path(&root);
        let perms = fs::metadata(&hook).unwrap().permissions();
        assert!(
            perms.mode() & 0o111 != 0,
            "hook file should be executable"
        );
    }
}