git-send 0.1.6

Commit and push changes with a single command
//! Commit message generation and validation

use anyhow::Result;
use std::path::Path;

use crate::git::{git_output, get_recent_commits};

/// Default commit message used when no message is provided.
pub const DEFAULT_MSG: &str = "update: automated commit";

/// Conventional commit types
const CONVENTIONAL_TYPES: &[&str] = &[
    "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert",
];

/// Number of recent commits to check for WIP
pub const WIP_CHECK_COUNT: usize = 10;

/// Checks if a commit message starts with a conventional type.
fn starts_with_conventional_type(msg: &str, prefix: &str) -> bool {
    msg.starts_with(&format!("{prefix}:")) || msg.starts_with(&format!("{prefix}("))
}

/// Detects if commit message follows conventional commit format.
pub fn is_conventional_commit(msg: &str) -> bool {
    let msg = msg.trim();
    for &prefix in CONVENTIONAL_TYPES {
        if starts_with_conventional_type(msg, prefix) {
            return true;
        }
    }
    false
}

/// Checks if file path indicates test files.
fn is_test_file(file: &str) -> bool {
    file.contains("test") || file.ends_with(".test.")
}

/// Checks if file path indicates documentation files.
fn is_doc_file(file: &str) -> bool {
    file.contains("doc")
        || Path::new(file)
            .extension()
            .is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
}

/// Checks if file path indicates CI configuration files.
fn is_ci_file(file: &str) -> bool {
    file.contains(".github") || file.contains("ci") || file.contains(".gitlab")
}

/// Suggests conventional commit format based on changed files.
pub fn suggest_conventional_type() -> Option<String> {
    let output = git_output(&["diff", "--cached", "--name-only"]).unwrap_or_default();
    if output.is_empty() {
        return None;
    }

    let files: Vec<&str> = output.lines().collect();
    let has_tests = files.iter().any(|f| is_test_file(f));
    if has_tests {
        return Some("test".to_string());
    }

    let has_docs = files.iter().any(|f| is_doc_file(f));
    if has_docs {
        return Some("docs".to_string());
    }

    let has_ci = files.iter().any(|f| is_ci_file(f));
    if has_ci {
        return Some("ci".to_string());
    }

    Some("feat".to_string())
}

/// Generates commit message for a single file.
fn generate_single_file_message(file: &str) -> String {
    let name = Path::new(file)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or(file);
    format!("update: {name}")
}

/// Generates commit message for multiple files.
fn generate_multiple_files_message(file_count: usize) -> String {
    format!("update: {file_count} files")
}

/// Auto-generates a commit message from changed files.
pub fn generate_commit_message() -> String {
    let output = git_output(&["diff", "--cached", "--name-only"]).unwrap_or_default();
    if output.is_empty() {
        return DEFAULT_MSG.to_string();
    }

    let files: Vec<&str> = output.lines().collect();
    let file_count = files.len();

    if file_count == 1 {
        return generate_single_file_message(files[0]);
    }

    generate_multiple_files_message(file_count)
}

/// Checks if a commit message indicates WIP.
pub fn is_wip_commit(commit: &str) -> bool {
    let lower = commit.to_lowercase();
    lower.contains("wip") || lower.contains("work in progress") || lower.starts_with("fixup!")
}

/// Detects WIP commits in recent history.
pub fn detect_wip_commits() -> Result<Vec<String>> {
    let commits = get_recent_commits(WIP_CHECK_COUNT)?;
    Ok(commits.into_iter().filter(|c| is_wip_commit(c)).collect())
}

/// Validates commit message is not empty
pub fn validate_commit_message(msg: &str) -> Result<()> {
    if !msg.trim().is_empty() {
        return Ok(());
    }
    anyhow::bail!("Commit message cannot be empty. Provide one with -m or use --interactive mode.")
}