mit-lint 4.1.0

Lints for commits parsed with mit-commit.
Documentation
use std::sync::LazyLock;

use mit_commit::CommitMessage;
use regex::Regex;

use crate::model::{Code, Problem, ProblemBuilder};

/// Canonical lint ID
pub const CONFIG: &str = "jira-issue-key-missing";
/// Advice on how to correct the problem
pub const HELP_MESSAGE: &str = "It's important to add the issue key because it allows us to link code back to the motivations \
for doing it, and in some cases provide an audit trail for compliance purposes.

You can fix this by adding a key like `JRA-123` to the commit message" ;
/// Description of the problem
pub const ERROR: &str = "Your commit message is missing a JIRA Issue Key";

static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)\b[A-Z]{2,}-\d+\b").unwrap());

pub struct JiraIssueKeyConfig;
impl Default for JiraIssueKeyConfig {
    fn default() -> Self {
        Self
    }
}

/// Checks if the commit message contains a JIRA issue key
///
/// # Arguments
///
/// * `commit_message` - The commit message to check
///
/// # Returns
///
/// * `Some(Problem)` - If the commit message does not contain a JIRA issue key
/// * `None` - If the commit message contains a JIRA issue key
///
/// # Examples
///
/// ```rust
/// use mit_commit::CommitMessage;
/// use mit_lint::Lint::JiraIssueKeyMissing;
///
/// // This should pass
/// let passing = CommitMessage::from("Subject\n\nBody\n\nJRA-123");
/// assert!(JiraIssueKeyMissing.lint(&passing).is_none());
///
/// // This should fail
/// let failing = CommitMessage::from("Subject\n\nBody");
/// assert!(JiraIssueKeyMissing.lint(&failing).is_some());
/// ```
///
/// # Errors
///
/// This function will never return an error, only an Option<Problem>
pub fn lint(commit_message: &CommitMessage<'_>) -> Option<Problem> {
    lint_with_config(commit_message, &JiraIssueKeyConfig)
}

fn lint_with_config(
    commit_message: &CommitMessage<'_>,
    _config: &JiraIssueKeyConfig,
) -> Option<Problem> {
    Some(commit_message)
        .filter(|commit| !has_jira_key(commit, &RE))
        .map(create_problem)
}

fn has_jira_key(commit_message: &CommitMessage<'_>, pattern: &Regex) -> bool {
    commit_message.matches_pattern(pattern)
}

fn create_problem(commit_message: &CommitMessage) -> Problem {
    // Use ProblemBuilder instead of directly creating Problem
    ProblemBuilder::new(
        ERROR,
        HELP_MESSAGE,
        Code::JiraIssueKeyMissing,
        commit_message,
    )
    .with_label_at_last_line("No JIRA Issue Key")
    .with_url("https://support.atlassian.com/jira-software-cloud/docs/what-is-an-issue/#Workingwithissues-Projectkeys")
    .build()
}

#[cfg(test)]
mod tests {
    use std::option::Option::None;

    use miette::{GraphicalReportHandler, GraphicalTheme, Report};
    use mit_commit::CommitMessage;
    use quickcheck::TestResult;

    use super::*;

    #[test]
    fn test_lowercase_token_is_not_a_jira_key() {
        // JIRA project keys are ALWAYS uppercase, so a lowercase token like
        // "parser-2024" must NOT count as a JIRA issue key. The commit message
        // below contains no uppercase KEY-NUMBER token and should therefore be
        // flagged as missing a JIRA issue key.
        let message = "fix the parser-2024 regression";
        let actual = lint(&CommitMessage::from(message));
        assert!(
            actual.is_some(),
            "Lowercase token 'parser-2024' should not count as a JIRA key; \
             the commit should be flagged as missing a JIRA issue key, \
             but lint returned None"
        );
    }

