pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! `pushkin enable <feature>` / `pushkin disable <feature>`: the
//! human-plane switch for whole enforcement planes, persisted as the
//! manifest's `[features]` table so every surface (init, doctor, the
//! pre-commit floor, CI) reads one truth. Like `waive`, these verbs are
//! the human plane: the manifest is a protected path, so agents cannot
//! reach the switch through gated file writes — and the local floors are
//! honest about being locally removable anyway (CI is the backstop).
//!
//! One feature today: `git-hooks` — the git-plane floor (lefthook block +
//! native `.git/hooks` shim + the staged check they run). Disable is
//! decisive, never a half-state: it records `git_hooks = false` AND
//! uninstalls both pushkin-owned surfaces. Enable records the flag and
//! names the install commands; it deliberately installs nothing (install
//! stays the explicit `init --agent ...` consent path).

use anyhow::{bail, Context, Result};
use pushkin_core::manifest::Manifest;

use super::MANIFEST_FILE;

pub const FEATURE_GIT_HOOKS: &str = "git-hooks";

/// Whether this repo's manifest positively declares `git_hooks = false`.
///
/// `Ok(false)` when no manifest exists — no manifest is nothing to
/// disable, mirroring the N13 principle: only a positive probe changes
/// behavior.
///
/// # Errors
/// A manifest that is present but rejected: the caller decides how loud
/// to be (init propagates; doctor treats it as not-disabled and lets the
/// gates' own surfaces name the broken manifest).
pub fn git_hooks_disabled() -> Result<bool> {
    let text = match std::fs::read_to_string(MANIFEST_FILE) {
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
        Err(error) => return Err(error).with_context(|| format!("cannot read {MANIFEST_FILE}")),
        Ok(text) => text,
    };
    let manifest = Manifest::parse(&text).context("manifest rejected")?;
    Ok(!manifest.git_hooks_enabled())
}

/// The refusal `init --agent lefthook|git` makes while the plane is off.
///
/// # Errors
/// The refusal itself (as a loud error naming the re-enable verb), or a
/// present-but-rejected manifest.
pub fn ensure_git_hooks_enabled() -> Result<()> {
    if git_hooks_disabled()? {
        bail!(
            "git hooks are disabled for this repo ([features] git_hooks = false \
             in {MANIFEST_FILE}); run `pushkin enable git-hooks` first"
        );
    }
    Ok(())
}

/// `pushkin enable <feature>`.
///
/// # Errors
/// Unknown feature, missing/rejected manifest, or a failed write.
pub fn run_enable(feature: &str) -> Result<i32> {
    require_known(feature)?;
    set_git_hooks(true)?;
    println!(
        "pushkin: git hooks enabled ([features] git_hooks = true in {MANIFEST_FILE}). \
         Nothing was installed: run `pushkin init --agent lefthook` (config-file \
         floor) or `pushkin init --agent git` (native shim) to install the \
         pre-commit gate."
    );
    Ok(0)
}

/// `pushkin disable <feature>`: records the flag, then removes both
/// pushkin-owned git-plane surfaces so nothing is left half-installed.
///
/// # Errors
/// Unknown feature, missing/rejected manifest, or a failed write/removal.
pub fn run_disable(feature: &str) -> Result<i32> {
    require_known(feature)?;
    set_git_hooks(false)?;
    super::init::remove_lefthook()?;
    super::init::remove_git_shim()?;
    println!(
        "pushkin: git hooks disabled ([features] git_hooks = false in {MANIFEST_FILE}); \
         `init --agent lefthook|git` will refuse until re-enabled, doctor stops \
         checking the floor, and `check --staged` passes with a notice. Agent \
         write-time gates are unaffected. Re-enable with `pushkin enable git-hooks`."
    );
    Ok(0)
}

fn require_known(feature: &str) -> Result<()> {
    if feature != FEATURE_GIT_HOOKS {
        bail!("unknown feature '{feature}' (known features: {FEATURE_GIT_HOOKS})");
    }
    Ok(())
}

/// Rewrites the manifest's `[features] git_hooks` value in place with a
/// line-level splice — comments and every other line are preserved
/// byte-for-byte. The result is parse-verified before it is written; a
/// splice that would produce a rejected manifest writes nothing.
fn set_git_hooks(enabled: bool) -> Result<()> {
    let text = std::fs::read_to_string(MANIFEST_FILE).with_context(|| {
        format!(
            "cannot read {MANIFEST_FILE} in the current directory — the feature \
             flag lives in the manifest, so there is nothing to record without one"
        )
    })?;
    let updated = splice_git_hooks(&text, enabled);
    Manifest::parse(&updated).context(
        "refusing to write: the updated manifest would be rejected — \
         fix pushkin.toml by hand",
    )?;
    std::fs::write(MANIFEST_FILE, updated).with_context(|| format!("cannot write {MANIFEST_FILE}"))
}

/// The pure splice: replace the `git_hooks` line inside `[features]`,
/// insert one after an existing `[features]` header, or append the table
/// at the end of the file. No other line changes.
fn splice_git_hooks(text: &str, enabled: bool) -> String {
    let value_line = format!("git_hooks = {enabled}");
    let lines: Vec<&str> = text.lines().collect();
    let header = lines.iter().position(|line| line.trim() == "[features]");
    let Some(header) = header else {
        let separator = if text.is_empty() || text.ends_with("\n\n") {
            ""
        } else if text.ends_with('\n') {
            "\n"
        } else {
            "\n\n"
        };
        return format!("{text}{separator}[features]\n{value_line}\n");
    };
    let table_end = lines[header + 1..]
        .iter()
        .position(|line| line.trim_start().starts_with('['))
        .map_or(lines.len(), |offset| header + 1 + offset);
    let existing = lines[header + 1..table_end].iter().position(|line| {
        let trimmed = line.trim_start();
        trimmed
            .strip_prefix("git_hooks")
            .is_some_and(|rest| rest.trim_start().starts_with('='))
    });
    let mut out: Vec<String> = lines.iter().map(ToString::to_string).collect();
    match existing {
        Some(offset) => out[header + 1 + offset] = value_line,
        None => out.insert(header + 1, value_line),
    }
    let mut joined = out.join("\n");
    if text.ends_with('\n') {
        joined.push('\n');
    }
    joined
}