git-checks 4.2.1

Checks to run against a topic in git to enforce coding standards.
Documentation
// Copyright Kitware, Inc.
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

use derive_builder::Builder;
use git_checks_core::impl_prelude::*;
use itertools::Itertools;

/// The CR/LF line ending.
const CR_LF_ENDING: &str = "\r\n";
/// A symbol to replace `\r` characters so that they appear in the error message.
const CARRIAGE_RETURN_SYMBOL: &str = "\u{23ce}";

/// Checks for bad whitespace using Git's built-in checks.
///
/// This is attribute-driven, so any `gitattributes(5)` files may be used to suppress spirious
/// errors from this check.
#[derive(Builder, Debug, Default, Clone, Copy)]
#[builder(field(private))]
pub struct CheckWhitespace {}

impl CheckWhitespace {
    /// Create a new builder.
    pub fn builder() -> CheckWhitespaceBuilder {
        CheckWhitespaceBuilder::default()
    }

    fn diff_tree(
        ctx: &CheckGitContext,
        args: &[&str],
        content: &dyn Content,
    ) -> Result<CheckResult, Box<dyn Error>> {
        let mut result = CheckResult::new();

        let diff_tree = ctx
            .git()
            .arg("diff-tree")
            .arg("--no-commit-id")
            .arg("--root")
            .arg("-c")
            .arg("--check")
            .args(args)
            .output()
            .map_err(|err| GitError::subcommand("diff-tree", err))?;
        if !diff_tree.status.success() {
            Self::add_error(&mut result, &diff_tree.stdout, content);
        }

        Ok(result)
    }

    fn add_error(result: &mut CheckResult, output: &[u8], content: &dyn Content) {
        // Check for CR/LF line endings. This is done because most editors will mask their
        // existence making the "trailing whitespace" hard to find.
        let output = String::from_utf8_lossy(output);
        let crlf_msg = if output.contains(CR_LF_ENDING) {
            " including CR/LF line endings"
        } else {
            ""
        };
        let formatted_output = output
            .split('\n')
            // Git seems to add a trailing newline to its output, so drop the last line.
            .dropping_back(1)
            .map(|line| format!("        {}\n", line))
            .join("")
            .replace('\r', CARRIAGE_RETURN_SYMBOL);

        result.add_error(format!(
            "{}adds bad whitespace{}:\n\n{}",
            commit_prefix(content),
            crlf_msg,
            formatted_output,
        ));
    }
}

impl Check for CheckWhitespace {
    fn name(&self) -> &str {
        "check-whitespace"
    }

    fn check(&self, ctx: &CheckGitContext, commit: &Commit) -> Result<CheckResult, Box<dyn Error>> {
        Self::diff_tree(ctx, &[commit.sha1.as_str()], commit)
    }
}

impl TopicCheck for CheckWhitespace {
    fn name(&self) -> &str {
        "check-whitespace"
    }

    fn check(&self, ctx: &CheckGitContext, topic: &Topic) -> Result<CheckResult, Box<dyn Error>> {
        Self::diff_tree(ctx, &[topic.base.as_str(), topic.sha1.as_str()], topic)
    }
}

#[cfg(feature = "config")]
pub(crate) mod config {
    use git_checks_config::{register_checks, CommitCheckConfig, IntoCheck, TopicCheckConfig};
    use serde::Deserialize;
    #[cfg(test)]
    use serde_json::json;

    use crate::CheckWhitespace;

    /// Configuration for the `CheckWhitespace` check.
    ///
    /// No configuration available.
    ///
    /// This check is registered as a commit check with the name `"check_whitespace"` and a topic
    /// check with the name `"check_whitespace/topic"`.
    #[derive(Deserialize, Debug)]
    pub struct CheckWhitespaceConfig {}

    impl IntoCheck for CheckWhitespaceConfig {
        type Check = CheckWhitespace;

        fn into_check(self) -> Self::Check {
            CheckWhitespace::default()
        }
    }

    register_checks! {
        CheckWhitespaceConfig {
            "check_whitespace" => CommitCheckConfig,
            "check_whitespace/topic" => TopicCheckConfig,
        },
    }

    #[test]
    fn test_check_whitespace_config_empty() {
        let json = json!({});
        let check: CheckWhitespaceConfig = serde_json::from_value(json).unwrap();

        let _ = check.into_check();
    }
}

#[cfg(test)]
mod tests {
    use git_checks_core::{Check, TopicCheck};

    use crate::test::*;
    use crate::CheckWhitespace;

    const DEFAULT_TOPIC: &str = "829cdf8cb069b8f8a634a034d3f85089271601cf";
    const NOCR_TOPIC: &str = "5db0c24d032d972ba5bf50eca99016adbfdd3e87";
    const ALL_IGNORED_TOPIC: &str = "3a87e0f3f7430bbb81ebbd8ae8764b7f26384f1c";
    const ALL_IGNORED_BLANKET_TOPIC: &str = "92cac7579a26f7d8449512476bd64b3000688fd5";

    #[test]
    fn test_check_whitespace_builder_default() {
        assert!(CheckWhitespace::builder().build().is_ok());
    }

    #[test]
    fn test_check_whitespace_name_commit() {
        let check = CheckWhitespace::default();
        assert_eq!(Check::name(&check), "check-whitespace");
    }