    #[test]
    fn test_jira_keys_in_comments_are_ignored() {
        test_has_missing_jira_issue_key(
            "An example commit\n\n# JRA-123 in comment",
            Some(Problem::new(
                ERROR.into(),
                HELP_MESSAGE.into(),
                Code::JiraIssueKeyMissing,
                &"An example commit\n\n# JRA-123 in comment".into(),
                Some(vec![("No JIRA Issue Key".to_string(), 19, 20)]),
                Some("https://support.atlassian.com/jira-software-cloud/docs/what-is-an-issue/#Workingwithissues-Projectkeys".parse().unwrap()),
            )).as_ref(),
        );
    }

    #[test]
    fn test_jira_key_in_comment_line_is_ignored() {
        // Regression test for quickcheck failure: when the subject line starts with #,
        // the entire line is treated as a comment and the JIRA key is ignored.
        let message = "# AA-0\n# comment";
        let result = lint(&CommitMessage::from(message));
        assert!(
            result.is_some(),
            "Commit with JIRA key in comment should fail"
        );
    }

    #[test]
    fn test_commit_with_jira_id_passes() {
        test_has_missing_jira_issue_key(
            "JRA-123 An example commit

This is an example commit
",
            None,
        );
        test_has_missing_jira_issue_key(
            "An example commit

This is an JRA-123 example commit
",
            None,
        );
        test_has_missing_jira_issue_key(
            "An example commit

JRA-123

This is an example commit
",
            None,
        );
        test_has_missing_jira_issue_key(
            "An example commit

This is an example commit

JRA-123
",
            None,
        );
        test_has_missing_jira_issue_key(
            "An example commit

This is an example commit

JR-123
",
            None,
        );
        test_has_missing_jira_issue_key(
            "An example commit

This is an example commit

Relates-to: [JRA-123]
",
            None,
        );
        test_has_missing_jira_issue_key(
            "[JRA-123] An example commit

This is an example commit
",
            None,
        );
        test_has_missing_jira_issue_key(
            "An example commit

This is an [JRA-123] example commit
",
            None,
        );
    }

    #[test]
    fn test_commit_without_jira_id_fails() {
        let message_1 = "An example commit

This is an example commit
";
        test_has_missing_jira_issue_key(
            message_1,
            Some(Problem::new(
                ERROR.into(),
                HELP_MESSAGE.into(),
                Code::JiraIssueKeyMissing,
                &message_1.into(),
                Some(vec![("No JIRA Issue Key".to_string(), 19_usize, 25_usize)]),
                Some("https://support.atlassian.com/jira-software-cloud/docs/what-is-an-issue/#Workingwithissues-Projectkeys".parse().unwrap()),
            )).as_ref(),
        );
        let message_2 = "An example commit

This is an example commit

A-123
";
        test_has_missing_jira_issue_key(
            message_2,
            Some(Problem::new(
                ERROR.into(),
                HELP_MESSAGE.into(),
                Code::JiraIssueKeyMissing,
                &message_2.into(),
                Some(vec![("No JIRA Issue Key".to_string(), 46_usize, 5_usize)]),
                Some("https://support.atlassian.com/jira-software-cloud/docs/what-is-an-issue/#Workingwithissues-Projectkeys".parse().unwrap()),
            )).as_ref(),
        );
        let message_3 = "An example commit

This is an example commit

JRA-
";
        test_has_missing_jira_issue_key(
            message_3,
            Some(Problem::new(
                ERROR.into(),
                HELP_MESSAGE.into(),
                Code::JiraIssueKeyMissing,
                &message_3.into(),
                Some(vec![("No JIRA Issue Key".to_string(), 46_usize, 4_usize)]),
                Some("https://support.atlassian.com/jira-software-cloud/docs/what-is-an-issue/#Workingwithissues-Projectkeys".parse().unwrap()),
            )).as_ref(),
        );
    }

    #[test]
    fn test_error_report_formatting() {
        let message = "An example commit

This is an example commit
";
        let problem = lint(&CommitMessage::from(message.to_string()));
        let actual = fmt_report(&Report::new(problem.unwrap()));
        let expected = "JiraIssueKeyMissing (https://support.atlassian.com/jira-software-cloud/docs/what-is-an-issue/#Workingwithissues-Projectkeys)

  x Your commit message is missing a JIRA Issue Key
   ,-[3:1]
 2 | 
 3 | This is an example commit
   : ^^^^^^^^^^^^|^^^^^^^^^^^^
   :             `-- No JIRA Issue Key
   `----
  help: It's important to add the issue key because it allows us to link code
        back to the motivations for doing it, and in some cases provide an
        audit trail for compliance purposes.
        
        You can fix this by adding a key like `JRA-123` to the commit message
" .to_string();
        assert_eq!(
            actual, expected,
            "Message {message:?} should have returned {expected:?}, found {actual:?}"
        );
    }

