creditlint 0.1.2

CLI for enforcing Git credit and authorship metadata policy
Documentation
use std::fs;
use std::io::{self, Read};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;

use clap::{ArgGroup, Args, Parser, Subcommand};
use thiserror::Error;

use crate::config::{
    ConfigError, default_config_contents, init_config_path_from_current_dir,
    load_policy_from_current_dir,
};
use crate::git::{
    CommitRecord, GitError, collect_all_messages, collect_range_messages, commit_msg_hook_path,
};
use crate::github::{RulesetExportError, export_ruleset_pattern};
use crate::policy::{AnalysisError, Identity, IdentityRole, Policy, Source, SourceKind, Violation};
use crate::reporter::{OutputFormat, render_violations};

#[derive(Debug, Parser)]
#[command(name = "creditlint")]
#[command(about = "CLI for enforcing Git credit and authorship metadata policy")]
pub struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    Check(CheckArgs),
    Audit(AuditArgs),
    Init,
    InstallHook,
    Github(GithubArgs),
}

#[derive(Debug, Args)]
#[command(group(
    ArgGroup::new("input")
        .required(true)
        .args(["message_file", "stdin", "range"])
))]
struct CheckArgs {
    #[arg(long)]
    message_file: Option<PathBuf>,
    #[arg(long)]
    stdin: bool,
    #[arg(long)]
    range: Option<String>,
    #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
    format: OutputFormat,
}

#[derive(Debug, Args)]
struct AuditArgs {
    #[arg(long)]
    all: bool,
    #[arg(long, value_enum, default_value_t = OutputFormat::Human)]
    format: OutputFormat,
}

#[derive(Debug, Args)]
struct GithubArgs {
    #[command(subcommand)]
    command: GithubCommands,
}

#[derive(Debug, Subcommand)]
enum GithubCommands {
    RulesetPattern,
}

pub fn run() -> Result<(), CliError> {
    let cli = Cli::parse();

    match cli.command {
        Commands::Check(args) => run_check(args),
        Commands::Audit(args) => run_audit(args),
        Commands::Init => run_init(),
        Commands::InstallHook => run_install_hook(),
        Commands::Github(args) => run_github(args),
    }
}

fn run_github(args: GithubArgs) -> Result<(), CliError> {
    match args.command {
        GithubCommands::RulesetPattern => run_github_ruleset_pattern(),
    }
}

fn run_github_ruleset_pattern() -> Result<(), CliError> {
    let policy = load_policy_from_current_dir().map_err(CliError::Config)?;
    let pattern = export_ruleset_pattern(&policy).map_err(CliError::RulesetExport)?;
    println!("{pattern}");
    Ok(())
}

const MANAGED_HOOK_MARKER: &str = "creditlint managed hook";
const MANAGED_HOOK_VERSION: &str = "version: 1";
const COMMIT_MSG_HOOK_CONTENTS: &str = r#"#!/bin/sh
# creditlint managed hook
# version: 1

creditlint check --message-file "$1"
"#;

fn run_init() -> Result<(), CliError> {
    let path = init_config_path_from_current_dir().map_err(CliError::Config)?;

    if path.exists() {
        return Err(CliError::ConfigAlreadyExists { path });
    }

    fs::write(&path, default_config_contents()).map_err(|source| CliError::WriteConfig {
        path: path.clone(),
        source,
    })?;

    println!("created {}", path.display());
    Ok(())
}

fn run_install_hook() -> Result<(), CliError> {
    let path = commit_msg_hook_path().map_err(CliError::Git)?;

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|source| CliError::WriteHook {
            path: parent.to_path_buf(),
            source,
        })?;
    }

    if path.exists() {
        let existing = fs::read_to_string(&path).map_err(|source| CliError::ReadHook {
            path: path.clone(),
            source,
        })?;

        if !is_managed_hook(&existing) {
            return Err(CliError::UnmanagedHookExists { path });
        }
    }

    fs::write(&path, COMMIT_MSG_HOOK_CONTENTS).map_err(|source| CliError::WriteHook {
        path: path.clone(),
        source,
    })?;
    set_hook_permissions(&path).map_err(|source| CliError::WriteHook {
        path: path.clone(),
        source,
    })?;

    println!("installed {}", path.display());
    Ok(())
}

fn is_managed_hook(contents: &str) -> bool {
    contents.contains(MANAGED_HOOK_MARKER) && contents.contains(MANAGED_HOOK_VERSION)
}

#[cfg(unix)]
fn set_hook_permissions(path: &PathBuf) -> Result<(), std::io::Error> {
    let mut permissions = fs::metadata(path)?.permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions)
}

#[cfg(not(unix))]
fn set_hook_permissions(_path: &PathBuf) -> Result<(), std::io::Error> {
    Ok(())
}