    #[test]
    fn test_check_whitespace_name_topic() {
        let check = CheckWhitespace::default();
        assert_eq!(TopicCheck::name(&check), "check-whitespace");
    }

    #[test]
    fn test_check_whitespace_defaults() {
        let check = CheckWhitespace::default();
        let result = run_check("test_check_whitespace_defaults", DEFAULT_TOPIC, check);
        test_result_errors(
            result,
            &[
                "commit 829cdf8cb069b8f8a634a034d3f85089271601cf adds bad whitespace including \
                 CR/LF line endings:\n\
                 \n        \
                 crlf-file:1: trailing whitespace.\n        \
                 +This file contains CRLF lines.\u{23ce}\n        \
                 crlf-file:2: trailing whitespace.\n        \
                 +\u{23ce}\n        \
                 crlf-file:3: trailing whitespace.\n        \
                 +line1\u{23ce}\n        \
                 crlf-file:4: trailing whitespace.\n        \
                 +line2\u{23ce}\n        \
                 crlf-mixed-file:3: trailing whitespace.\n        \
                 +crlf\u{23ce}\n        \
                 extra-newlines:2: new blank line at EOF.\n        \
                 mixed-tabs-spaces:3: space before tab in indent.\n        \
                 +   \tmixed indent\n        \
                 trailing-spaces:3: trailing whitespace.\n        \
                 +trailing \n        \
                 trailing-tab:3: trailing whitespace.\n        \
                 +trailing\t\n",
            ],
        );
    }

    #[test]
    fn test_check_whitespace_nocr() {
        let check = CheckWhitespace::default();
        let result = run_check("test_check_whitespace_nocr", NOCR_TOPIC, check);
        test_result_errors(
            result,
            &[
                "commit 5db0c24d032d972ba5bf50eca99016adbfdd3e87 adds bad whitespace:\n\
                 \n        \
                 extra-newlines:2: new blank line at EOF.\n        \
                 mixed-tabs-spaces:3: space before tab in indent.\n        \
                 +   \tmixed indent\n        \
                 trailing-spaces:3: trailing whitespace.\n        \
                 +trailing \n        \
                 trailing-tab:3: trailing whitespace.\n        \
                 +trailing\t\n",
            ],
        );
    }

    #[test]
    fn test_check_whitespace_all_ignored() {
        let check = CheckWhitespace::default();
        run_check_ok(
            "test_check_whitespace_all_ignored",
            ALL_IGNORED_TOPIC,
            check,
        );
    }

    #[test]
    fn test_check_whitespace_all_ignored_blanket() {
        let check = CheckWhitespace::default();
        run_check_ok(
            "test_check_whitespace_all_ignored_blanket",
            ALL_IGNORED_BLANKET_TOPIC,
            check,
        );
    }

    #[test]
    fn test_check_whitespace_defaults_topic() {
        let check = CheckWhitespace::default();
        let result = run_topic_check("test_check_whitespace_defaults_topic", DEFAULT_TOPIC, check);
        test_result_errors(
            result,
            &["adds bad whitespace including CR/LF line endings:\n\
               \n        \
               crlf-file:1: trailing whitespace.\n        \
               +This file contains CRLF lines.\u{23ce}\n        \
               crlf-file:2: trailing whitespace.\n        \
               +\u{23ce}\n        \
               crlf-file:3: trailing whitespace.\n        \
               +line1\u{23ce}\n        \
               crlf-file:4: trailing whitespace.\n        \
               +line2\u{23ce}\n        \
               crlf-mixed-file:3: trailing whitespace.\n        \
               +crlf\u{23ce}\n        \
               extra-newlines:2: new blank line at EOF.\n        \
               mixed-tabs-spaces:3: space before tab in indent.\n        \
               +   \tmixed indent\n        \
               trailing-spaces:3: trailing whitespace.\n        \
               +trailing \n        \
               trailing-tab:3: trailing whitespace.\n        \
               +trailing\t\n"],
        );
    }

    #[test]
    fn test_check_whitespace_nocr_topic() {
        let check = CheckWhitespace::default();
        let result = run_topic_check("test_check_whitespace_nocr_topic", NOCR_TOPIC, check);
        test_result_errors(
            result,
            &["adds bad whitespace:\n\
               \n        \
               extra-newlines:2: new blank line at EOF.\n        \
               mixed-tabs-spaces:3: space before tab in indent.\n        \
               +   \tmixed indent\n        \
               trailing-spaces:3: trailing whitespace.\n        \
               +trailing \n        \
               trailing-tab:3: trailing whitespace.\n        \
               +trailing\t\n"],
        );
    }

    #[test]
    fn test_check_whitespace_all_ignored_topic() {
        let check = CheckWhitespace::default();
        run_topic_check_ok(
            "test_check_whitespace_all_ignored_topic",
            ALL_IGNORED_TOPIC,
            check,
        );
    }

    #[test]
    fn test_check_whitespace_all_ignored_blanket_topic() {
        let check = CheckWhitespace::default();
        run_topic_check_ok(
            "test_check_whitespace_all_ignored_blanket_topic",
            ALL_IGNORED_BLANKET_TOPIC,
            check,
        );
    }
}