helen 0.1.0

Repository review gate.
Documentation
//! Elenchus commit-message validation.

use super::error::{ElenchusError, Result};

/// Conventional Commit types accepted by the elenchus gate.
const COMMIT_TYPES: &[&str] = &[
    "feat", "fix", "refactor", "test", "docs", "chore", "perf", "build", "ci", "style", "revert",
];

/// Validated single-line Conventional Commit subject.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct CommitMessage(String);

impl CommitMessage {
    /// Validates a raw elenchus message.
    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))
    }

    /// Returns the diagnostic used when the message is absent.
    fn missing_error() -> ElenchusError {
        ElenchusError::usage(
            "error: missing semantic commit message\n\
             usage: alma elenchus \"refactor(plugin): queue backpressure handling\"",
        )
    }

    /// Borrows the validated message as text.
    pub(super) fn as_str(&self) -> &str {
        &self.0
    }
}

/// Validates the Conventional Commit subject grammar used by the shell gate.
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())
}

/// Returns true for characters allowed inside a Conventional Commit scope.
const fn is_scope_char(character: char) -> bool {
    character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-')
}

#[cfg(test)]
mod tests {
    //! Tests for commit-message validation.

    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"));
    }
}