git-checks-core 1.4.0

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 std::collections::BTreeMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;

use git_workarea::{GitError, GitWorkArea, Identity, SubmoduleConfig};
use thiserror::Error;

/// Errors which can occur when querying an attribute.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum AttributeError {
    /// Command preparation failure.
    #[error("git error: {}", source)]
    Git {
        /// The cause of the error.
        #[from]
        source: GitError,
    },
    /// Failure when getting the attribute from git.
    #[error(
        "check-attr error: failed to check the {} attribute of {}: {}",
        attribute,
        path.display(),
        output
    )]
    CheckAttr {
        /// The attribute being queried.
        attribute: String,
        /// The path being queried.
        path: PathBuf,
        /// Git's output for the error.
        output: String,
    },
    /// Failure to parse Git's attribute output.
    #[error(
        "check-attr error: unexpected git output format error: no value for {} on {}",
        attribute,
        path.display()
    )]
    MissingValue {
        /// The attribute being queried.
        attribute: String,
        /// The path being queried.
        path: PathBuf,
    },
}

impl AttributeError {
    fn check_attr(attr: &str, path: &OsStr, output: &[u8]) -> Self {
        AttributeError::CheckAttr {
            attribute: attr.into(),
            path: path.into(),
            output: String::from_utf8_lossy(output).into(),
        }
    }

    fn missing_value(attr: &str, path: &OsStr) -> Self {
        AttributeError::MissingValue {
            attribute: attr.into(),
            path: path.into(),
        }
    }
}

/// States attributes may be in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttributeState {
    /// The attribute is neither set nor unset.
    Unspecified,
    /// The attribute is set.
    Set,
    /// The attribute is unset.
    Unset,
    /// The attribute is set with the given value.
    Value(String),
}

/// Git context for use in checks.
#[derive(Debug)]
pub struct CheckGitContext {
    /// The workarea for the check.
    workarea: GitWorkArea,
    /// The owner of the topic.
    topic_owner: Identity,
    /// Configuration lookup callback.
    configuration: BTreeMap<String, String>,
}

impl CheckGitContext {
    /// Create a new git context for checking a commit.
    pub fn new(workarea: GitWorkArea, topic_owner: Identity) -> Self {
        Self {
            workarea,
            topic_owner,
            configuration: BTreeMap::new(),
        }
    }

    /// Add a configuration value for use by checks.
    ///
    /// Any existing configuration value is overwritten.
    pub fn add_configuration<K, V>(&mut self, key: K, value: V) -> &mut Self
    where
        K: Into<String>,
        V: Into<String>,
    {
        self.configuration.insert(key.into(), value.into());

        self
    }

    /// Add a sequence of configuration values for use by checks.
    ///
    /// Any existing configuration values are overwritten.
    pub fn add_configurations<I, K, V>(&mut self, iter: I) -> &mut Self
    where
        I: IntoIterator<Item = (K, V)>,
        K: Into<String>,
        V: Into<String>,
    {
        self.configuration
            .extend(iter.into_iter().map(|(k, v)| (k.into(), v.into())));

        self
    }

    /// Create a git command for use in checks.
    pub fn git(&self) -> Command {
        self.workarea.git()
    }

    /// The publisher of the branch.
    pub fn topic_owner(&self) -> &Identity {
        &self.topic_owner
    }

    /// Query for a configuration value.
    pub fn configuration(&self, key: &str) -> Option<&str> {
        self.configuration.get(key).map(|v| v.as_str())
    }

    /// Check an attribute of the given path.
    fn check_attr_impl(&self, attr: &str, path: &OsStr) -> Result<AttributeState, AttributeError> {
        let check_attr = self
            .workarea
            .git()
            .arg("--literal-pathspecs")
            .arg("check-attr")
            .arg(attr)
            .arg("--")
            .arg(path)
            .output()
            .map_err(|err| GitError::subcommand("check-attr", err))?;
        if !check_attr.status.success() {
            return Err(AttributeError::check_attr(attr, path, &check_attr.stderr));
        }
        let attr_line = String::from_utf8_lossy(&check_attr.stdout);

        // So the output format here is ambiguous. The `gitattributes(5)` format does not support
        // spaces in the values of attributes, so split on whitespace and take the last element.
        let attr_value = attr_line
            .split_whitespace()
            .last()
            .ok_or_else(|| AttributeError::missing_value(attr, path))?;
        if attr_value == "set" {
            Ok(AttributeState::Set)
        } else if attr_value == "unset" {
            Ok(AttributeState::Unset)
        } else if attr_value == "unspecified" {
            Ok(AttributeState::Unspecified)
        } else {
            // Attribute values which match one of the above are ambiguous. `git-check-attr(1)`
            // states that is ambiguous and leaves it at that.
            Ok(AttributeState::Value(attr_value.to_owned()))
        }
    }

    /// Check an attribute of the given path.
    pub fn check_attr<A, P>(&self, attr: A, path: P) -> Result<AttributeState, AttributeError>
    where
        A: AsRef<str>,
        P: AsRef<OsStr>,
    {
        self.check_attr_impl(attr.as_ref(), path.as_ref())
    }

    /// The workarea used for check operations.
    pub fn workarea(&self) -> &GitWorkArea {
        &self.workarea
    }

    /// The workarea used for check operations.
    pub fn workarea_mut(&mut self) -> &mut GitWorkArea {
        &mut self.workarea
    }

