pmat 3.30.1

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP)
Documentation
//! Hook content generation: templates, environment variables, and quality checks

#![cfg_attr(coverage_nightly, coverage(off))]

use super::hooks_command::HooksCommand;
use crate::services::configuration_service::{configuration, PmatConfig};
use anyhow::Result;
// chrono::Local removed — CB-1335 prohibits timestamps in generated hooks
use std::fs;
use std::path::Path;

impl HooksCommand {
    /// Normalize hook content by removing timestamp line for comparison
    ///
    /// # Complexity
    /// - Time: O(n) where n is content length
    /// - Cyclomatic: 3
    pub(super) fn normalize_hook_content(content: &str) -> String {
        content
            .lines()
            .filter(|line| !line.contains("# Generated at:"))
            .collect::<Vec<_>>()
            .join("\n")
    }

    /// Check if a hook is PMAT-managed
    pub(super) fn is_pmat_managed(&self, hook_path: &Path) -> Result<bool> {
        if !hook_path.exists() {
            return Ok(false);
        }

        let content = fs::read_to_string(hook_path)?;
        Ok(content.contains("auto-managed by PMAT") && content.contains("DO NOT EDIT"))
    }

    /// Generate hook content from template and configuration
    pub(super) async fn generate_hook_content(&self) -> Result<String> {
        let config_service = configuration();
        let config = config_service.get_config()?;

        let header = self.generate_hook_header();
        let env_vars = self.generate_env_vars(&config);
        let checks = self.generate_quality_checks();

        let hook_content = format!("{header}\n{env_vars}\n{checks}");
        Ok(hook_content)
    }

    /// Generate hook header section
    ///
    /// NOTE: No timestamp — CB-1335 requires deterministic output.
    /// Two consecutive `pmat hooks install` runs must produce identical files.
    #[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
    pub(crate) fn generate_hook_header(&self) -> String {
        r#"#!/bin/bash
# Generated pre-commit hook (auto-managed by PMAT)
# DO NOT EDIT: This file is automatically generated

set -e

echo "🔍 PMAT Pre-commit Quality Gates"
echo "================================"
"#
        .to_string()
    }

    /// Generate environment variables section
    ///
    /// GH-301: `PMAT_PRECOMMIT_EXCLUDE_DIRS` is an escape hatch for directory patterns
    /// that must never be scanned by the hook. Space-separated path prefixes. The
    /// hook defaults cover Claude/Cursor ephemeral worktrees and common build/cache
    /// directories so agent sessions don't balloon pre-commit time.
    fn generate_env_vars(&self, config: &PmatConfig) -> String {
        format!(
            r#"# Load current configuration dynamically
export PMAT_MAX_CYCLOMATIC_COMPLEXITY={}
export PMAT_MAX_COGNITIVE_COMPLEXITY={}
export PMAT_MIN_TEST_COVERAGE={}
export PMAT_MAX_SATD_COMMENTS=5
export PMAT_TASK_ID_PATTERN="PMAT-[0-9]{{4}}"
# GH-301: directories excluded from any hook filesystem scan.
# Override with: PMAT_PRECOMMIT_EXCLUDE_DIRS=".claude/worktrees target ..." git commit
export PMAT_PRECOMMIT_EXCLUDE_DIRS="${{PMAT_PRECOMMIT_EXCLUDE_DIRS:-.claude/worktrees .cursor/worktrees target node_modules .venv}}"
"#,
            config.quality.max_complexity,
            config.quality.max_cognitive_complexity,
            config.quality.min_coverage as u32
        )
    }

