mit-commit-message-lints 3.99.1

Check the correctness of a specific commit message. Designed to be used in tools providing commit-msg style hooks
Documentation
use std::collections::BTreeMap;
use std::ops::Add;

use mit_commit::CommitMessage;
use mit_commit::Trailer as NgTrailer;

use crate::console::exit::Code;
use crate::lints::lib::Problem;

pub(crate) const CONFIG: &str = "duplicated-trailers";

const TRAILERS_TO_CHECK_FOR_DUPLICATES: [&str; 3] =
    ["Signed-off-by", "Co-authored-by", "Relates-to"];
const FIELD_SINGULAR: &str = "field";
const ERROR: &str = "Your commit message has duplicated trailers";

const FIELD_PLURAL: &str = "fields";

fn get_duplicated_trailers(commit_message: &CommitMessage) -> Vec<String> {
    commit_message
        .get_trailers()
        .iter()
        .fold(
            BTreeMap::new(),
            |acc: BTreeMap<&NgTrailer, usize>, trailer| {
                let mut next = acc.clone();
                match acc.get(trailer) {
                    Some(count) => next.insert(trailer, count.add(1)),
                    None => next.insert(trailer, 1),
                };

                next
            },
        )
        .into_iter()
        .filter_map(|(trailer, usize)| {
            let key: &str = &trailer.get_key();

            if usize > 1 && TRAILERS_TO_CHECK_FOR_DUPLICATES.contains(&key) {
                Some(trailer.get_key())
            } else {
                None
            }
        })
        .collect::<Vec<_>>()
}

pub(crate) fn lint(commit: &CommitMessage) -> Option<Problem> {
    let duplicated_trailers = get_duplicated_trailers(commit);
    if duplicated_trailers.is_empty() {
        None
    } else {
        let warning = warning(&duplicated_trailers);
        Some(Problem::new(
            ERROR.into(),
            warning,
            Code::DuplicatedTrailers,
        ))
    }
}

fn warning(duplicated_trailers: &[String]) -> String {
    let warning = format!(
        "These are normally added accidentally when you're rebasing or amending to a commit, \
        sometimes in the text editor, but often by git hooks.\n\nYou can fix this by deleting the \
        duplicated \"{}\" {}",
        duplicated_trailers.join("\", \""),
        if duplicated_trailers.len() > 1 {
            FIELD_PLURAL
        } else {
            FIELD_SINGULAR
        }
    );
    warning
}

#[cfg(test)]
mod tests_has_duplicated_trailers {
    #![allow(clippy::wildcard_imports)]

    use indoc::indoc;
    use mit_commit::CommitMessage;
    use pretty_assertions::assert_eq;

    use super::*;

    #[test]
    fn commit_without_trailers() {
        test_lint_duplicated_trailers(
            indoc!(
                "
                An example commit

                This is an example commit without any duplicate trailers
                "
            )
            .into(),
            &None,
        );
    }

    #[test]
    fn two_duplicates() {
        test_lint_duplicated_trailers(
            indoc!(
                "
                An example commit

                This is an example commit without any duplicate trailers

                Signed-off-by: Billie Thompson <email@example.com>
                Signed-off-by: Billie Thompson <email@example.com>
                Co-authored-by: Billie Thompson <email@example.com>
                Co-authored-by: Billie Thompson <email@example.com>
                "
            ).into(),
            &Some(Problem::new(ERROR.into(),
                               "These are normally added accidentally when you\'re rebasing or amending to a commit, sometimes in the text editor, but often by git hooks.\n\nYou can fix this by deleting the duplicated \"Co-authored-by\", \"Signed-off-by\" fields".into(),
                               Code::DuplicatedTrailers,
            )),
        );
    }

    #[test]
    fn signed_off_by() {
        test_lint_duplicated_trailers(
            indoc!(
                "
                An example commit

                This is an example commit without any duplicate trailers

                Signed-off-by: Billie Thompson <email@example.com>
                Signed-off-by: Billie Thompson <email@example.com>
                "
            )
            .into(),
            &Some(Problem::new(
                ERROR.into(),
                "These are normally added accidentally when you\'re rebasing or amending to a commit, sometimes in the text editor, but often by git hooks.\n\nYou can fix this by deleting the duplicated \"Signed-off-by\" field".into(),
                Code::DuplicatedTrailers,
            )),
        );
    }

    #[test]
    fn co_authored_by() {
        test_lint_duplicated_trailers(
            indoc!(
                "
                An example commit

                This is an example commit without any duplicate trailers

                Co-authored-by: Billie Thompson <email@example.com>
                Co-authored-by: Billie Thompson <email@example.com>
                "
            )
            .into(),
            &Some(Problem::new(
                ERROR.into(),
                "These are normally added accidentally when you\'re rebasing or amending to a commit, sometimes in the text editor, but often by git hooks.\n\nYou can fix this by deleting the duplicated \"Co-authored-by\" field".into(),
                Code::DuplicatedTrailers,
            )),
        );
    }

    #[test]
    fn relates_to() {
        test_lint_duplicated_trailers(
            indoc!(
                "
                An example commit

                This is an example commit without any duplicate trailers

                Relates-to: #315
                Relates-to: #315
                "
            )
            .into(),
            &Some(Problem::new(
                ERROR.into(),
                "These are normally added accidentally when you\'re rebasing or amending to a commit, sometimes in the text editor, but often by git hooks.\n\nYou can fix this by deleting the duplicated \"Relates-to\" field".into(),
                Code::DuplicatedTrailers,
            )),
        );
    }

    #[test]
    fn trailer_like_duplicates_in_the_scissors_section() {
        test_lint_duplicated_trailers(
            indoc!(
                "
                Move to specdown
                # Bitte geben Sie eine Commit-Beschreibung fur Ihre Anderungen ein. Zeilen,
                # die mit '#' beginnen, werden ignoriert, und eine leere Beschreibung

                # ------------------------ >8 ------------------------
                # Andern oder entfernen Sie nicht die obige Zeile.
                # Alles unterhalb von ihr wird ignoriert.
                diff --git a/Makefile b/Makefile
                index 0d3fc98..38a2784 100644
                --- a/Makefile
                +++ b/Makefile
                +
                 This is a commit message that has trailers and is invalid

                -Signed-off-by: Someone Else <someone@example.com>
                -Signed-off-by: Someone Else <someone@example.com>
                 Co-authored-by: Billie Thompson <billie@example.com>
                 Co-authored-by: Billie Thompson <billie@example.com>
                +Signed-off-by: Someone Else <someone@example.com>
                +Signed-off-by: Someone Else <someone@example.com>


                 ---
                @@ -82,6 +82,7 @@ Co-authored-by: Billie Thompson <billie@example.com>
                 Your commit message has duplicated trailers

                 You can fix this by deleting the duplicated \"Signed-off-by\", \"Co-authored-by\" fields
                +
                "
            ).into(),
            &None,
        );
    }

    #[test]
    fn other_trailers() {
        test_lint_duplicated_trailers(
            indoc!(
                "
                An example commit

                This is an example commit without any duplicate trailers

                Anything: Billie Thompson <email@example.com>
                Anything: Billie Thompson <email@example.com>
                "
            )
            .into(),
            &None,
        );
    }

    fn test_lint_duplicated_trailers(message: String, expected: &Option<Problem>) {
        let actual = &lint(&CommitMessage::from(message));
        assert_eq!(
            actual, expected,
            "Expected {:?}, found {:?}",
            expected, actual
        );
    }
}