use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::result::Result as StdResult;
use std::string::ToString;
use crate::Result;
#[derive(Debug, clap::Args)]
pub struct CheckConventionalCommit {
#[clap(required = true)]
pub commit_msg_file: PathBuf,
#[clap(long, default_value = default_allowed_types(), value_delimiter = ',')]
pub allowed_types: Vec<String>,
}
impl CheckConventionalCommit {
pub async fn run(&self) -> Result<()> {
check_conventional_commit(&self.commit_msg_file, &self.allowed_types)
}
}
fn check_conventional_commit(path: &PathBuf, allowed_types: &[String]) -> Result<()> {
let file = File::open(path)?;
let mut lines = BufReader::new(file)
.lines()
.map_while(StdResult::ok)
.filter(|line| !line.starts_with('#'));
let Some(title) = lines.next() else {
return Err(eyre::eyre!("Empty commit message"));
};
parse_commit_title(&title, allowed_types)?;
Ok(())
}
fn parse_commit_title(title: &str, allowed_types: &[String]) -> Result<bool> {
let mut parts = title.splitn(2, ":");
let Some(prefix) = parts.next() else {
return Err(eyre::eyre!("Missing commit type"));
};
if prefix.is_empty() {
return Err(eyre::eyre!("Missing commit type"));
}
let mut type_and_scope = prefix.trim_end_matches('!').splitn(2, '(');
let Some(commit_type) = type_and_scope.next() else {
return Err(eyre::eyre!("Missing commit type"));
};
if !check_commit_type(commit_type, allowed_types) {
return Err(eyre::eyre!("Invalid commit type: '{commit_type}'"));
}
if let Some(scope) = type_and_scope.next()
&& !scope.ends_with(')')
{
return Err(eyre::eyre!("Invalid scope, missing closing parentheses"));
}
let Some(description) = parts.next() else {
return Err(eyre::eyre!("Missing description"));
};
if description.strip_prefix(' ').unwrap_or_default().is_empty() {
return Err(eyre::eyre!("Missing description"));
}
Ok(true)
}
fn check_commit_type(commit_type: &str, allowed_types: &[String]) -> bool {
allowed_types.contains(&commit_type.to_string())
}
fn default_allowed_types() -> String {
[
"build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style",
"test",
]
.join(",")
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::NamedTempFile;
#[test]
fn test_empty_commit_message() {
let commit_msg_file = NamedTempFile::new().unwrap();
let path = commit_msg_file.path().to_path_buf();
let result = check_conventional_commit(&path, &[]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "Empty commit message");
}
#[test]
fn test_missing_commit_type() {
let commit_msg_file = NamedTempFile::new().unwrap();
let path = commit_msg_file.path().to_path_buf();
fs::write(&path, b": test description").unwrap();
let result = check_conventional_commit(&path, &[]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "Missing commit type");
}
#[test]
fn test_missing_description() {
let commit_msg_file = NamedTempFile::new().unwrap();
let path = commit_msg_file.path().to_path_buf();
fs::write(&path, b"test: ").unwrap();
let result = check_conventional_commit(&path, &["test".to_string()]);
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "Missing description");
}
#[test]
fn test_invalid_commit_type() {
let commit_msg_file = NamedTempFile::new().unwrap();
let path = commit_msg_file.path().to_path_buf();
fs::write(&path, b"testing: test description").unwrap();
let result = check_conventional_commit(&path, &["test".to_string()]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Invalid commit type: 'testing'"
);
}
#[test]
fn test_unenclosed_scope_parentheses() {
let commit_msg_file = NamedTempFile::new().unwrap();
let path = commit_msg_file.path().to_path_buf();
fs::write(&path, b"test(scope: test description").unwrap();
let result = check_conventional_commit(&path, &["test".to_string()]);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Invalid scope, missing closing parentheses"
);
}
#[test]
fn test_valid_commit_message() {
let commit_msg_file = NamedTempFile::new().unwrap();
let path = commit_msg_file.path().to_path_buf();
fs::write(&path, b"test(scope): test description").unwrap();
let result = check_conventional_commit(&path, &["test".to_string()]);
assert!(result.is_ok());
}
}