cc-hook 0.1.0

A cross-platform CLI that installs a git commit-msg hook to enforce Conventional Commits
use std::fs;

use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::TempDir;

fn cc_hook() -> Command {
    Command::cargo_bin("cc-hook").expect("binary not found")
}

/// Creates a temporary directory with a `.git` dir inside (fake repo).
fn fake_repo() -> TempDir {
    let tmp = tempfile::tempdir().expect("failed to create temp dir");
    fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap();
    tmp
}

/// Creates a temporary file with the given content and returns the temp dir
/// (to keep it alive) and the file path.
fn commit_msg_file(content: &str) -> (TempDir, std::path::PathBuf) {
    let tmp = tempfile::tempdir().expect("failed to create temp dir");
    let path = tmp.path().join("COMMIT_EDITMSG");
    fs::write(&path, content).unwrap();
    (tmp, path)
}

// -----------------------------------------------------------
// cc-hook (sem argumentos) → mostra usage
// -----------------------------------------------------------

#[test]
fn no_args_shows_usage() {
    cc_hook()
        .assert()
        .success()
        .stdout(predicate::str::contains("cc-hook - Conventional Commits Hook"))
        .stdout(predicate::str::contains("cc-hook install"))
        .stdout(predicate::str::contains("cc-hook validate"));
}

// -----------------------------------------------------------
// cc-hook validate — mensagem válida → exit code 0
// -----------------------------------------------------------

#[test]
fn validate_valid_message_exits_0() {
    let (_tmp, path) = commit_msg_file("feat: add new feature");
    cc_hook()
        .args(["validate", path.to_str().unwrap()])
        .assert()
        .success();
}

#[test]
fn validate_valid_message_with_scope_exits_0() {
    let (_tmp, path) = commit_msg_file("fix(api): resolve timeout");
    cc_hook()
        .args(["validate", path.to_str().unwrap()])
        .assert()
        .success();
}

#[test]
fn validate_merge_commit_exits_0() {
    let (_tmp, path) = commit_msg_file("Merge branch 'develop' into main");
    cc_hook()
        .args(["validate", path.to_str().unwrap()])
        .assert()
        .success();
}

// -----------------------------------------------------------
// cc-hook validate — mensagem inválida → exit code 1
// -----------------------------------------------------------

#[test]
fn validate_invalid_message_exits_1() {
    let (_tmp, path) = commit_msg_file("bad commit message");
    cc_hook()
        .args(["validate", path.to_str().unwrap()])
        .assert()
        .failure()
        .code(1)
        .stdout(predicate::str::contains("Conventional Commits"))
        .stdout(predicate::str::contains("bad commit message"));
}

#[test]
fn validate_invalid_message_shows_valid_types() {
    let (_tmp, path) = commit_msg_file("oops");
    cc_hook()
        .args(["validate", path.to_str().unwrap()])
        .assert()
        .failure()
        .stdout(predicate::str::contains("feat"))
        .stdout(predicate::str::contains("fix"))
        .stdout(predicate::str::contains("docs"))
        .stdout(predicate::str::contains("refactor"));
}

#[test]
fn validate_invalid_message_shows_examples() {
    let (_tmp, path) = commit_msg_file("nope");
    cc_hook()
        .args(["validate", path.to_str().unwrap()])
        .assert()
        .failure()
        .stdout(predicate::str::contains("feat(auth): adicionar"))
        .stdout(predicate::str::contains("fix(api): corrigir"));
}

// -----------------------------------------------------------
// cc-hook validate — arquivo vazio → exit code 1
// -----------------------------------------------------------

#[test]
fn validate_empty_file_exits_1() {
    let (_tmp, path) = commit_msg_file("");
    cc_hook()
        .args(["validate", path.to_str().unwrap()])
        .assert()
        .failure()
        .code(1)
        .stdout(predicate::str::contains("ERRO:"))
        .stdout(predicate::str::contains("vazia"));
}

// -----------------------------------------------------------
// cc-hook validate — arquivo inexistente → erro
// -----------------------------------------------------------

#[test]
fn validate_nonexistent_file_exits_with_error() {
    cc_hook()
        .args(["validate", "/tmp/does_not_exist_cch_test.txt"])
        .assert()
        .failure();
}

// -----------------------------------------------------------
// cc-hook install — em repo temporário → hook criado
// -----------------------------------------------------------

#[test]
fn install_creates_hook_in_repo() {
    let repo = fake_repo();
    cc_hook()
        .arg("install")
        .current_dir(repo.path())
        .assert()
        .success()
        .stdout(predicate::str::contains("SUCESSO:"))
        .stdout(predicate::str::contains("commit-msg configurado"));

    let hook = repo.path().join(".git").join("hooks").join("commit-msg");
    assert!(hook.exists(), "hook file should be created");

    let content = fs::read_to_string(&hook).unwrap();
    assert!(content.contains("cc-hook validate"));
}

// -----------------------------------------------------------
// cc-hook install — com hook existente (sem stdin → cancela)
// -----------------------------------------------------------

#[test]
fn install_with_existing_hook_and_no_input_cancels() {
    let repo = fake_repo();
    let hook = repo.path().join(".git").join("hooks").join("commit-msg");
    fs::write(&hook, "#!/bin/sh\necho old").unwrap();

    // Pipe empty stdin → read_line gets "" → defaults to N
    cc_hook()
        .arg("install")
        .current_dir(repo.path())
        .write_stdin("")
        .assert()
        .success()
        .stdout(predicate::str::contains("cancelada"));

    // Original hook should remain unchanged
    let content = fs::read_to_string(&hook).unwrap();
    assert_eq!(content, "#!/bin/sh\necho old");
}

// -----------------------------------------------------------
// cc-hook install — com hook existente + resposta "y" → backup criado
// -----------------------------------------------------------

#[test]
fn install_with_existing_hook_and_yes_creates_backup() {
    let repo = fake_repo();
    let hook = repo.path().join(".git").join("hooks").join("commit-msg");
    fs::write(&hook, "#!/bin/sh\necho old").unwrap();

    cc_hook()
        .arg("install")
        .current_dir(repo.path())
        .write_stdin("y\n")
        .assert()
        .success()
        .stdout(predicate::str::contains("Backup criado"))
        .stdout(predicate::str::contains("SUCESSO:"));

    // New hook should have cc-hook content
    let content = fs::read_to_string(&hook).unwrap();
    assert!(content.contains("cc-hook validate"));

    // Backup file should exist
    let hooks_dir = repo.path().join(".git").join("hooks");
    let backups: Vec<_> = fs::read_dir(&hooks_dir)
        .unwrap()
        .filter_map(|e| e.ok())
        .filter(|e| {
            e.file_name()
                .to_str()
                .is_some_and(|n| n.starts_with("commit-msg.backup"))
        })
        .collect();
    assert_eq!(backups.len(), 1, "exactly one backup should exist");

    let backup_content = fs::read_to_string(backups[0].path()).unwrap();
    assert_eq!(backup_content, "#!/bin/sh\necho old");
}

// -----------------------------------------------------------
// cc-hook install — sem .git → erro
// -----------------------------------------------------------

#[test]
fn install_without_git_dir_exits_1() {
    let tmp = tempfile::tempdir().unwrap();
    // No .git dir created

    cc_hook()
        .arg("install")
        .current_dir(tmp.path())
        .assert()
        .failure()
        .code(1)
        .stdout(predicate::str::contains("ERRO:"))
        .stdout(predicate::str::contains(".git"));
}