use anyhow::Result;
use std::path::Path;
use crate::git::{git_output, get_recent_commits};
pub const DEFAULT_MSG: &str = "update: automated commit";
const CONVENTIONAL_TYPES: &[&str] = &[
"feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert",
];
pub const WIP_CHECK_COUNT: usize = 10;
fn starts_with_conventional_type(msg: &str, prefix: &str) -> bool {
msg.starts_with(&format!("{prefix}:")) || msg.starts_with(&format!("{prefix}("))
}
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
}
fn is_test_file(file: &str) -> bool {
file.contains("test") || file.ends_with(".test.")
}
fn is_doc_file(file: &str) -> bool {
file.contains("doc")
|| Path::new(file)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
}
fn is_ci_file(file: &str) -> bool {
file.contains(".github") || file.contains("ci") || file.contains(".gitlab")
}
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())
}
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}")
}
fn generate_multiple_files_message(file_count: usize) -> String {
format!("update: {file_count} 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)
}
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!")
}
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())
}
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.")
}