use async_trait::async_trait;
use serde::Deserialize;
pub mod anthropic;
pub mod gemini;
pub mod openai;
#[derive(Debug, Deserialize)]
pub struct GeneratedCommit {
pub title: String,
pub description: String,
}
impl GeneratedCommit {
pub fn validate(&self) -> Result<(), String> {
if self.title.len() > 72 {
return Err("Commit title is too long (max 72 characters)".to_string());
}
let conventional_types = [
"feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "ci", "build",
"revert",
];
if let Some(colon_pos) = self.title.find(':') {
let prefix = &self.title[..colon_pos];
let type_part = if let Some(paren_pos) = prefix.find('(') {
&prefix[..paren_pos]
} else {
prefix
};
let clean_type = type_part.trim_end_matches('!');
if !conventional_types.contains(&clean_type) {
return Err(format!(
"Invalid commit type '{}'. Valid types: {}",
clean_type,
conventional_types.join(", ")
));
}
let description = &self.title[colon_pos + 1..].trim();
if description.is_empty() {
return Err("Commit description after colon cannot be empty".to_string());
}
if description.chars().next().unwrap_or(' ').is_uppercase() {
return Err("Commit description should start with lowercase letter".to_string());
}
} else {
return Err("Commit title must follow format: type(scope): description".to_string());
}
Ok(())
}
#[allow(dead_code)]
pub fn summary(&self) -> String {
format!(
"Type: {}, Files: {}",
self.get_type(),
self.description.lines().count()
)
}
#[allow(dead_code)]
pub fn get_type(&self) -> String {
if let Some(colon_pos) = self.title.find(':') {
let prefix = &self.title[..colon_pos];
if let Some(paren_pos) = prefix.find('(') {
prefix[..paren_pos].trim_end_matches('!').to_string()
} else {
prefix.trim_end_matches('!').to_string()
}
} else {
"unknown".to_string()
}
}
}
impl std::fmt::Display for GeneratedCommit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}\n\n{}", self.title, self.description)
}
}
#[async_trait]
pub trait AIProvider {
async fn generate_commit_message(&self, diff: &str) -> Result<GeneratedCommit, String>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generated_commit_to_string() {
let commit = GeneratedCommit {
title: "feat: add new feature".to_string(),
description: "This adds a new feature to the project.".to_string(),
};
let s = commit.to_string();
assert!(s.contains("feat: add new feature"));
assert!(s.contains("This adds a new feature to the project."));
}
}