pushkin 0.2.1

Schema-first enforcement harness that gates AI coding agents' file writes against project contracts
//! Worktree policy (spec §9): whether the gate is attached in THIS
//! worktree. Layers, strongest first:
//!   0. explicit attach (`PUSHKIN_ATTACH=1`) — always wins, even over
//!      the kill-switch: a human who says "gate this" is gated;
//!   1. per-worktree marker file `.pushkinignore-self`;
//!   2. user denylist file (glob-per-line; path in
//!      `PUSHKIN_WORKTREE_DENYLIST`, default under the user config dir);
//!      re-read every evaluation, so an mtime change is live immediately;
//!   3. env kill-switch `PUSHKIN_DISABLE=1`.
//!
//! Detached = the gate stands down (allow, silence) in that worktree.
//! `pushkin policy test` dry-runs the decision and names the layer.

use pushkin_core::board::glob_matches;
use std::path::{Path, PathBuf};

pub const MARKER_FILE: &str = ".pushkinignore-self";
pub const DENYLIST_ENV: &str = "PUSHKIN_WORKTREE_DENYLIST";
pub const KILL_SWITCH_ENV: &str = "PUSHKIN_DISABLE";
pub const ATTACH_ENV: &str = "PUSHKIN_ATTACH";

/// The policy decision for one worktree, with the layer that decided it.
#[derive(Debug, PartialEq, Eq)]
pub enum PolicyDecision {
    Attached { reason: String },
    Detached { layer: String },
}

impl PolicyDecision {
    #[must_use]
    pub fn is_detached(&self) -> bool {
        matches!(self, Self::Detached { .. })
    }
}

/// Evaluates the policy for the current working directory.
#[must_use]
pub fn evaluate() -> PolicyDecision {
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
    evaluate_for(&cwd)
}

/// Policy for an explicit worktree root (test seam and `policy test`).
#[must_use]
pub fn evaluate_for(worktree: &Path) -> PolicyDecision {
    if env_flag(ATTACH_ENV) {
        return PolicyDecision::Attached {
            reason: format!("explicit attach ({ATTACH_ENV}=1) overrides all policy layers"),
        };
    }
    if worktree.join(MARKER_FILE).exists() {
        return PolicyDecision::Detached {
            layer: format!("marker file {MARKER_FILE}"),
        };
    }
    if let Some(glob) = denylist_match(worktree) {
        return PolicyDecision::Detached {
            layer: format!("denylist glob '{glob}'"),
        };
    }
    if env_flag(KILL_SWITCH_ENV) {
        return PolicyDecision::Detached {
            layer: format!("env kill-switch {KILL_SWITCH_ENV}=1"),
        };
    }
    PolicyDecision::Attached {
        reason: "no detach layer matched".to_owned(),
    }
}

/// `pushkin policy test`: dry-run, prints the decision and layer,
/// exit 0 always — it changes nothing.
#[must_use]
pub fn run_test() -> i32 {
    match evaluate() {
        PolicyDecision::Attached { reason } => {
            println!("pushkin policy: attached — {reason}");
        }
        PolicyDecision::Detached { layer } => {
            println!("pushkin policy: detached — {layer}");
        }
    }
    0
}

fn env_flag(name: &str) -> bool {
    std::env::var(name).is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
}

/// First denylist glob matching this worktree path, if any. The file is
/// read on every call — a fresh mtime is live by construction. Comment
/// lines (#) and blanks are skipped; an unreadable file denies nothing.
/// Globs are matched literally AND with their non-glob directory prefix
/// canonicalized, so a pattern written against either side of a symlink
/// (macOS /var vs /private/var) still covers the worktree.
fn denylist_match(worktree: &Path) -> Option<String> {
    let path = std::env::var(DENYLIST_ENV).map_or_else(|_| default_denylist(), PathBuf::from);
    let text = std::fs::read_to_string(path).ok()?;
    let literal = worktree.to_string_lossy().into_owned();
    let canonical = worktree
        .canonicalize()
        .map_or_else(|_| literal.clone(), |p| p.to_string_lossy().into_owned());
    // A denylist glob covers the worktree when it matches the worktree
    // path itself OR its contents ("<dir>/**" is the documented idiom and
    // does not match "<dir>" alone — probe with a child path).
    let candidates = [
        literal.clone(),
        format!("{literal}/x"),
        canonical.clone(),
        format!("{canonical}/x"),
    ];
    text.lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .find(|line| {
            glob_variants(line)
                .iter()
                .any(|glob| candidates.iter().any(|path| glob_matches(glob, path)))
        })
        .map(str::to_owned)
}

/// The glob as written plus, when its longest non-glob directory prefix
/// canonicalizes to a different path, the same pattern re-rooted there.
fn glob_variants(line: &str) -> Vec<String> {
    let mut variants = vec![line.to_owned()];
    let prefix_end = line.find(['*', '?', '[']).unwrap_or(line.len());
    let (prefix, suffix) = line.split_at(prefix_end);
    let (dir, rest) = match prefix.rfind('/') {
        Some(slash) => prefix.split_at(slash),
        None => return variants,
    };
    if let Ok(canonical_dir) = Path::new(dir).canonicalize() {
        let rerooted = format!("{}{rest}{suffix}", canonical_dir.to_string_lossy());
        if rerooted != line {
            variants.push(rerooted);
        }
    }
    variants
}

fn default_denylist() -> PathBuf {
    std::env::var("HOME").map_or_else(
        |_| PathBuf::from(".pushkin-worktree-denylist"),
        |home| {
            PathBuf::from(home)
                .join(".config")
                .join("pushkin")
                .join("worktree-denylist")
        },
    )
}