    fn fmt_report(diag: &Report) -> String {
        let mut out = String::new();
        GraphicalReportHandler::new_themed(GraphicalTheme::none())
            .with_width(80)
            .with_links(false)
            .render_report(&mut out, diag.as_ref())
            .unwrap();
        out
    }

    fn test_has_missing_jira_issue_key(message: &str, expected: Option<&Problem>) {
        let actual = lint(&CommitMessage::from(message));
        assert_eq!(
            actual.as_ref(),
            expected,
            "Message {message:?} should have returned {expected:?}, found {actual:?}"
        );
    }

    #[derive(Debug, Clone)]
    struct CommitWithoutJira(String);

    impl quickcheck::Arbitrary for CommitWithoutJira {
        fn arbitrary(g: &mut quickcheck::Gen) -> Self {
            // Generate a commit message guaranteed to lack JIRA issue keys
            let subject = String::arbitrary(g)
                .replace(|c: char| c.is_ascii_uppercase() || c == '-', "")
                .replace("JRA", "")
                .replace("PROJ", "");

            let mut body = String::new();
            for _ in 0..=(usize::arbitrary(g) % 5) {
                let word = String::arbitrary(g)
                    .replace(|c: char| c.is_ascii_uppercase() || c.is_ascii_digit(), "")
                    .replace('-', "");
                body.push_str(&word);
                body.push(' ');
            }

            Self(format!("{subject}\n\n{body}"))
        }
    }

    #[allow(clippy::needless_pass_by_value)]
    #[quickcheck]
    fn test_quickcheck_commits_without_jira_id_fail(commit: CommitWithoutJira) -> TestResult {
        let message = CommitMessage::from(commit.0);
        let result = lint(&message);
        TestResult::from_bool(result.is_some())
    }

    #[allow(clippy::needless_pass_by_value)]
    #[quickcheck]
    fn test_quickcheck_commits_with_jira_id_pass(
        before: Option<String>,
        characters: String,
        numbers: usize,
        after: Option<String>,
    ) -> TestResult {
        if characters.chars().count() < 2
            || characters
                .chars()
                .any(|x| !x.is_ascii_alphabetic() || !x.is_uppercase())
        {
            return TestResult::discard();
        }

        // Reject cases where `before` would make the JIRA key appear in a comment
        // (lines starting with # are treated as comments and excluded from matches_pattern)
        if before.as_ref().map_or(false, |s| s.contains('#')) {
            return TestResult::discard();
        }

        let message = CommitMessage::from(format!(
            "{}{}-{}{}\n# comment",
            before.map(|x| format!("{x} ")).unwrap_or_default(),
            characters,
            numbers,
            after.map(|x| format!(" {x} ")).unwrap_or_default(),
        ));
        let result = lint(&message);
        TestResult::from_bool(result.is_none())
    }

    #[test]
    fn test_jira_key_in_scissors_section_is_ignored() {
        let message = [
            "An example commit",
            "",
            "This is an example commit",
            "# ------------------------ >8 ------------------------",
            "JRA-123",
        ]
        .join("\n");

        // Should fail because JRA-123 is in scissors section, not actual commit message
        test_has_missing_jira_issue_key(
            &message.clone(),
            Some(Problem::new(
                ERROR.into(),
                HELP_MESSAGE.into(),
                Code::JiraIssueKeyMissing,
                &message.into(),
                Some(vec![("No JIRA Issue Key".to_string(), 100_usize, 7_usize)]),
                Some("https://support.atlassian.com/jira-software-cloud/docs/what-is-an-issue/#Workingwithissues-Projectkeys".parse().unwrap()),
            )).as_ref(),
        );
    }
}