    /// The path to the git repository.
    pub fn gitdir(&self) -> &Path {
        self.workarea.gitdir()
    }

    /// The submodule configuration for the repository.
    pub fn submodule_config(&self) -> &SubmoduleConfig {
        self.workarea.submodule_config()
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use git_workarea::{CommitId, GitContext, Identity};

    use crate::context::*;

    fn make_context() -> GitContext {
        let gitdir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.git"));
        if !gitdir.exists() {
            panic!("The tests must be run from a git checkout.");
        }

        GitContext::new(gitdir)
    }

    #[test]
    fn test_commit_attrs() {
        let ctx = make_context();

        // A commit with attributes set on some paths.
        let sha1 = "85b9551a672a34e1926d5010a9c9075eda0a6107";
        let prep_ctx = ctx.prepare(&CommitId::new(sha1)).unwrap();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let check_ctx = CheckGitContext::new(prep_ctx, ben);

        assert_eq!(
            check_ctx.check_attr("foo", "file1").unwrap(),
            AttributeState::Value("bar".to_owned()),
        );
        assert_eq!(
            check_ctx.check_attr("attr_set", "file1").unwrap(),
            AttributeState::Set,
        );
        assert_eq!(
            check_ctx.check_attr("attr_unset", "file1").unwrap(),
            AttributeState::Unspecified,
        );
        assert_eq!(
            check_ctx.check_attr("text", "file1").unwrap(),
            AttributeState::Unspecified,
        );

        assert_eq!(
            check_ctx.check_attr("foo", "file2").unwrap(),
            AttributeState::Unspecified,
        );
        assert_eq!(
            check_ctx.check_attr("attr_set", "file2").unwrap(),
            AttributeState::Set,
        );
        assert_eq!(
            check_ctx.check_attr("attr_unset", "file2").unwrap(),
            AttributeState::Unset,
        );
        assert_eq!(
            check_ctx.check_attr("text", "file2").unwrap(),
            AttributeState::Unspecified,
        );

        assert_eq!(
            check_ctx.check_attr("foo", "file3").unwrap(),
            AttributeState::Unspecified,
        );
        assert_eq!(
            check_ctx.check_attr("attr_set", "file3").unwrap(),
            AttributeState::Unspecified,
        );
        assert_eq!(
            check_ctx.check_attr("attr_unset", "file3").unwrap(),
            AttributeState::Unspecified,
        );
        assert_eq!(
            check_ctx.check_attr("text", "file3").unwrap(),
            AttributeState::Value("yes".to_owned()),
        );
    }

    #[test]
    fn test_commit_attrs_literal_pathspecs() {
        let ctx = make_context();

        // A commit with attributes set on some paths.
        let sha1 = "9055e6f31ee5e7de8cdce0ca57452c38f433fd89";
        let prep_ctx = ctx.prepare(&CommitId::new(sha1)).unwrap();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let check_ctx = CheckGitContext::new(prep_ctx, ben);

        assert_eq!(
            check_ctx.check_attr("custom-attr", "foo.attr").unwrap(),
            AttributeState::Set,
        );
        assert_eq!(
            check_ctx.check_attr("custom-attr", "*.attr").unwrap(),
            AttributeState::Set,
        );
    }

    #[test]
    fn test_commit_attrs_no_path() {
        let ctx = make_context();

        // A commit with attributes set on some paths.
        let sha1 = "9055e6f31ee5e7de8cdce0ca57452c38f433fd89";
        let prep_ctx = ctx.prepare(&CommitId::new(sha1)).unwrap();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let check_ctx = CheckGitContext::new(prep_ctx, ben);

        assert_eq!(
            check_ctx.check_attr("noexist", "noattr").unwrap(),
            AttributeState::Unspecified,
        );
    }

    #[test]
    fn test_context_apis() {
        let ctx = make_context();

        // A commit with attributes set on some paths.
        let sha1 = "9055e6f31ee5e7de8cdce0ca57452c38f433fd89";
        let prep_ctx = ctx.prepare(&CommitId::new(sha1)).unwrap();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let check_ctx = CheckGitContext::new(prep_ctx, ben);
        let _ = check_ctx.workarea();

        let mut check_ctx = check_ctx;
        let _ = check_ctx.workarea_mut();
    }

    #[test]
    fn test_context_configuration_overwrite() {
        let ctx = make_context();

        // A commit with attributes set on some paths.
        let sha1 = "9055e6f31ee5e7de8cdce0ca57452c38f433fd89";
        let prep_ctx = ctx.prepare(&CommitId::new(sha1)).unwrap();

        let ben = Identity::new("Ben Boeckel", "ben.boeckel@kitware.com");
        let check_ctx = {
            let mut check_ctx = CheckGitContext::new(prep_ctx, ben);
            check_ctx
                .add_configuration("key1", "value1")
                .add_configuration("key2", "value2")
                .add_configuration("key3", "value3")
                .add_configuration("key1", "value1_overwrite")
                .add_configurations([("key2", "value2_overwrite"), ("key4", "value4")]);
            check_ctx
        };

        let expect = [
            ("key1", Some("value1_overwrite")),
            ("key2", Some("value2_overwrite")),
            ("key3", Some("value3")),
            ("key4", Some("value4")),
            ("key5", None),
        ];

        for (k, v) in expect {
            assert_eq!(check_ctx.configuration(k), v);
        }
        assert_eq!(check_ctx.configuration.len(), 4);
    }
}