use super::error::{ElenchusError, Result};
const COMMIT_TYPES: &[&str] = &[
"feat", "fix", "refactor", "test", "docs", "chore", "perf", "build", "ci", "style", "revert",
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct CommitMessage(String);
impl CommitMessage {
pub(super) fn parse(raw: Option<String>) -> Result<Self> {
let Some(message) = raw else {
return Err(Self::missing_error());
};
if message.is_empty() {
return Err(Self::missing_error());
}
if message.contains('\n') {
return Err(ElenchusError::usage(
"error: commit message must be a single line",
));
}
if !is_conventional_commit_subject(&message) {
return Err(ElenchusError::usage(format!(
"error: commit message must be a Conventional Commit subject\n\
usage: alma elenchus \"refactor(plugin): Preserve intent queue attribution\"\n\
allowed types: {}",
COMMIT_TYPES.join(", ")
)));
}
Ok(Self(message))
}
fn missing_error() -> ElenchusError {
ElenchusError::usage(
"error: missing semantic commit message\n\
usage: alma elenchus \"refactor(plugin): queue backpressure handling\"",
)
}
pub(super) fn as_str(&self) -> &str {
&self.0
}
}
fn is_conventional_commit_subject(message: &str) -> bool {
let Some((head, subject)) = message.split_once(": ") else {
return false;
};
if subject.is_empty() {
return false;
}
let head = head.strip_suffix('!').unwrap_or(head);
let (kind, scope) = if let Some(open) = head.find('(') {
let Some(scope_text) = head.strip_suffix(')') else {
return false;
};
if !scope_text[open + 1..].chars().all(is_scope_char) {
return false;
}
(&head[..open], Some(&scope_text[open + 1..]))
} else {
(head, None)
};
COMMIT_TYPES.contains(&kind) && scope.is_none_or(|scope| !scope.is_empty())
}
const fn is_scope_char(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
}
#[cfg(test)]
mod tests {
use super::{CommitMessage, is_conventional_commit_subject};
#[test]
fn conventional_commit_subject_matches_elenchus_contract() {
assert!(is_conventional_commit_subject(
"refactor(elenchus): Reuse approved review results"
));
assert!(is_conventional_commit_subject(
"feat(cli)!: Add elenchus subcommand"
));
assert!(!is_conventional_commit_subject("elenchus: missing type"));
assert!(!is_conventional_commit_subject("fix(): empty scope"));
assert!(!is_conventional_commit_subject("fix(scope):"));
assert!(!is_conventional_commit_subject("fix(scope) missing colon"));
}
#[test]
fn commit_message_rejects_multiline_input() {
let error = CommitMessage::parse(Some(String::from("fix(cli): one\ntwo")))
.expect_err("multiline message should fail");
assert_eq!(error.code, 2);
assert!(error.to_string().contains("single line"));
}
}