klasp 0.4.0

Block AI coding agents on the same quality gates your humans hit. See https://github.com/klasp-dev/klasp
Documentation
//! Detector for the [pre-commit framework](https://pre-commit.com/).
//!
//! Looks for `.pre-commit-config.yaml` or `.pre-commit-config.yml` at the
//! repo root. Prefers the `.yaml` spelling when both are present (matches
//! pre-commit's own discovery priority).
//!
//! # Chaining policy
//!
//! In v1 we never auto-edit `.pre-commit-config.yaml`. Chaining is therefore
//! [`ChainSupport::ManualOnly`] with instructions pointing the user at the
//! klasp gate runtime.
//!
//! # Duplicate-execution warning
//!
//! If `.git/hooks/pre-commit` exists and contains the marker string
//! `# Generated by pre-commit`, both pre-commit's own hook shim and klasp's
//! `pre_commit` recipe would fire at commit time. We emit a warning so the
//! user can choose one or the other.
//!
//! See klasp-dev/klasp#97.

use std::io;
use std::path::Path;

use crate::adopt::detect::first_existing_file;
use crate::adopt::plan::{
    ChainSupport, DetectedGate, GateType, ProposedCheck, ProposedCheckSource, TriggerKind,
};

/// Marker substring written by `pre-commit install` into `.git/hooks/pre-commit`.
const PRE_COMMIT_GENERATED_MARKER: &str = "# Generated by pre-commit";

/// Preferred config filename (pre-commit's own default).
const CONFIG_YAML: &str = ".pre-commit-config.yaml";

/// Alternate config filename accepted by pre-commit.
const CONFIG_YML: &str = ".pre-commit-config.yml";

/// Detect the pre-commit framework at `repo_root`.
///
/// Returns a single [`DetectedGate`] with [`GateType::PreCommitFramework`]
/// when a config file is found, or an empty `Vec` when neither config file
/// exists.
///
/// # Errors
///
/// Returns `Err` only for unexpected I/O failures (e.g. permission denied
/// reading the `.git/hooks/pre-commit` file). Absence of config files is
/// not an error.
pub fn detect(repo_root: &Path) -> io::Result<Vec<DetectedGate>> {
    let config_path = match first_existing_file(repo_root, &[CONFIG_YAML, CONFIG_YML]) {
        Some(p) => p,
        None => return Ok(vec![]),
    };

    let warnings = build_warnings(repo_root)?;

    let gate = DetectedGate {
        gate_type: GateType::PreCommitFramework,
        source_path: config_path,
        proposed_checks: vec![proposed_check()],
        chain_support: ChainSupport::ManualOnly,
        manual_chain_instructions: Some(manual_instructions()),
        warnings,
    };

    Ok(vec![gate])
}

/// Build the list of duplicate-execution warnings for this detection pass.
fn build_warnings(repo_root: &Path) -> io::Result<Vec<String>> {
    let hook_path = repo_root.join(".git/hooks/pre-commit");
    let contents = match std::fs::read_to_string(&hook_path) {
        Ok(s) => s,
        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]),
        Err(e) => return Err(e),
    };

    if contents.contains(PRE_COMMIT_GENERATED_MARKER) {
        return Ok(vec![
            "running both pre-commit's git hook AND klasp's `pre_commit` recipe will execute \
             hooks twice; choose one: either keep pre-commit's hook as the single source of truth \
             and remove the `pre_commit` check from klasp.toml, or uninstall pre-commit's shim \
             (`pre-commit uninstall`) and let klasp own the git hook."
                .to_string(),
        ]);
    }

    Ok(vec![])
}

/// The proposed `klasp.toml` check that mirrors the pre-commit framework.
fn proposed_check() -> ProposedCheck {
    ProposedCheck {
        name: "pre-commit".to_string(),
        triggers: vec![TriggerKind::Commit],
        timeout_secs: 120,
        source: ProposedCheckSource::PreCommit {
            hook_stage: None,
            config_path: None,
        },
    }
}

