use std::path::{Component, Path, PathBuf};
use globset::Glob;
use super::cursor::{normalize_lexical, relative_path};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathVerdict {
pub path: String,
pub denied: bool,
pub matched: Option<String>,
}
pub fn check_deny_paths(
deny_paths: &[String],
candidates: &[String],
cwd: &Path,
workspace_root: &Path,
) -> Vec<PathVerdict> {
let cwd = canonicalize_existing_prefix(cwd);
let workspace_root = canonicalize_existing_prefix(workspace_root);
let (cwd, workspace_root) = (cwd.as_path(), workspace_root.as_path());
let entries: Vec<(&String, Option<globset::GlobMatcher>, String)> = deny_paths
.iter()
.filter(|e| !e.is_empty())
.map(|e| {
let matcher = Glob::new(e).ok().map(|g| g.compile_matcher());
(e, matcher, literal_base(e))
})
.collect();
candidates
.iter()
.map(|candidate| {
let rel = workspace_relative(candidate, cwd, workspace_root);
let matched = entries
.iter()
.find(|(_, matcher, base)| {
matcher.as_ref().is_some_and(|m| m.is_match(&rel))
|| (!base.is_empty()
&& (rel == *base || rel.starts_with(&format!("{base}/"))))
})
.map(|(entry, _, _)| (*entry).clone());
PathVerdict {
path: candidate.clone(),
denied: matched.is_some(),
matched,
}
})
.collect()
}
fn workspace_relative(candidate: &str, cwd: &Path, workspace_root: &Path) -> String {
let path = Path::new(candidate);
let abs: PathBuf = if path.is_absolute() {
canonicalize_existing_prefix(path)
} else {
normalize_lexical(&cwd.join(path))
};
let rel = relative_path(workspace_root, &abs);
rel.components()
.map(|c| match c {
Component::ParentDir => "..".to_string(),
other => other.as_os_str().to_string_lossy().to_string(),
})
.collect::<Vec<_>>()
.join("/")
}
fn canonicalize_existing_prefix(path: &Path) -> PathBuf {
let norm = normalize_lexical(path);
let mut existing = norm.as_path();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
loop {
if existing.exists() {
break;
}
match (existing.parent(), existing.file_name()) {
(Some(parent), Some(name)) => {
tail.push(name.to_os_string());
existing = parent;
}
_ => return norm,
}
}
let mut out = std::fs::canonicalize(existing).unwrap_or_else(|_| existing.to_path_buf());
for name in tail.iter().rev() {
out.push(name);
}
out
}
fn literal_base(entry: &str) -> String {
let cut = entry
.find(['*', '?', '[', '{'])
.map_or(entry, |i| &entry[..i]);
cut.trim_end_matches('/').to_string()
}
fn active_binding_path(workspace_root: &Path) -> PathBuf {
workspace_root
.join(".memstead.cache")
.join("projection")
.join("active-binding.json")
}
pub fn write_active_binding_file(workspace_root: &Path, binding_id: &str) {
let path = active_binding_path(workspace_root);
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let payload = serde_json::json!({ "binding": binding_id });
if let Ok(bytes) = serde_json::to_vec(&payload) {
let _ = std::fs::write(&path, bytes);
}
}
pub fn read_active_binding_file(workspace_root: &Path) -> Option<String> {
let raw = std::fs::read(active_binding_path(workspace_root)).ok()?;
let value: serde_json::Value = serde_json::from_slice(&raw).ok()?;
value["binding"].as_str().map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
const WS: &str = "/home/dev/memstead";
fn verdicts(deny: &[&str], candidates: &[&str], cwd: &str) -> Vec<PathVerdict> {
let deny: Vec<String> = deny.iter().map(|s| s.to_string()).collect();
let candidates: Vec<String> = candidates.iter().map(|s| s.to_string()).collect();
check_deny_paths(&deny, &candidates, Path::new(cwd), Path::new(WS))
}
fn denied(deny: &[&str], candidate: &str) -> bool {
verdicts(deny, &[candidate], WS)[0].denied
}
#[test]
fn fixture_cases_hold_at_the_one_seam() {
let entries = &["dev/**", "**/VISION.md", "docs/meta/CLAUDE.md"];
for blocked in [
"dev/notes/a.md",
"dev/x.rs",
"dev/deep/nested/y.txt",
"VISION.md",
"crates/foo/VISION.md",
"docs/meta/CLAUDE.md",
] {
assert!(denied(entries, blocked), "must block {blocked}");
}
for allowed in [
"src/lib.rs",
"dev-tools/x.rs",
"VISION-draft.md",
"docs/meta/README.md",
"other/CLAUDE.md",
"crates/foo/mod.rs",
] {
assert!(!denied(entries, allowed), "must allow {allowed}");
}
}
#[test]
fn subtree_entry_blocks_the_directory_itself() {
let entries = &["dev/**"];
assert!(denied(entries, "dev"));
assert!(denied(entries, "dev/"));
assert!(denied(entries, &format!("{WS}/dev")));
assert!(!denied(entries, "dev-tools/x.rs"));
}
#[test]
fn legacy_bare_names_degrade_to_prefix_blocks() {
let entries = &["VISION.md", "CLAUDE.md", "dev"];
assert!(denied(entries, "CLAUDE.md"));
assert!(denied(entries, &format!("{WS}/VISION.md")));
assert!(denied(entries, "dev/notes/foo.md"));
assert!(denied(entries, "dev"));
assert!(!denied(entries, "subdir/CLAUDE.md"));
assert!(!denied(entries, "VISION-draft.md"));
assert!(!denied(entries, "engine/dev-tools/foo.rs"));
}
#[test]
fn glob_pattern_candidates_are_blocked() {
let entries = &["dev/**"];
assert!(denied(entries, "dev/**/*.md"));
assert!(denied(entries, &format!("{WS}/dev/**")));
assert!(denied(entries, "dev/notes/*"));
}
#[test]
fn dot_dot_entries_match_out_of_workspace_candidates() {
let ws = format!("{WS}/graph");
let deny: Vec<String> = vec!["../dev/**".into(), "../CLAUDE.md".into()];
let candidates: Vec<String> = vec![
format!("{WS}/dev/notes/a.md"),
format!("{WS}/CLAUDE.md"),
format!("{ws}/src/x.rs"),
];
let v = check_deny_paths(&deny, &candidates, Path::new(&ws), Path::new(&ws));
assert!(v[0].denied, "sibling dev/ read is blocked");
assert!(v[1].denied, "sibling CLAUDE.md read is blocked");
assert!(!v[2].denied, "in-workspace file is untouched");
}
#[test]
fn empty_list_and_outside_paths_are_open() {
assert!(!denied(&[], "CLAUDE.md"));
assert!(!denied(&[], "dev/notes/foo.md"));
assert!(!denied(&["dev/**"], "/etc/hosts"));
assert!(!denied(&["dev/**"], "/tmp/something.md"));
}
#[test]
fn relative_candidates_resolve_against_cwd() {
let v = verdicts(&["dev/**"], &["../dev/notes/a.md"], &format!("{WS}/graph"));
assert!(v[0].denied);
let v = verdicts(&["dev/**"], &["notes/a.md"], &format!("{WS}/dev"));
assert!(v[0].denied);
}
#[test]
fn matched_entry_is_named_in_order() {
let v = verdicts(&["**/VISION.md", "dev/**"], &["dev/VISION.md"], WS);
assert_eq!(v[0].matched.as_deref(), Some("**/VISION.md"));
let v = verdicts(&["dev/**"], &["src/lib.rs"], WS);
assert_eq!(v[0].matched, None);
assert!(!v[0].denied);
}
#[test]
fn malformed_entry_degrades_to_prefix_rule() {
let entries = &["secrets/[", "dev/**"];
assert!(denied(entries, "secrets/key.txt"));
assert!(denied(entries, "dev/x.rs"));
assert!(!denied(entries, "src/lib.rs"));
}
#[test]
fn character_classes_and_braces_follow_engine_semantics() {
assert!(denied(&["docs/x[ab].md"], "docs/xa.md"));
assert!(!denied(&["docs/x[ab].md"], "docs/xz.md"));
assert!(denied(&["**/*.{png,jpg}"], "assets/logo.png"));
assert!(!denied(&["**/*.{png,jpg}"], "assets/logo.svg"));
}
#[test]
fn active_binding_pointer_overwrites_and_fails_open() {
let ws = tempfile::tempdir().unwrap();
assert_eq!(read_active_binding_file(ws.path()), None);
write_active_binding_file(ws.path(), "engine/x-graph");
assert_eq!(
read_active_binding_file(ws.path()).as_deref(),
Some("engine/x-graph")
);
write_active_binding_file(ws.path(), "project/y-graph");
assert_eq!(
read_active_binding_file(ws.path()).as_deref(),
Some("project/y-graph")
);
}
}