- Implemented a code quality scanner that checks for common clean-code violations in Rust source files, including missing documentation, usage of `.unwrap()` in production code, and commented-out code. - Introduced a commit message validator that follows the Conventional Commits specification, ensuring proper formatting and providing suggestions for invalid messages. - Created a unified BestPracticeEngine to encapsulate the functionalities of skills, architecture audits, code quality checks, and commit message validation. - Added tests for both the code quality scanner and commit message validator to ensure reliability and correctness.
257 lines
7.7 KiB
Rust
257 lines
7.7 KiB
Rust
//! Commit message validation following Conventional Commits (Bahasa Indonesia).
|
|
//!
|
|
//! # Flow
|
|
//!
|
|
//! `validate_commit_message(msg)` → parse subject → check type, scope,
|
|
//! description format → return list of errors (empty = valid).
|
|
//!
|
|
//! # Format
|
|
//!
|
|
//! ```text
|
|
//! feat(scope): description
|
|
//! fix(scope): description
|
|
//! chore: description
|
|
//! docs: description
|
|
//! refactor: description
|
|
//! test: description
|
|
//! style: description
|
|
//! perf: description
|
|
//! ci: description
|
|
//! ```
|
|
|
|
use regex::Regex;
|
|
|
|
/// Valid commit types.
|
|
const VALID_TYPES: &[&str] = &[
|
|
"feat", "fix", "chore", "docs", "refactor", "test", "style", "perf", "ci", "build", "revert",
|
|
];
|
|
|
|
/// Validate a commit message against the Conventional Commits spec.
|
|
///
|
|
/// Returns `Ok(())` if valid, or `Err(errors)` with human-readable messages.
|
|
pub fn validate_commit_message(msg: &str) -> Result<(), Vec<String>> {
|
|
let mut errors = Vec::new();
|
|
let msg = msg.trim();
|
|
|
|
if msg.is_empty() {
|
|
errors.push("Commit message must not be empty.".to_string());
|
|
return Err(errors);
|
|
}
|
|
|
|
// Split subject and body.
|
|
let subject = msg.lines().next().unwrap_or(msg);
|
|
|
|
// Check subject length.
|
|
if subject.len() > 72 {
|
|
errors.push(format!(
|
|
"Subject line is {} characters (max 72).",
|
|
subject.len()
|
|
));
|
|
}
|
|
|
|
// Parse: `type(scope): description` or `type!: description` or `type: description`
|
|
let re = Regex::new(
|
|
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$",
|
|
)
|
|
.expect("valid regex for commit parsing");
|
|
|
|
match re.captures(subject) {
|
|
None => {
|
|
errors.push(format!(
|
|
"Subject does not match Conventional Commits format.\n\
|
|
Expected: `type(scope): description`\n\
|
|
Got: {subject}\n\
|
|
Valid types: {}",
|
|
VALID_TYPES.join(", ")
|
|
));
|
|
}
|
|
Some(caps) => {
|
|
let type_ = caps.name("type").map(|m| m.as_str()).unwrap_or("");
|
|
let scope = caps.name("scope").map(|m| m.as_str());
|
|
let desc = caps.name("desc").map(|m| m.as_str()).unwrap_or("");
|
|
|
|
// Validate type.
|
|
if !VALID_TYPES.contains(&type_) {
|
|
errors.push(format!(
|
|
"Invalid commit type '{type_}'. Valid types: {}",
|
|
VALID_TYPES.join(", ")
|
|
));
|
|
}
|
|
|
|
// Scope is lowercase, no spaces.
|
|
if let Some(s) = scope {
|
|
if s.contains(' ') {
|
|
errors.push(format!("Scope must not contain spaces: '{s}'"));
|
|
}
|
|
if s.chars().any(|c| c.is_uppercase()) {
|
|
errors.push(format!("Scope must be lowercase: '{s}'"));
|
|
}
|
|
}
|
|
|
|
// Description rules.
|
|
if desc.is_empty() {
|
|
errors.push("Description must not be empty.".to_string());
|
|
} else {
|
|
let first_char = desc.chars().next().unwrap_or(' ');
|
|
if first_char.is_uppercase() {
|
|
errors.push(format!(
|
|
"Description must start with lowercase: '{desc}'"
|
|
));
|
|
}
|
|
if desc.ends_with('.') {
|
|
errors.push(format!(
|
|
"Description must not end with a period: '{desc}'"
|
|
));
|
|
}
|
|
}
|
|
|
|
// Type-specific rules.
|
|
match type_ {
|
|
"chore" | "docs" | "refactor" | "test" | "style" | "perf" | "ci" | "build"
|
|
| "revert" => {
|
|
if scope.is_some() {
|
|
errors.push(format!(
|
|
"'{type_}' commits should not use a scope. \
|
|
Only 'feat' and 'fix' require scopes."
|
|
));
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
if errors.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(errors)
|
|
}
|
|
}
|
|
|
|
/// Convert a commit message to a structured representation.
|
|
pub struct CommitInfo {
|
|
pub type_: String,
|
|
pub scope: Option<String>,
|
|
pub breaking: bool,
|
|
pub description: String,
|
|
pub body: Option<String>,
|
|
}
|
|
|
|
/// Parse a commit message into its structured components.
|
|
pub fn parse_commit_message(msg: &str) -> Option<CommitInfo> {
|
|
let msg = msg.trim();
|
|
let subject = msg.lines().next()?;
|
|
|
|
let re = Regex::new(
|
|
r"^(?P<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<desc>.+)$",
|
|
)
|
|
.expect("valid regex");
|
|
|
|
let caps = re.captures(subject)?;
|
|
|
|
let body_lines: Vec<&str> = msg.lines().skip(1).collect();
|
|
let body = if body_lines.is_empty() {
|
|
None
|
|
} else {
|
|
Some(body_lines.join("\n"))
|
|
};
|
|
|
|
Some(CommitInfo {
|
|
type_: caps.name("type").map(|m| m.as_str()).unwrap_or("").to_string(),
|
|
scope: caps.name("scope").map(|m| m.as_str().to_string()),
|
|
breaking: caps.name("breaking").is_some(),
|
|
description: caps.name("desc").map(|m| m.as_str()).unwrap_or("").to_string(),
|
|
body,
|
|
})
|
|
}
|
|
|
|
/// Suggest a commit message template for a given change type.
|
|
pub fn suggest_template(type_: &str, scope: Option<&str>) -> String {
|
|
match type_ {
|
|
"feat" | "fix" if scope.is_some() => {
|
|
format!("{}({}): <imperative description>", type_, scope.unwrap())
|
|
}
|
|
"feat" | "fix" => {
|
|
format!("{}(<scope>): <imperative description>", type_)
|
|
}
|
|
_ => {
|
|
format!("{}: <imperative description>", type_)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn valid_feat_commit() {
|
|
assert!(validate_commit_message("feat(tool): add batch file delete").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn valid_fix_commit() {
|
|
assert!(validate_commit_message("fix(ipc): reconnect loop on socket timeout").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn valid_chore_commit() {
|
|
assert!(validate_commit_message("chore: bump reqwest to 0.13").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn valid_docs_commit() {
|
|
assert!(validate_commit_message("docs: add architecture diagram to README").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_empty_message() {
|
|
assert!(validate_commit_message("").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_missing_colon() {
|
|
assert!(validate_commit_message("feat:missing space").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_uppercase_description() {
|
|
let result = validate_commit_message("feat(tool): Add new feature");
|
|
assert!(result.is_err(), "should reject uppercase start");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_trailing_period() {
|
|
let result = validate_commit_message("feat(tool): add new feature.");
|
|
assert!(result.is_err(), "should reject trailing period");
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_invalid_type() {
|
|
let result = validate_commit_message("wat(tool): something broke");
|
|
assert!(result.is_err(), "should reject invalid type");
|
|
}
|
|
|
|
#[test]
|
|
fn parses_valid_commit() {
|
|
let parsed = parse_commit_message("feat(agent): add parallel execution\n\nWith cycle orchestration.").unwrap();
|
|
assert_eq!(parsed.type_, "feat");
|
|
assert_eq!(parsed.scope, Some("agent".to_string()));
|
|
assert!(!parsed.breaking);
|
|
assert_eq!(parsed.description, "add parallel execution");
|
|
assert!(parsed.body.unwrap().contains("cycle orchestration"));
|
|
}
|
|
|
|
#[test]
|
|
fn parses_breaking_change() {
|
|
let parsed = parse_commit_message("feat(api)!: change response format").unwrap();
|
|
assert!(parsed.breaking);
|
|
}
|
|
|
|
#[test]
|
|
fn suggests_template() {
|
|
let tpl = suggest_template("feat", Some("tool"));
|
|
assert_eq!(tpl, "feat(tool): <imperative description>");
|
|
}
|
|
}
|