/// Manual chaining instructions for users who want klasp and pre-commit to
/// coexist in the same commit flow.
fn manual_instructions() -> String {
    "To run klasp inside pre-commit's flow: add a local hook in \
     `.pre-commit-config.yaml` that calls `klasp gate`. To run pre-commit \
     inside klasp: the proposed `type = \"pre_commit\"` check already does this. \
     Do not enable both simultaneously or hooks will fire twice."
        .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    #[test]
    fn empty_repo_yields_no_findings() {
        let dir = tempfile::tempdir().unwrap();
        let findings = detect(dir.path()).unwrap();
        assert!(findings.is_empty(), "expected no findings in empty repo");
    }

    #[test]
    fn yaml_config_yields_one_finding() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();

        let findings = detect(dir.path()).unwrap();
        assert_eq!(findings.len(), 1);

        let gate = &findings[0];
        assert_eq!(gate.gate_type, GateType::PreCommitFramework);
        assert_eq!(gate.source_path, dir.path().join(CONFIG_YAML));
    }

    #[test]
    fn yml_config_yields_one_finding() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(CONFIG_YML), "repos: []\n").unwrap();

        let findings = detect(dir.path()).unwrap();
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].source_path, dir.path().join(CONFIG_YML));
    }

    #[test]
    fn prefers_yaml_over_yml_when_both_present() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();
        fs::write(dir.path().join(CONFIG_YML), "repos: []\n").unwrap();

        let findings = detect(dir.path()).unwrap();
        assert_eq!(findings.len(), 1, "must not produce two findings");
        assert_eq!(
            findings[0].source_path,
            dir.path().join(CONFIG_YAML),
            "must prefer .yaml over .yml"
        );
    }

    #[test]
    fn proposed_check_has_pre_commit_source_type() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();

        let findings = detect(dir.path()).unwrap();
        let check = &findings[0].proposed_checks[0];

        assert_eq!(check.name, "pre-commit");
        assert_eq!(check.triggers, vec![TriggerKind::Commit]);
        assert_eq!(check.timeout_secs, 120);
        assert!(
            matches!(
                &check.source,
                ProposedCheckSource::PreCommit {
                    hook_stage: None,
                    config_path: None
                }
            ),
            "source must be PreCommit with no overrides"
        );
    }

    #[test]
    fn chain_support_is_manual_only() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();

        let findings = detect(dir.path()).unwrap();
        assert_eq!(findings[0].chain_support, ChainSupport::ManualOnly);
        assert!(
            findings[0].manual_chain_instructions.is_some(),
            "manual instructions must be set when chain_support = ManualOnly"
        );
    }

    #[test]
    fn duplicate_execution_warning_fires_when_hook_has_marker() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();

        // Create .git/hooks/pre-commit with the pre-commit marker.
        let hooks_dir = dir.path().join(".git/hooks");
        fs::create_dir_all(&hooks_dir).unwrap();
        fs::write(
            hooks_dir.join("pre-commit"),
            "#!/bin/sh\n# Generated by pre-commit\nexec pre-commit run\n",
        )
        .unwrap();

        let findings = detect(dir.path()).unwrap();
        assert_eq!(findings.len(), 1);
        assert!(
            !findings[0].warnings.is_empty(),
            "should warn about duplicate execution"
        );
        assert!(
            findings[0].warnings[0].contains("twice"),
            "warning should mention duplicate execution: {}",
            findings[0].warnings[0]
        );
    }

    #[test]
    fn no_duplicate_warning_when_hook_is_not_pre_commit_generated() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();

        let hooks_dir = dir.path().join(".git/hooks");
        fs::create_dir_all(&hooks_dir).unwrap();
        // A user-owned hook without the pre-commit marker.
        fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\nnpm test\n").unwrap();

        let findings = detect(dir.path()).unwrap();
        assert!(
            findings[0].warnings.is_empty(),
            "should not warn when hook is not pre-commit-generated"
        );
    }

    #[test]
    fn no_duplicate_warning_when_git_hook_absent() {
        let dir = tempfile::tempdir().unwrap();
        fs::write(dir.path().join(CONFIG_YAML), "repos: []\n").unwrap();
        // No .git directory at all.

        let findings = detect(dir.path()).unwrap();
        assert!(
            findings[0].warnings.is_empty(),
            "no warning when .git/hooks/pre-commit does not exist"
        );
    }
}