use clap::Parser;
use log::{error, info};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use textwrap::fill;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Cli {
#[arg(short = 'm', long = "message")]
message_string: Option<String>,
#[arg(short = 'f', long = "file")]
message_file: Option<PathBuf>,
}
#[derive(Debug, Serialize, Deserialize)]
struct Config {
standard_verbs: HashMap<String, String>,
indicators: HashMap<String, Vec<String>>,
verb_mapping: HashMap<String, String>,
message_template: MessageTemplate,
}
#[derive(Debug, Serialize, Deserialize)]
struct MessageTemplate {
references_section: String,
testing_section: String,
dependencies_section: String,
changes_section: String,
breaking_section: String,
}
#[derive(Debug)]
struct GitChanges {
file_changes: HashMap<String, Vec<String>>,
breaking_changes: Vec<String>,
}
impl GitChanges {
fn has_changes(&self) -> bool {
!self.file_changes.is_empty()
}
}
const COMMIT_INSTRUCTIONS: &str = r#"
To utilise this commit message:
1. Review the generated commit.md file
2. Complete any sections marked with [Required]
3. Update any sections marked with [Optional]
4. Use it directly with git commit:
git commit -F commit.md
Or copy specific sections into your commit:
cat commit.md | git commit -F -
"#;
impl Default for Config {
fn default() -> Self {
let mut standard_verbs = HashMap::new();
standard_verbs.insert(
"Add".to_string(),
"Create a capability, e.g. feature, test, dependency".to_string(),
);
standard_verbs.insert(
"Cut".to_string(),
"Remove a capability, e.g. feature, test, dependency".to_string(),
);
standard_verbs.insert(
"Fix".to_string(),
"Fix an issue, e.g. bug, typo, error, misstatement".to_string(),
);
let mut indicators = HashMap::new();
indicators.insert(
"fix".to_string(),
vec!["fix".to_string(), "bug".to_string(), "issue".to_string()],
);
let mut verb_mapping = HashMap::new();
verb_mapping.insert("fix".to_string(), "Fix".to_string());
Config {
standard_verbs,
indicators,
verb_mapping,
message_template: MessageTemplate {
references_section: "# References [Required]\n# Link to related tickets, docs, or discussions\nCloses #\nRelates to #\nSee also: ".to_string(),
testing_section: "# Testing Instructions [Optional]\n# Describe how to test these changes\n1. Steps to test\n2. Expected outcomes\n3. Edge cases to verify".to_string(),
dependencies_section: "# Dependencies [Optional]\n# List any prerequisite changes or dependencies\n- [ ] Database migrations\n- [ ] Configuration updates\n- [ ] External service changes".to_string(),
changes_section: "# Changes Overview [Required]\n# Briefly describe the purpose of these changes".to_string(),
breaking_section: "# Breaking Changes [Required if any]\n# List any backward-incompatible changes and migration steps".to_string(),
},
}
}
}
struct CommitMessageVerifier {
config: Config,
}
impl CommitMessageVerifier {
fn new(config: Config) -> Self {
Self { config }
}
fn verify_message(&self, message: &str) -> (bool, Vec<String>) {
let mut errors = Vec::new();
let lines: Vec<&str> = message.lines().collect();
if lines.is_empty() {
return (false, vec!["Empty commit message".to_string()]);
}
let subject = lines[0];
if subject.len() > 50 {
errors.push("Subject line exceeds 50 characters".to_string());
}
let first_word = subject.split_whitespace().next().unwrap_or("");
if !self.config.standard_verbs.contains_key(first_word) {
errors.push(format!(
"Subject must start with standard verb: {}",
self.config
.standard_verbs
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
));
}
if subject.ends_with('.') {
errors.push("Subject line ends with a full stop".to_string());
}
if !subject.chars().next().map_or(false, |c| c.is_uppercase()) {
errors.push("Subject line not capitalised".to_string());
}
if lines.get(1).map_or(false, |line| !line.is_empty()) {
errors.push("No blank line between subject and body".to_string());
}
for (i, line) in lines.iter().skip(2).enumerate() {
if !line.is_empty() && line.len() > 72 {
errors.push(format!("Line {} exceeds 72 characters", i + 3));
}
}
(!errors.is_empty(), errors)
}
fn verify_file(&self, file_path: &PathBuf) -> (bool, Vec<String>) {
match fs::read_to_string(file_path) {
Ok(message) => self.verify_message(&message),
Err(e) => (false, vec![format!("Failed to read file: {}", e)]),
}
}
}
struct GitDiffAnalyzer {
config: Config,
}
impl GitDiffAnalyzer {
fn new(config: Config) -> Self {
Self { config }
}
fn get_git_diff(&self) -> Option<String> {
let output = Command::new("git")
.args(["diff", "--cached"])
.output()
.ok()?;
String::from_utf8(output.stdout).ok()
}
fn analyse_diff(&self, diff_output: &str) -> GitChanges {
let mut file_changes = HashMap::new();
let mut breaking_changes = Vec::new();
let mut current_file = None;
for line in diff_output.lines() {
if line.starts_with("diff --git") {
current_file = line
.split_whitespace()
.last()
.map(|s| s.trim_start_matches("b/").to_string());
} else if line.starts_with('+') && !line.starts_with("+++") {
if let Some(file) = ¤t_file {
let change = line[1..].trim().to_string();
if !change.is_empty() {
file_changes
.entry(file.clone())
.or_insert_with(Vec::new)
.push(change.clone());
if self.is_breaking_change(&change) {
breaking_changes
.push(format!("* Breaking change in {}:\n {}", file, change));
}
}
}
}
}
GitChanges {
file_changes,
breaking_changes,
}
}
fn is_breaking_change(&self, change: &str) -> bool {
let breaking_indicators = [
"remove",
"delete",
"deprecate",
"break",
"change",
"rename",
"refactor",
"drop",
"migrate",
];
let change_lower = change.to_lowercase();
breaking_indicators
.iter()
.any(|&word| change_lower.contains(word))
}
fn determine_commit_verb(&self, file_changes: &HashMap<String, Vec<String>>) -> String {
let all_changes: String = file_changes
.values()
.flatten()
.map(|s| s.to_lowercase())
.collect::<Vec<_>>()
.join(" ");
for (verb, words) in &self.config.indicators {
if words.iter().any(|word| all_changes.contains(word)) {
return self
.config
.verb_mapping
.get(verb)
.unwrap_or(&"Add".to_string())
.clone();
}
}
"Add".to_string()
}
}
struct CommitMessageGenerator<'a> {
analyzer: &'a GitDiffAnalyzer,
}
impl<'a> CommitMessageGenerator<'a> {
fn new(analyzer: &'a GitDiffAnalyzer) -> Self {
Self { analyzer }
}
fn generate_subject_line(&self, changes: &GitChanges) -> String {
let verb = self.analyzer.determine_commit_verb(&changes.file_changes);
let significant_changes: Vec<_> = changes
.file_changes
.values()
.filter_map(|changes| changes.first())
.collect();
let description = significant_changes
.first()
.map(|s| s.trim().to_lowercase())
.unwrap_or_else(|| "codebase".to_string());
let subject = format!("{} {}", verb, description);
if subject.len() > 50 {
format!("{}...", &subject[..47])
} else {
subject
}
}
fn wrap_body_text(&self, text: &str) -> String {
fill(text, 72)
}
fn generate_message(&self, changes: &GitChanges) -> String {
let subject = self.generate_subject_line(changes);
let templates = &self.analyzer.config.message_template;
let mut sections = vec![templates.references_section.clone()];
let mut changes_section = format!("{}\n\n", templates.changes_section);
for (file, changes_list) in &changes.file_changes {
changes_section.push_str(&format!("* In {}:\n", file));
for change in changes_list.iter().take(3) {
changes_section.push_str(&format!(" - {}\n", self.wrap_body_text(change)));
}
}
sections.push(changes_section);
if !changes.breaking_changes.is_empty() {
let mut breaking = format!("{}\n\n", templates.breaking_section);
for change in &changes.breaking_changes {
breaking.push_str(&format!("{}\n", self.wrap_body_text(change)));
}
sections.push(breaking);
}
sections.push(templates.testing_section.clone());
sections.push(templates.dependencies_section.clone());
format!("{}\n\n{}", subject, sections.join("\n\n"))
}
}
fn main() {
env_logger::init();
let cli = Cli::parse();
let config = Config::default();
if let Some(message) = cli.message_string {
let verifier = CommitMessageVerifier::new(config);
let (valid, errors) = verifier.verify_message(&message);
if !valid {
error!("Commit message validation failed:");
for error in errors {
error!("- {}", error);
}
std::process::exit(1);
}
info!("Commit message is valid");
return;
}
if let Some(file_path) = cli.message_file {
let verifier = CommitMessageVerifier::new(config);
let (valid, errors) = verifier.verify_file(&file_path);
if !valid {
error!("Commit message validation failed:");
for error in errors {
error!("- {}", error);
}
std::process::exit(1);
}
info!("Commit message is valid");
return;
}
let analyzer = GitDiffAnalyzer::new(config);
let generator = CommitMessageGenerator::new(&analyzer);
let diff_output = match analyzer.get_git_diff() {
Some(diff) => diff,
None => {
error!("No staged changes found. Please stage changes with 'git add' first.");
std::process::exit(1);
}
};
let changes = analyzer.analyse_diff(&diff_output);
if !changes.has_changes() {
error!("No changes detected in diff.");
std::process::exit(1);
}
let commit_message = generator.generate_message(&changes);
match fs::write("commit.md", &commit_message) {
Ok(_) => {
info!("\n=== Generated Commit Message ===\n");
println!("{}", commit_message);
info!("\n===========================");
info!("{}", COMMIT_INSTRUCTIONS);
}
Err(e) => {
error!("Failed to write commit message: {}", e);
std::process::exit(1);
}
}
}