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";
#[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 { .. })
}
}
#[must_use]
pub fn evaluate() -> PolicyDecision {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
evaluate_for(&cwd)
}
#[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(),
}
}
#[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"))
}
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());
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)
}
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")
},
)
}