//! 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> { 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[a-z]+)(?:\((?P[^)]+)\))?(?P!)?:\s+(?P.+)$", ) .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, pub breaking: bool, pub description: String, pub body: Option, } /// Parse a commit message into its structured components. pub fn parse_commit_message(msg: &str) -> Option { let msg = msg.trim(); let subject = msg.lines().next()?; let re = Regex::new( r"^(?P[a-z]+)(?:\((?P[^)]+)\))?(?P!)?:\s+(?P.+)$", ) .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!("{}({}): ", type_, scope.unwrap()) } "feat" | "fix" => { format!("{}(): ", type_) } _ => { format!("{}: ", 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): "); } }