mit-lint 4.0.0

Lints for commits parsed with mit-commit.
Documentation
use mit_commit::CommitMessage;

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

/// Canonical lint ID
pub const CONFIG: &str = "subject-line-not-capitalized";
/// Advice on how to correct the problem
pub const HELP_MESSAGE: &str = "The subject line is a title, and as such should be \
                            capitalised.\n\nYou can fix this by capitalising the first character \
                            in the subject";
/// Description of the problem
pub const ERROR: &str = "Your commit message is missing a capital letter";

fn has_problem(commit_message: &CommitMessage<'_>) -> bool {
    commit_message
        .get_subject()
        .chars()
        .find(|x| !x.is_whitespace())
        .as_ref()
        .is_some_and(|x| x.is_lowercase())
}

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

pub fn lint(commit_message: &CommitMessage<'_>) -> Option<Problem> {
    lint_with_config(commit_message, &SubjectNotCapitalizedConfig)
}

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

fn create_problem(commit_message: &CommitMessage) -> Problem {
    let subject = commit_message.get_subject();
    let position: usize = subject
        .chars()
        .take_while(|x| x.is_whitespace())
        .map(char::len_utf8)
        .sum();

    let length: usize = subject
        .chars()
        .find(|x| !x.is_whitespace())
        .map_or(1, char::len_utf8);

    ProblemBuilder::new(
        ERROR,
        HELP_MESSAGE,
        Code::SubjectNotCapitalized,
        commit_message,
    )
    .with_url("https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines")
    .with_label("Not capitalised", position, length)
    .build()
}

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

    use crate::{Code, Problem};
    use miette::{Diagnostic, GraphicalReportHandler, GraphicalTheme, Report};
    use mit_commit::CommitMessage;
    use quickcheck::TestResult;

    #[test]
    fn test_capitalized_subject_passes() {
        run_test("Subject Line", None);
    }

    #[test]
    fn test_lowercase_subject_fails() {
        run_test(
            "subject line
",
            Some(Problem::new(
                ERROR.into(),
                HELP_MESSAGE.into(),
                Code::SubjectNotCapitalized,
                &"subject line
"
                    .into(),
                Some(vec![("Not capitalised".to_string(), 0_usize, 1_usize)]),
                Some("https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines".parse().unwrap()),
            )).as_ref(),
        );
    }

    #[test]
    fn test_two_leading_spaces_with_lowercase_subject_fails() {
        run_test(
            "  hello",
            Some(Problem::new(
                ERROR.into(),
                HELP_MESSAGE.into(),
                Code::SubjectNotCapitalized,
                &CommitMessage::from("  hello"),
                Some(vec![("Not capitalised".to_string(), 2_usize, 1_usize)]),
                Some("https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines".to_string()),
            )).as_ref(),
        );
    }

    #[test]
    fn test_leading_space_with_lowercase_subject_fails() {
        run_test(
            "  subject line",
            Some(Problem::new(
                ERROR.into(),
                HELP_MESSAGE.into(),
                Code::SubjectNotCapitalized,
                &CommitMessage::from("  subject line"),
                Some(vec![("Not capitalised".to_string(), 2_usize, 1_usize)]),
                Some("https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines".to_string()),
            )).as_ref(),
        );
    }

    #[test]
    fn test_numeric_subject_passes() {
        run_test(
            "1234567
", None,
        );
    }

    #[test]
    fn test_unicode_titlecase_character() {
        // Test with the specific character "Dž" (U+01C5 LATIN CAPITAL LETTER D WITH SMALL LETTER Z WITH CARON)
        // This is a titlecase character in Unicode
        run_test("Dž", None);
    }

    #[test]
    fn test_multibyte_lowercase_char_label_uses_byte_length() {
        // "ä" is a lowercase character that is 2 bytes in UTF-8.
        // The label must use the byte length (2), not a hardcoded 1.
        let message = "ä test commit\n# comment";
        let commit = CommitMessage::from(message);
        let result = lint(&commit);

        let problem = result.expect("should detect lowercase 'ä'");
        let labels: Vec<_> = problem
            .labels()
            .unwrap()
            .map(|span| (span.label().unwrap().to_string(), span.offset(), span.len()))
            .collect();

        assert_eq!(labels.len(), 1, "Expected 1 label, got {labels:?}");
        assert_eq!(
            labels[0],
            ("Not capitalised".to_string(), 0, 2),
            "Label for 'ä' should be at byte offset 0 with byte length 2"
        );
    }

    #[test]
    fn test_unicode_titlecase_character_in_quickcheck() {
        // This test simulates the quickcheck test with the specific character "Dž"
        let commit_message_body = "Dž";

        // Check if the character would be discarded by the quickcheck test
        let char = commit_message_body.chars().next().unwrap();
        let would_discard = char.to_uppercase().to_string() == char.to_string()
            || char.is_uppercase()
            || !char.is_alphabetic();

        // The character should not be discarded, and the lint should pass
        assert!(
            !would_discard,
            "The character 'Dž' should not be discarded by the quickcheck test"
        );

        // Verify the lint result
        let message = CommitMessage::from(format!("{commit_message_body}\n# commit"));
        let result = lint(&message);
        assert!(
            result.is_none(),
            "The lint should pass for the character 'Dž'"
        );
    }

    #[test]
    fn test_error_formatting_matches_expected_output() {
        let message = "  an example commit\n\nexample";
        let problem = lint(&CommitMessage::from(message.to_string()));
        let actual = fmt_report(&Report::new(problem.unwrap()));

        // Instead of comparing the exact strings, we'll normalize the whitespace
        // by removing all whitespace and then comparing
        let normalize_whitespace =
            |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>();

        let actual_normalized = normalize_whitespace(&actual);
        let expected_normalized = normalize_whitespace("SubjectNotCapitalized (https://git-scm.com/book/en/v2/Distributed-Git-Contributing-to-a-Project#_commit_guidelines)

  x Your commit message is missing a capital letter
   ,-[1:3]
 1 |   an example commit
   :   |
   :   `-- Not capitalised
 2 | 
   `----
  help: The subject line is a title, and as such should be capitalised.

        You can fix this by capitalising the first character in the subject
");

        assert_eq!(
            actual_normalized, expected_normalized,
            "Message {message:?} should have returned a string equivalent to the expected output after normalizing whitespace"
        );
    }

    #[allow(clippy::needless_pass_by_value)]
    #[quickcheck]
    fn test_lowercase_first_character_always_fails(commit_message_body: String) -> TestResult {
        match commit_message_body
            .chars()
            .take_while(|x| *x != '\n')
            .find(|x| !x.is_whitespace())
        {
            None => return TestResult::discard(),
            Some(char) => {
                // We only want characters that the lint detects as problems,
                // i.e. characters where is_lowercase() returns true.
                // Skip titlecase, uppercase, non-alphabetic, and characters
                // without a distinct uppercase form.
                if !char.is_lowercase() || char.to_uppercase().to_string() == char.to_string() {
                    return TestResult::discard();
                }
            }
        }

        let message = CommitMessage::from(format!("{commit_message_body}\n# commit"));
        let result = lint(&message);
        let b = result.is_some();
        TestResult::from_bool(b)
    }

    #[allow(clippy::needless_pass_by_value)]
    #[quickcheck]
    fn test_uppercase_first_character_always_passes(commit_message_body: String) -> TestResult {
        if commit_message_body.starts_with('#') {
            return TestResult::discard();
        }

        match commit_message_body
            .chars()
            .take_while(|x| *x != '\n')
            .find(|x| !x.is_whitespace())
        {
            None => return TestResult::discard(),
            Some(char) => {
                if char.is_lowercase() {
                    return TestResult::discard();
                }
            }
        }

        let message = CommitMessage::from(format!("{commit_message_body}\n# commit"));
        let result = lint(&message);
        TestResult::from_bool(result.is_none())
    }

    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 run_test(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:?}"
        );
    }
}