fn run_check(args: CheckArgs) -> Result<(), CliError> {
    let policy = load_policy_from_current_dir().map_err(CliError::Config)?;
    let format = args.format;
    let violations = match (args.message_file, args.stdin, args.range) {
        (Some(path), false, None) => {
            let content = fs::read_to_string(&path).map_err(|source| CliError::ReadMessage {
                path: path.clone(),
                source,
            })?;
            let source = Source {
                kind: SourceKind::MessageFile,
                path: Some(path),
                commit_sha: None,
            };
            policy
                .analyze(source, &content)
                .map_err(CliError::AnalyzeMessage)?
        }
        (None, true, None) => {
            let mut content = String::new();
            io::stdin()
                .read_to_string(&mut content)
                .map_err(CliError::ReadStdin)?;
            let source = Source {
                kind: SourceKind::Stdin,
                path: None,
                commit_sha: None,
            };
            policy
                .analyze(source, &content)
                .map_err(CliError::AnalyzeMessage)?
        }
        (None, false, Some(range)) => collect_range_messages(&range)
            .map_err(CliError::Git)?
            .into_iter()
            .map(|commit| analyze_commit(&policy, SourceKind::Commit, commit))
            .collect::<Result<Vec<_>, _>>()
            .map_err(CliError::AnalyzeMessage)?
            .into_iter()
            .flatten()
            .collect(),
        _ => return Err(CliError::InvalidInputSelection),
    };

    if violations.is_empty() {
        if format == OutputFormat::Json {
            let output = render_violations(format, &violations).map_err(CliError::RenderReport)?;
            println!("{output}");
        }
        return Ok(());
    }

    let output = render_violations(format, &violations).map_err(CliError::RenderReport)?;
    println!("{output}");
    Err(CliError::PolicyViolation)
}

fn run_audit(args: AuditArgs) -> Result<(), CliError> {
    if !args.all {
        return Err(CliError::InvalidInputSelection);
    }

    let policy = load_policy_from_current_dir().map_err(CliError::Config)?;
    let violations = collect_all_messages()
        .map_err(CliError::Git)?
        .into_iter()
        .map(|commit| analyze_commit(&policy, SourceKind::Audit, commit))
        .collect::<Result<Vec<_>, _>>()
        .map_err(CliError::AnalyzeMessage)?
        .into_iter()
        .flatten()
        .collect::<Vec<_>>();

    if violations.is_empty() {
        if args.format == OutputFormat::Json {
            let output =
                render_violations(args.format, &violations).map_err(CliError::RenderReport)?;
            println!("{output}");
        }
        return Ok(());
    }

    let output = render_violations(args.format, &violations).map_err(CliError::RenderReport)?;
    println!("{output}");
    Err(CliError::PolicyViolation)
}

fn analyze_commit(
    policy: &Policy,
    kind: SourceKind,
    commit: CommitRecord,
) -> Result<Vec<Violation>, AnalysisError> {
    let source = Source {
        kind,
        path: None,
        commit_sha: Some(commit.sha),
    };

    let mut violations = Vec::new();
    violations.extend(policy.analyze_identity(
        source.clone(),
        &Identity {
            role: IdentityRole::Author,
            name: commit.author_name,
            email: commit.author_email,
        },
    )?);
    violations.extend(policy.analyze_identity(
        source.clone(),
        &Identity {
            role: IdentityRole::Committer,
            name: commit.committer_name,
            email: commit.committer_email,
        },
    )?);
    violations.extend(policy.analyze(source, &commit.message)?);

    Ok(violations)
}

#[derive(Debug, Error)]
pub enum CliError {
    #[error("policy violations found")]
    PolicyViolation,
    #[error("exactly one input source must be selected")]
    InvalidInputSelection,
    #[error("failed to load policy")]
    Config(#[source] ConfigError),
    #[error("failed to read message file at {path}")]
    ReadMessage {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to read message text from stdin")]
    ReadStdin(#[source] std::io::Error),
    #[error("failed to analyze message")]
    AnalyzeMessage(#[source] AnalysisError),
    #[error("failed to collect commit messages from git")]
    Git(#[source] GitError),
    #[error("failed to render output")]
    RenderReport(#[source] serde_json::Error),
    #[error("config file already exists at {path}")]
    ConfigAlreadyExists { path: PathBuf },
    #[error("failed to write config file at {path}")]
    WriteConfig {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to read existing hook at {path}")]
    ReadHook {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error(
        "existing unmanaged commit-msg hook found at {path}; integrate creditlint manually or remove it first"
    )]
    UnmanagedHookExists { path: PathBuf },
    #[error("failed to install commit-msg hook at {path}")]
    WriteHook {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },
    #[error("failed to export GitHub ruleset pattern: {0}")]
    RulesetExport(#[source] RulesetExportError),
}

impl CliError {
    pub fn exit_code(&self) -> i32 {
        match self {
            CliError::PolicyViolation => 1,
            CliError::InvalidInputSelection
            | CliError::Config(_)
            | CliError::ReadMessage { .. }
            | CliError::ReadStdin(_)
            | CliError::AnalyzeMessage(_)
            | CliError::Git(_)
            | CliError::RenderReport(_)
            | CliError::ConfigAlreadyExists { .. }
            | CliError::WriteConfig { .. }
            | CliError::ReadHook { .. }
            | CliError::UnmanagedHookExists { .. }
            | CliError::WriteHook { .. }
            | CliError::RulesetExport(_) => 2,
        }
    }
}