    /// Generate the cargo fmt --check gate for Rust projects
    fn generate_format_check() -> &'static str {
        r#"# 0. Format check (Rust only — cargo fmt --check on staged .rs files)
STAGED_RS=$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs' 2>/dev/null)
if [ -n "$STAGED_RS" ] && command -v cargo &> /dev/null && [ -f Cargo.toml ]; then
    echo -n "  Format check... "
    # --all matches the pre-push gate. Without it only the current package is
    # checked, so an unformatted file in a workspace MEMBER passes pre-commit
    # and then fails pre-push -- two gates disagreeing about the same repo.
    FMT_OUTPUT=$(cargo fmt --all -- --check 2>&1)
    if [ $? -eq 0 ]; then
        echo "✅"
    else
        echo "❌"
        echo "   Unformatted Rust code detected. Run 'cargo fmt' before committing."
        echo "$FMT_OUTPUT" | grep "^Diff in" | head -5
        exit 1
    fi
fi
"#
    }

    /// Generate quality check sections
    ///
    /// The generated hook is project-type aware:
    /// - Non-code repos (no source files at all) get a fast pass
    /// - Mixed repos only check staged source files
    /// - SATD and docs checks only run when source files exist in the repo
    #[provable_contracts_macros::contract("pmat-core.yaml", equation = "check_compliance")]
    pub(crate) fn generate_quality_checks(&self) -> String {
        let mut hook = String::from(
            r#"# Check if pmat is available
if ! command -v pmat &> /dev/null; then
    echo "⚠️  Warning: pmat not found in PATH"
    echo "   Install with: cargo install pmat"
    exit 0  # Allow commit but warn
fi

echo "📊 Running quality gate checks..."

# GH-301: helper that strips PMAT_PRECOMMIT_EXCLUDE_DIRS prefixes from a stream
# of git-tracked paths. Never call `find` here — `find` traverses .gitignore'd
# paths (like .claude/worktrees/*) which blows up pre-commit time on monorepos
# with many agent worktrees and can even invoke nested Makefiles.
_pmat_filter_excluded() {
    if [ -z "$PMAT_PRECOMMIT_EXCLUDE_DIRS" ]; then cat; return; fi
    awk -v excl="$PMAT_PRECOMMIT_EXCLUDE_DIRS" '
        BEGIN { n = split(excl, a, " ") }
        {
            skip = 0
            for (i = 1; i <= n; i++) {
                if (a[i] != "" && index($0, a[i] "/") == 1) { skip = 1; break }
            }
            if (!skip) print
        }
    '
}

# Detect if this repo has any source files at all (not just staged ones).
# Non-code repos (docs, configs, YAML, contracts) get a fast pass.
# Uses `git ls-files` (GH-301) so .gitignore'd directories — including
# .claude/worktrees/, target/, node_modules/ — are never scanned. Outside a
# git worktree we fall back to a bounded find with explicit prunes.
if git rev-parse --git-dir > /dev/null 2>&1; then
    HAS_SOURCE_FILES=$(git ls-files -- \
        '*.rs' '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' \
        '*.go' '*.c' '*.cpp' '*.lua' '*.php' '*.swift' 2>/dev/null \
        | _pmat_filter_excluded | head -n 1)
else
    HAS_SOURCE_FILES=$(find . -maxdepth 4 -type f \( -name '*.rs' -o -name '*.py' -o -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' -o -name '*.go' -o -name '*.c' -o -name '*.cpp' -o -name '*.lua' -o -name '*.php' -o -name '*.swift' \) -not -path './.git/*' -not -path '*/target/*' -not -path '*/node_modules/*' -not -path '*/.claude/worktrees/*' -not -path '*/.cursor/worktrees/*' -print -quit 2>/dev/null)
fi

if [ -z "$HAS_SOURCE_FILES" ]; then
    echo "  Project type: non-code (docs/configs/YAML)"
    echo "  Complexity check... ⏭️  (no source files in repo)"
    echo "  SATD check... ⏭️  (no source files in repo)"
    echo ""
    echo "✅ All quality gates passed!"
    echo ""
    exit 0
fi

"#,
        );
        hook.push_str(Self::generate_format_check());
        hook.push_str(r#"
# 1. Complexity analysis (only staged source files, not entire project)
# Supports: Rust, Python, TypeScript, JavaScript, Go, C, C++, Lua, PHP, Swift
STAGED_SRC=$(git diff --cached --name-only --diff-filter=ACMR -- '*.rs' '*.py' '*.ts' '*.tsx' '*.js' '*.jsx' '*.go' '*.c' '*.cpp' '*.lua' '*.php' '*.swift' 2>/dev/null | head -20)
if [ -n "$STAGED_SRC" ]; then
    echo -n "  Complexity check... "
    COMPLEXITY_FAILED=0
    COMPLEXITY_DETAILS=""
    for SRC_FILE in $STAGED_SRC; do
        if [ -f "$SRC_FILE" ]; then
            # ANSI codes MUST be stripped before matching. `pmat analyze
            # complexity` colourises its summary, so the violation line is
            # literally  ESC[1;31mErrors:ESC[0m 2  -- there is no ": " sequence
            # in it, and the previous pattern 'Errors.*: [1-9]' therefore NEVER
            # matched. The gate silently passed every violation: a file
            # measured at Cyclomatic 81 / Cognitive 120 against thresholds of
            # 30/25 committed cleanly. Verified by hand:
            #   raw      ^[[1;31mErrors:^[[0m 2   -> 'Errors.*: [1-9]'  NO MATCH
            #   stripped Errors: 2                -> 'Errors: *[1-9]'   MATCH
            # NO_COLOR is set as well so the sed is belt-and-braces rather than
            # the only line of defence.
            FILE_OUTPUT=$(NO_COLOR=1 pmat analyze complexity --file "$SRC_FILE" --max-cyclomatic $PMAT_MAX_CYCLOMATIC_COMPLEXITY --max-cognitive $PMAT_MAX_COGNITIVE_COMPLEXITY 2>&1 | sed "s/$(printf '\\033')\[[0-9;]*m//g")
            if echo "$FILE_OUTPUT" | grep -qE 'Errors: *[1-9]'; then
                COMPLEXITY_FAILED=1
                # Extract the offending file and functions
                COMPLEXITY_DETAILS="${COMPLEXITY_DETAILS}  ${SRC_FILE}:"$'\n'
                FILE_VIOLATIONS=$(echo "$FILE_OUTPUT" | grep -E '^[0-9]+\. ' | grep -E 'Cyclomatic|Cognitive' | head -3)
                COMPLEXITY_DETAILS="${COMPLEXITY_DETAILS}${FILE_VIOLATIONS}"$'\n'
            fi
        fi
    done
    if [ "$COMPLEXITY_FAILED" -eq 0 ]; then
        echo "✅"
    else
        echo "❌"
        echo "Issues Found:"
        echo "$COMPLEXITY_DETAILS" | head -10
        echo "   Complexity exceeds thresholds (Cyclomatic: $PMAT_MAX_CYCLOMATIC_COMPLEXITY, Cognitive: $PMAT_MAX_COGNITIVE_COMPLEXITY)"
        exit 1
    fi
else
    echo "  Complexity check... ⏭️  (no source files staged)"
fi

# 2. SATD (Self-Admitted Quality Issues) check - informational only
echo -n "  SATD check... "
# `pmat analyze satd` prints "Total violations:  N". An older pattern looked
# for a phrase pmat never emitted, so the grep matched nothing and the
# `|| echo 0` fallback hardcoded the count to zero. Verified: a file with 3
# SATD markers reported "(0 SATD comments)".
# sed rather than a PCRE grep: PCRE support is GNU-only, and this hook also
# runs on macOS (BSD grep) where it fails and would silently restore the zero.
SATD_OUTPUT=$(pmat analyze satd 2>&1 | sed "s/$(printf '\033')\[[0-9;]*m//g")
SATD_COUNT=$(printf '%s' "$SATD_OUTPUT" | sed -n 's/.*Total violations:[[:space:]]*\([0-9][0-9]*\).*/\1/p' | head -1)
[ -n "$SATD_COUNT" ] || SATD_COUNT=0
if [ "$SATD_COUNT" -le "$PMAT_MAX_SATD_COMMENTS" ] 2>/dev/null; then
    echo "✅ ($SATD_COUNT SATD comments)"
else
    echo "⚠️  ($SATD_COUNT SATD comments, threshold: $PMAT_MAX_SATD_COMMENTS)"
fi

# 3. Documentation synchronization (only if docs structure exists)
if [ -d "docs/execution" ] || [ -f "CHANGELOG.md" ]; then
    echo -n "  Documentation check... "
    if [ -f "docs/execution/roadmap.md" ] && [ -f "CHANGELOG.md" ]; then
        echo "✅"
    else
        echo "⚠️  (docs/execution/roadmap.md or CHANGELOG.md missing)"
    fi
fi

# 4. Task ID validation (if commit message available)
if [ -n "$1" ]; then
    echo -n "  Task ID check... "
    if echo "$1" | grep -qE "$PMAT_TASK_ID_PATTERN"; then
        echo "✅"
    else
        echo "⚠️"
        echo "   Warning: Commit message should contain task ID matching $PMAT_TASK_ID_PATTERN"
    fi
fi

echo ""
echo "✅ All quality gates passed!"
echo ""

# Success
exit 0
"#);
        hook
    }
}

#[cfg(test)]
mod complexity_gate_tests {
    use super::*;
    use std::path::PathBuf;

    /// The generated hook must strip ANSI before matching the violation line.
    ///
    /// Regression for a silent-pass bug: `pmat analyze complexity` colourises
    /// its summary, so the violation line is literally
    /// `ESC[1;31mErrors:ESC[0m 2`. The old pattern `Errors.*: [1-9]` requires a
    /// literal ": " which that byte sequence does not contain, so the gate
    /// matched nothing and passed EVERY violation -- verified live: a file
    /// measuring Cyclomatic 81 / Cognitive 120 against 30/25 thresholds
    /// committed cleanly through the hook.
    #[test]
    fn complexity_gate_strips_ansi_before_matching() {
        let cmd = HooksCommand::new(PathBuf::from("/tmp"), PathBuf::from("/tmp"));
        let hook = cmd.generate_quality_checks();
        assert!(
            hook.contains("[0-9;]*m//g"),
            "generated hook must pipe output through sed to strip ANSI codes"
        );
        assert!(
            hook.contains("grep -qE 'Errors: *[1-9]'"),
            "generated hook must use the ANSI-stripped pattern"
        );
        assert!(
            !hook.contains("grep -q 'Errors.*: [1-9]'"),
            "the ANSI-blind pattern must not come back: it never matches \
             colourised output and silently passes all violations"
        );
    }

    /// SATD counting must use the phrase pmat ACTUALLY prints.
    ///
    /// Regression: the hook grepped for "Total SATD comments found: N", which
    /// pmat has never emitted. The real line is "Total violations:  N". The
    /// `|| echo 0` fallback then hardcoded the count to zero, so a file with 3
    /// SATD markers reported "✅ (0 SATD comments)".
    #[test]
    fn satd_gate_matches_the_phrase_pmat_actually_prints() {
        let cmd = HooksCommand::new(PathBuf::from("/tmp"), PathBuf::from("/tmp"));
        let hook = cmd.generate_quality_checks();
        assert!(
            !hook.contains("Total SATD comments found:"),
            "pmat never prints this phrase; matching it hardcodes SATD_COUNT to 0"
        );
        assert!(
            hook.contains("Total violations:"),
            "SATD gate must match pmat's real output line"
        );
        assert!(
            !hook.contains("grep -oP"),
            "grep -P is GNU-only; this hook also runs on macOS (BSD grep) where \
             it fails and silently re-introduces a zero count"
        );
    }

    /// Both gates must agree on formatting scope.
    ///
    /// pre-commit used `cargo fmt -- --check` (current package only) while
    /// pre-push uses `cargo fmt --all -- --check`. An unformatted file in a
    /// workspace member therefore passed pre-commit and failed pre-push.
    #[test]
    fn format_gate_uses_workspace_scope() {
        let cmd = HooksCommand::new(PathBuf::from("/tmp"), PathBuf::from("/tmp"));
        let hook = cmd.generate_quality_checks();
        assert!(
            hook.contains("cargo fmt --all -- --check"),
            "pre-commit format scope must match pre-push (--all)"
        );
    }

    /// Asserts the underlying reason directly, independent of the shell:
    /// the raw colourised line contains no literal "Errors: ".
    #[test]
    fn colourised_errors_line_has_no_literal_colon_space() {
        let colourised = "\u{1b}[1;31mErrors:\u{1b}[0m 2";
        assert!(
            !colourised.contains("Errors: "),
            "colourised output has no literal 'Errors: ' -- this is exactly \
             why the old grep never fired"
        );

        // Mirror the sed in the hook, then confirm the shipped pattern matches.
        let mut stripped = String::new();
        let mut chars = colourised.chars().peekable();
        while let Some(c) = chars.next() {
            if c == '\u{1b}' {
                for n in chars.by_ref() {
                    if n == 'm' {
                        break;
                    }
                }
            } else {
                stripped.push(c);
            }
        }
        assert_eq!(stripped, "Errors: 2");
    }
}