use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use std::process::Command;
use globset::{Glob, GlobSet, GlobSetBuilder};
use crate::Engine;
use crate::pipeline::{MediumType, PatternMode};
use super::brief::{NoSignalNote, SourceCursor, SyncCommand};
use super::change_detection::{
StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
};
use super::resolve::{
ChangeStrategy, ResolvedIngest, ResolvedSource, find_git_root, resolve_change_strategy,
};
use super::slice::{
NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
};
use crate::pipeline::Source;
fn normalize_lexical(path: &Path) -> PathBuf {
let mut out: Vec<Component> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => match out.last() {
Some(Component::Normal(_)) => {
out.pop();
}
Some(Component::RootDir | Component::Prefix(_)) => {}
_ => out.push(comp),
},
other => out.push(other),
}
}
out.iter().collect()
}
fn relative_path(from: &Path, to: &Path) -> PathBuf {
let from = normalize_lexical(from);
let to = normalize_lexical(to);
let from_comps: Vec<Component> = from.components().collect();
let to_comps: Vec<Component> = to.components().collect();
let mut common = 0;
while common < from_comps.len()
&& common < to_comps.len()
&& from_comps[common] == to_comps[common]
{
common += 1;
}
let mut result = PathBuf::new();
for _ in common..from_comps.len() {
result.push("..");
}
for comp in &to_comps[common..] {
result.push(comp.as_os_str());
}
result
}
pub fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
if pointer.is_empty() {
workspace_root.to_path_buf()
} else {
normalize_lexical(&workspace_root.join(pointer))
}
}
fn engine_state_denies(workspace_root: &Path) -> Vec<String> {
use crate::workspace_store::{FileWorkspaceStore, WorkspaceStoreAdapter};
let mut denies: Vec<String> = vec![
".memstead/**".to_string(),
".memstead.cache/**".to_string(),
"**/.memstead/**".to_string(),
"**/.memstead.cache/**".to_string(),
];
if let Ok(ws) = FileWorkspaceStore.load(workspace_root) {
for mount in &ws.mounts {
let dir: Option<PathBuf> = match &mount.storage {
crate::workspace::MountStorage::GitBranch { gitdir, .. } => {
gitdir.parent().map(Path::to_path_buf)
}
crate::workspace::MountStorage::Folder { path } => Some(path.clone()),
crate::workspace::MountStorage::Archive { path, .. } => {
let rel = relative_path(workspace_root, &normalize_lexical(path));
denies.push(rel.to_string_lossy().to_string());
None
}
crate::workspace::MountStorage::InMemory => None,
};
if let Some(dir) = dir {
let rel = relative_path(workspace_root, &normalize_lexical(&dir));
if !rel.as_os_str().is_empty() {
denies.push(format!("{}/**", rel.to_string_lossy()));
}
}
}
}
denies
}
fn git_head(git_root: &Path) -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(git_root)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!sha.is_empty()).then_some(sha)
}
fn to_git_pathspec(pattern: &str, git_root: &Path, workspace_root: &Path, exclude: bool) -> String {
let magic = if exclude {
":(glob,exclude)"
} else {
":(glob)"
};
if pattern.starts_with("**") {
return format!("{magic}{pattern}");
}
let resolved = normalize_lexical(&workspace_root.join(pattern));
let git_rel = relative_path(git_root, &resolved);
format!("{magic}{}", git_rel.to_string_lossy())
}
fn in_repo_pathspec(
pattern: &str,
git_root: &Path,
workspace_root: &Path,
exclude: bool,
) -> Option<String> {
if pattern.starts_with("**") {
return Some(to_git_pathspec(pattern, git_root, workspace_root, exclude));
}
let resolved = normalize_lexical(&workspace_root.join(pattern));
let git_rel = relative_path(git_root, &resolved);
if git_rel
.components()
.next()
.is_some_and(|c| c == Component::ParentDir)
{
return None;
}
let magic = if exclude {
":(glob,exclude)"
} else {
":(glob)"
};
Some(format!("{magic}{}", git_rel.to_string_lossy()))
}
fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
builder.add(Glob::new(pattern).ok()?);
}
builder.build().ok()
}
fn facet_unscoped(source: &Source) -> bool {
!source.scope.iter().any(|r| r.mode == PatternMode::Allow)
}
pub fn enumerate_facet_files(
source: &Source,
deny_paths: &[String],
workspace_root: &Path,
) -> Vec<String> {
if !matches!(
source.medium_type,
MediumType::Codebase | MediumType::Filesystem
) {
return Vec::new();
}
let mut allows: Vec<&str> = Vec::new();
let mut denies: Vec<&str> = Vec::new();
for rule in &source.scope {
match rule.mode {
PatternMode::Allow => allows.push(&rule.path),
PatternMode::Deny => denies.push(&rule.path),
}
}
for dp in deny_paths {
denies.push(dp);
}
let forced = engine_state_denies(workspace_root);
for f in &forced {
denies.push(f);
}
if allows.is_empty() {
return Vec::new();
}
let Some(allow_set) = build_glob_set(&allows) else {
return Vec::new();
};
let deny_set = if denies.is_empty() {
None
} else {
build_glob_set(&denies)
};
let base = medium_base(&source.pointer, workspace_root);
let mut out: Vec<String> = Vec::new();
let mut stack = vec![base];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
let skip = path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
VCS_INTERNAL_DIRS.contains(&n) || n == ".memstead" || n == ".memstead.cache"
});
if !skip {
stack.push(path);
}
} else if file_type.is_file() {
let rel = relative_path(workspace_root, &normalize_lexical(&path))
.to_string_lossy()
.to_string();
let denied = deny_set.as_ref().is_some_and(|d| d.is_match(&rel));
if allow_set.is_match(&rel) && !denied {
out.push(rel);
}
}
}
}
out.sort();
out.dedup();
out
}
fn compute_git_slice(
source: &Source,
deny_paths: &[String],
workspace_root: &Path,
baseline: Option<&str>,
) -> SliceOutcome {
let base = medium_base(&source.pointer, workspace_root);
let Some(git_root) = find_git_root(&base) else {
return SliceOutcome::NoSignal {
reason: NoSignalReason::GitUnavailable,
};
};
let Some(head) = git_head(&git_root) else {
return SliceOutcome::NoSignal {
reason: NoSignalReason::GitUnavailable,
};
};
let baseline = match baseline {
Some(b) if is_git_token(b) => b,
_ => return SliceOutcome::Reseed { token: head },
};
if baseline == head {
return SliceOutcome::Unchanged { token: head };
}
let mut allows: Vec<&str> = Vec::new();
let mut denies: Vec<&str> = Vec::new();
for rule in &source.scope {
match rule.mode {
PatternMode::Allow => allows.push(&rule.path),
PatternMode::Deny => denies.push(&rule.path),
}
}
if allows.is_empty() {
return SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped,
};
}
for dp in deny_paths {
denies.push(dp);
}
let forced = engine_state_denies(workspace_root);
for f in &forced {
denies.push(f);
}
let mut specs: Vec<String> = Vec::with_capacity(allows.len() + denies.len());
for a in &allows {
specs.push(to_git_pathspec(a, &git_root, workspace_root, false));
}
for d in &denies {
if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
specs.push(spec);
}
}
let mut cmd = Command::new("git");
cmd.args([
"diff",
"--no-renames",
"--name-status",
baseline,
&head,
"--",
]);
cmd.args(&specs);
cmd.current_dir(&git_root);
let out = match cmd.output() {
Ok(o) if o.status.success() => o,
_ => {
return SliceOutcome::NoSignal {
reason: NoSignalReason::GitUnavailable,
};
}
};
let text = String::from_utf8_lossy(&out.stdout);
let mut slice = Slice::default();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let Some(tab) = line.find('\t') else { continue };
let status = line[..tab].trim();
let git_path = line[tab + 1..].trim();
let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
.to_string_lossy()
.to_string();
match status.chars().next() {
Some('A') => slice.added.push(ws_path),
Some('D') => slice.deleted.push(ws_path),
_ => slice.modified.push(ws_path),
}
}
slice.added.sort();
slice.modified.sort();
slice.deleted.sort();
SliceOutcome::Changed {
token: head,
slice,
degraded: false,
}
}
fn compute_graph_slice(engine: &Engine, source_mem: &str, baseline: Option<&str>) -> SliceOutcome {
let current = match engine.mem_head_sha(source_mem) {
Ok(Some(sha)) => sha,
_ => {
return SliceOutcome::NoSignal {
reason: NoSignalReason::GraphSnapshotMissing,
};
}
};
let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
if changed {
let baseline = baseline.expect("changed implies a baseline");
match engine.changes_since(source_mem, baseline, None) {
Ok(report) => graph_slice_outcome(Some(baseline), ¤t, &report.changes),
Err(_) => SliceOutcome::NoSignal {
reason: NoSignalReason::GraphSnapshotMissing,
},
}
} else {
graph_slice_outcome(baseline, ¤t, &[])
}
}
fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
let safe: String = facet_ref
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
c
} else {
'_'
}
})
.collect();
cache_root
.join("source-cursor")
.join(ingest_name)
.join(format!("{safe}.json"))
}
fn read_cursor_memo(
cache_root: &Path,
ingest: &str,
facet: &str,
aggregate: &str,
) -> Option<StatMap> {
let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
memo.get(aggregate).cloned()
}
fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
let path = cursor_memo_path(cache_root, ingest, facet);
let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
memo.insert(aggregate.to_string(), map.clone());
if memo.len() > 3 {
let drop: Vec<String> = memo
.keys()
.filter(|k| k.as_str() != aggregate)
.skip(2)
.cloned()
.collect();
for key in drop {
memo.remove(&key);
}
}
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(bytes) = serde_json::to_vec(&memo) {
let _ = std::fs::write(&path, bytes);
}
}
fn active_deny_path(workspace_root: &Path) -> PathBuf {
workspace_root
.join(".memstead.cache")
.join("projection")
.join("active-deny-paths.json")
}
pub fn write_active_deny_file(workspace_root: &Path, ingest_name: &str, deny_paths: &[String]) {
let path = active_deny_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!({
"ingest": ingest_name,
"deny_paths": deny_paths,
});
if let Ok(bytes) = serde_json::to_vec(&payload) {
let _ = std::fs::write(&path, bytes);
}
}
const VCS_INTERNAL_DIRS: &[&str] = &[".git", ".svn", ".hg"];
const DEAD_DENY_SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
"dist",
".memstead.cache",
".sqlx",
".svn",
".hg",
];
fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
let mut out: Vec<String> = Vec::new();
let mut stack = vec![base.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
let skip = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
if !skip {
stack.push(path);
}
} else if file_type.is_file() {
if out.len() >= cap {
return None;
}
out.push(
relative_path(workspace_root, &normalize_lexical(&path))
.to_string_lossy()
.to_string(),
);
}
}
}
Some(out)
}
fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
if resolved.deny_paths.is_empty() {
return Vec::new();
}
let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
return Vec::new();
};
let mut dead: Vec<String> = Vec::new();
for entry in &resolved.deny_paths {
let Some(set) = build_glob_set(&[entry.as_str()]) else {
continue;
};
if !files.iter().any(|f| set.is_match(f)) {
dead.push(entry.clone());
}
}
dead
}
fn compute_mtime_slice(
source: &Source,
ingest_name: &str,
deny_paths: &[String],
workspace_root: &Path,
cache_root: &Path,
baseline: Option<&str>,
) -> SliceOutcome {
if facet_unscoped(source) {
return SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped,
};
}
let files = enumerate_facet_files(source, deny_paths, workspace_root);
let now_map = compute_stat_map(&files, workspace_root);
let now_digest = digest_stat_map(&now_map);
write_cursor_memo(
cache_root,
ingest_name,
&source.name,
&now_digest.aggregate,
&now_map,
);
let prev_map = baseline
.and_then(parse_digest_token)
.and_then(|base| read_cursor_memo(cache_root, ingest_name, &source.name, &base.aggregate));
mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
}
fn current_primary_token(
engine: &Engine,
source: &Source,
deny_paths: &[String],
workspace_root: &Path,
) -> Option<String> {
match resolve_change_strategy(source, workspace_root) {
ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
&source.pointer,
workspace_root,
))?),
ChangeStrategy::Graph => engine.mem_head_sha(&source.pointer).ok().flatten(),
ChangeStrategy::Mtime => {
if facet_unscoped(source) {
None
} else {
let files = enumerate_facet_files(source, deny_paths, workspace_root);
Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
&files,
workspace_root,
))))
}
}
ChangeStrategy::None => None,
}
}
pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
source_moved_since(engine, resolved, workspace_root, "synced", false)
}
pub fn source_moved_since(
engine: &Engine,
resolved: &ResolvedIngest,
workspace_root: &Path,
state: &str,
missing_baseline_is_moved: bool,
) -> bool {
let dest = &resolved.destination_mem;
let baseline_map = engine
.mem_config_for(dest)
.map(|c| c.sync_state.clone())
.unwrap_or_default();
for source in &resolved.sources {
let (facet_ref, current) = match source {
ResolvedSource::Primary(p) => (
p.name.clone(),
current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
),
ResolvedSource::Reference { mem } => {
(mem.clone(), engine.mem_head_sha(mem).ok().flatten())
}
};
let key = format!("{}/{}#{state}", resolved.name, facet_ref);
let Some(baseline) = baseline_map.get(&key) else {
if missing_baseline_is_moved && current.as_deref().is_some_and(|c| !c.is_empty()) {
return true;
}
continue;
};
if let Some(current) = current
&& !current.is_empty()
&& current != *baseline
{
return true;
}
}
false
}
pub fn compute_source_cursor(
engine: &Engine,
resolved: &ResolvedIngest,
workspace_root: &Path,
) -> SourceCursor {
let dest = &resolved.destination_mem;
let baseline_map = engine
.mem_config_for(dest)
.map(|c| c.sync_state.clone())
.unwrap_or_default();
let cache_root = workspace_root.join(".memstead.cache").join("ingest");
let mut union = Slice::default();
let mut write_commands: Vec<SyncCommand> = Vec::new();
let mut reseed: Vec<SyncCommand> = Vec::new();
let mut no_signal: Vec<NoSignalNote> = Vec::new();
let mut degraded = false;
for source in &resolved.sources {
let (facet_ref, outcome) = match source {
ResolvedSource::Primary(p) => {
let key = format!("{}/{}#synced", resolved.name, p.name);
let baseline = baseline_map.get(&key).map(String::as_str);
let outcome = match resolve_change_strategy(p, workspace_root) {
ChangeStrategy::Git => {
compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
}
ChangeStrategy::Graph => compute_graph_slice(engine, &p.pointer, baseline),
ChangeStrategy::Mtime => compute_mtime_slice(
p,
&resolved.name,
&resolved.deny_paths,
workspace_root,
&cache_root,
baseline,
),
ChangeStrategy::None => SliceOutcome::NoSignal {
reason: NoSignalReason::DetectionNone,
},
};
(p.name.clone(), outcome)
}
ResolvedSource::Reference { mem } => {
let key = format!("{}/{}#synced", resolved.name, mem);
let baseline = baseline_map.get(&key).map(String::as_str);
(mem.clone(), compute_graph_slice(engine, mem, baseline))
}
};
let key = format!("{}/{}#synced", resolved.name, facet_ref);
match outcome {
SliceOutcome::Unchanged { .. } => {}
SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
source: facet_ref.clone(),
reason,
}),
SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
SliceOutcome::Changed {
token,
slice,
degraded: d,
} => {
union.added.extend(slice.added);
union.modified.extend(slice.modified);
union.deleted.extend(slice.deleted);
degraded |= d;
write_commands.push(SyncCommand { key, token });
}
}
}
dedupe_sort(&mut union.added);
dedupe_sort(&mut union.modified);
dedupe_sort(&mut union.deleted);
let any_changes =
!union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
SourceCursor {
union,
write_commands,
reseed,
no_signal,
any_changes,
degraded,
dead_denies: dead_deny_entries(resolved, workspace_root),
dest_mem: dest.clone(),
binding_id: resolved.name.clone(),
}
}
fn dedupe_sort(v: &mut Vec<String>) {
v.sort();
v.dedup();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_resolves_dot_and_dotdot() {
assert_eq!(
normalize_lexical(Path::new("/a/b/../c/./d")),
PathBuf::from("/a/c/d")
);
assert_eq!(
normalize_lexical(Path::new("/a/../../b")),
PathBuf::from("/b"),
"dotdot past root is clamped"
);
}
#[test]
fn relative_computes_updowns() {
assert_eq!(
relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
PathBuf::from("c/d")
);
assert_eq!(
relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
PathBuf::from("../../x")
);
assert_eq!(
relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
PathBuf::from("crates/x.rs")
);
assert_eq!(
relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
PathBuf::from("../public/crates/x.rs")
);
}
#[test]
fn pathspec_builds_glob_magic_relative_to_git_root() {
let ws = Path::new("/m/graph");
let git_root = Path::new("/m/public");
assert_eq!(
to_git_pathspec("../public/**/*.rs", git_root, ws, false),
":(glob)**/*.rs"
);
assert_eq!(
to_git_pathspec("../public/target/**", git_root, ws, true),
":(glob,exclude)target/**"
);
}
#[test]
fn wildcard_prefixed_pathspec_reanchors_verbatim() {
let ws = Path::new("/m/ws");
let git_root = Path::new("/m/ws/src");
assert_eq!(to_git_pathspec("**/*", git_root, ws, false), ":(glob)**/*");
assert_eq!(
in_repo_pathspec("**/__pycache__/**", git_root, ws, true).as_deref(),
Some(":(glob,exclude)**/__pycache__/**")
);
}
use crate::ingest::resolve::Source;
use crate::pipeline::{MediumType, PatternEntry};
fn git(repo: &Path, args: &[&str]) {
let status = std::process::Command::new("git")
.args(args)
.current_dir(repo)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(
status.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&status.stderr)
);
}
fn primary(scope: Vec<PatternEntry>) -> Source {
Source {
name: "src".to_string(),
medium_type: MediumType::Codebase,
pointer: String::new(),
change_detection: Some("git".to_string()),
scope,
engagement: None,
preparation: None,
}
}
#[test]
fn deny_dialect_fixture_matches_engine_slice() {
let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../plugins/claude-code/hooks/deny-dialect-fixture.json");
let raw = std::fs::read(&fixture_path)
.unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture_path.display()));
let fixture: serde_json::Value = serde_json::from_slice(&raw).unwrap();
let strs = |key: &str| -> Vec<String> {
fixture[key]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect()
};
let entries = strs("entries");
let blocked = strs("blocked");
let allowed = strs("allowed");
let ws = tempfile::tempdir().unwrap();
for rel in blocked.iter().chain(allowed.iter()) {
let path = ws.path().join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "x").unwrap();
}
let source = primary(vec![PatternEntry {
path: "**".to_string(),
mode: PatternMode::Allow,
}]);
let mut got = enumerate_facet_files(&source, &entries, ws.path());
got.sort();
let mut want = allowed.clone();
want.sort();
assert_eq!(
got, want,
"engine slice must equal the fixture `allowed` set"
);
for b in &blocked {
assert!(
!got.contains(b),
"denied `{b}` leaked into the engine slice"
);
}
for a in &allowed {
assert!(
got.contains(a),
"allowed `{a}` missing from the engine slice"
);
}
}
#[test]
fn active_deny_file_overwrites_and_writes_empty() {
let ws = tempfile::tempdir().unwrap();
let path = active_deny_path(ws.path());
write_active_deny_file(ws.path(), "x-graph", &["dev/**".to_string()]);
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(v["ingest"], "x-graph");
assert_eq!(v["deny_paths"], serde_json::json!(["dev/**"]));
write_active_deny_file(ws.path(), "y-graph", &["**/VISION.md".to_string()]);
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(v["ingest"], "y-graph");
assert_eq!(v["deny_paths"], serde_json::json!(["**/VISION.md"]));
write_active_deny_file(ws.path(), "z-graph", &[]);
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(v["ingest"], "z-graph");
assert_eq!(v["deny_paths"], serde_json::json!([]));
}
#[test]
fn out_of_repo_deny_pathspec_is_dropped() {
let ws = Path::new("/m/graph");
let git_root = Path::new("/m/public");
assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
assert_eq!(
in_repo_pathspec("../public/target/**", git_root, ws, true),
Some(":(glob,exclude)target/**".to_string())
);
}
#[test]
fn git_slice_survives_cross_repo_deny() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
std::fs::write(root.join("keep.rs"), "one").unwrap();
git(root, &["init", "-q"]);
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "seed"]);
let baseline = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(root.join("keep.rs"), "two").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let source = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
match outcome {
SliceOutcome::Changed { slice, .. } => {
assert_eq!(slice.modified, vec!["keep.rs"]);
}
other => panic!("expected Changed (deny dropped), got {other:?}"),
}
}
#[test]
fn git_slice_diffs_baseline_to_head() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
git(root, &["init", "-q"]);
std::fs::write(root.join("keep.rs"), "one").unwrap();
std::fs::write(root.join("gone.rs"), "bye").unwrap();
std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let baseline = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(root.join("keep.rs"), "two").unwrap();
std::fs::remove_file(root.join("gone.rs")).unwrap();
std::fs::write(root.join("new.rs"), "hi").unwrap();
std::fs::write(root.join("note.md"), "still ignored").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let source = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
match outcome {
SliceOutcome::Changed {
slice, degraded, ..
} => {
assert!(!degraded);
assert_eq!(slice.added, vec!["new.rs"]);
assert_eq!(slice.modified, vec!["keep.rs"]);
assert_eq!(slice.deleted, vec!["gone.rs"]);
}
other => panic!("expected Changed, got {other:?}"),
}
let head = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
assert!(matches!(
compute_git_slice(&source, &[], root, Some(&head)),
SliceOutcome::Unchanged { .. }
));
assert!(matches!(
compute_git_slice(&source, &[], root, None),
SliceOutcome::Reseed { .. }
));
}
#[test]
fn enumerate_honours_allow_and_deny() {
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("a.rs"), "").unwrap();
std::fs::write(root.join("sub/b.rs"), "").unwrap();
std::fs::write(root.join("c.md"), "").unwrap();
let source = primary(vec![
PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
},
PatternEntry {
path: "sub/**".to_string(),
mode: PatternMode::Deny,
},
]);
assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
let mut graph_source = source.clone();
graph_source.medium_type = MediumType::Graph;
assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
}
#[test]
fn mtime_driver_reseeds_then_diffs_precisely() {
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
let cache = root.join(".memstead.cache").join("ingest");
std::fs::write(root.join("a.rs"), "one").unwrap();
std::fs::write(root.join("gone.rs"), "bye").unwrap();
let source = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
SliceOutcome::Reseed { token } => token,
other => panic!("expected Reseed, got {other:?}"),
};
std::fs::write(root.join("a.rs"), "one-longer").unwrap();
std::fs::remove_file(root.join("gone.rs")).unwrap();
std::fs::write(root.join("new.rs"), "x").unwrap();
match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
SliceOutcome::Changed {
slice, degraded, ..
} => {
assert!(
!degraded,
"memo present → precise, not a degraded full scan"
);
assert_eq!(slice.added, vec!["new.rs"]);
assert_eq!(slice.modified, vec!["a.rs"]);
assert_eq!(
slice.deleted,
vec!["gone.rs"],
"deletions come from the memo"
);
}
other => panic!("expected Changed, got {other:?}"),
}
let stale = super::super::change_detection::serialize_digest_token(
&super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
);
match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
other => panic!("expected degraded Changed, got {other:?}"),
}
}
fn head_sha(repo: &Path) -> String {
String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string()
}
fn slice_contains(slice: &Slice, path: &str) -> bool {
let p = path.to_string();
slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
}
fn mtime_token(source: &Source, deny: &[String], root: &Path) -> String {
let files = enumerate_facet_files(source, deny, root);
serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
}
#[test]
fn deny_paths_excluded_from_every_strategy_and_token() {
use crate::binding::BuildMode;
use crate::ingest::refinement::next_batch;
use crate::pipeline::IngestTrigger;
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
let cache = root.join(".memstead.cache").join("ingest");
git(root, &["init", "-q"]);
std::fs::write(root.join("keep.rs"), "one").unwrap();
std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let baseline = head_sha(root);
std::fs::write(root.join("keep.rs"), "two").unwrap();
std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let source = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
let deny = vec!["denied.rs".to_string()];
match compute_git_slice(&source, &deny, root, Some(&baseline)) {
SliceOutcome::Changed { slice, .. } => {
assert_eq!(slice.modified, vec!["keep.rs"]);
assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
}
other => panic!("git: expected Changed, got {other:?}"),
}
match compute_git_slice(&source, &[], root, Some(&baseline)) {
SliceOutcome::Changed { slice, .. } => {
assert!(
slice_contains(&slice, "denied.rs"),
"un-denied, denied.rs is a genuine git change"
);
}
other => panic!("git(no-deny): expected Changed, got {other:?}"),
}
assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
assert!(
enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
"un-denied, denied.rs is enumerated"
);
let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
SliceOutcome::Reseed { token } => token,
other => panic!("mtime reseed expected, got {other:?}"),
};
std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
SliceOutcome::Changed { slice, .. } => {
assert_eq!(slice.modified, vec!["keep.rs"]);
assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
}
other => panic!("mtime: expected Changed, got {other:?}"),
}
let token_present = mtime_token(&source, &deny, root);
std::fs::remove_file(root.join("denied.rs")).unwrap();
let token_absent = mtime_token(&source, &deny, root);
assert_eq!(
token_present, token_absent,
"denied.rs must not influence the mtime digest / source_moved token"
);
std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
let resolved = ResolvedIngest {
name: "ing".to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 50,
deny_paths: deny.clone(),
projection_ref: "m/p".to_string(),
projection_mem: "m".to_string(),
projection_name: "p".to_string(),
intent: None,
sources: vec![ResolvedSource::Primary(source.clone())],
destination_mem: "m".to_string(),
rules: None,
post_actions: None,
};
let batch = next_batch(&resolved, root, &cache, 20).unwrap();
assert!(
batch.files.contains(&"keep.rs".to_string()),
"keep.rs batched"
);
assert!(
!batch.files.contains(&"denied.rs".to_string()),
"denied.rs must never enter a refinement batch"
);
}
#[test]
fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
let cache = root.join(".memstead.cache").join("ingest");
git(root, &["init", "-q"]);
std::fs::write(root.join("a.rs"), "one").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let baseline = head_sha(root);
std::fs::write(root.join("a.rs"), "two").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let unscoped = primary(vec![PatternEntry {
path: "target/**".to_string(),
mode: PatternMode::Deny,
}]);
assert_eq!(
compute_git_slice(&unscoped, &[], root, Some(&baseline)),
SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped
},
"git refuses an unscoped facet"
);
assert_eq!(
compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped
},
"mtime refuses an unscoped facet identically"
);
let empty_scope = primary(vec![]);
assert_eq!(
compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
SliceOutcome::NoSignal {
reason: NoSignalReason::Unscoped
}
);
let scoped = primary(vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]);
assert!(
matches!(
compute_git_slice(&scoped, &[], root, Some(&baseline)),
SliceOutcome::Changed { .. }
),
"scoped facet + empty deny_paths → normal git slice, not a refusal"
);
assert!(
matches!(
compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
SliceOutcome::Reseed { .. }
),
"scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
);
}
#[test]
fn unscoped_facet_emits_no_refinement_batch() {
use crate::binding::BuildMode;
use crate::ingest::refinement::next_batch;
use crate::pipeline::IngestTrigger;
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
let cache = root.join(".memstead.cache").join("ingest");
std::fs::write(root.join("a.rs"), "x").unwrap();
let resolved = ResolvedIngest {
name: "ing".to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 50,
deny_paths: vec![],
projection_ref: "m/p".to_string(),
projection_mem: "m".to_string(),
projection_name: "p".to_string(),
intent: None,
sources: vec![ResolvedSource::Primary(primary(vec![]))],
destination_mem: "m".to_string(),
rules: None,
post_actions: None,
};
assert!(
next_batch(&resolved, root, &cache, 20).is_none(),
"an all-unscoped ingest emits no refinement batch"
);
}
#[test]
fn compute_source_cursor_notes_no_signal_reasons() {
use crate::binding::BuildMode;
use crate::pipeline::IngestTrigger;
let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
std::fs::write(root.join("a.rs"), "x").unwrap();
let allow_rs = || {
vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}]
};
let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
ResolvedSource::Primary(Source {
name: facet.to_string(),
medium_type: MediumType::Filesystem,
pointer: String::new(),
change_detection: Some(declared.to_string()),
scope,
engagement: None,
preparation: None,
})
};
let resolved = ResolvedIngest {
name: "ing".to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
deny_paths: vec![],
projection_ref: "m/p".to_string(),
projection_mem: "m".to_string(),
projection_name: "p".to_string(),
intent: None,
sources: vec![
src("plan", "none", allow_rs()),
src("blind", "mtime", vec![]),
src("watched", "mtime", allow_rs()),
],
destination_mem: "m".to_string(),
rules: None,
post_actions: None,
};
let cursor = compute_source_cursor(&engine, &resolved, root);
let reasons: BTreeMap<&str, NoSignalReason> = cursor
.no_signal
.iter()
.map(|n| (n.source.as_str(), n.reason))
.collect();
assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
assert!(
!reasons.contains_key("watched"),
"a first-seen (reseed) source is not a no-signal note"
);
assert_eq!(cursor.no_signal.len(), 2);
assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
let out = crate::ingest::brief::render_changed_slice(&cursor);
assert!(out.contains("- `plan`: `signal:none`"));
assert!(out.contains("- `blind`: unscoped facet"));
}
fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
paths
.iter()
.map(|p| {
(
(*p).to_string(),
super::super::change_detection::StatEntry { mtime: 1, size: 1 },
)
})
.collect()
}
#[test]
fn engine_state_never_enumerates_even_when_allowed() {
let ws = tempfile::tempdir().unwrap();
let root = ws.path();
for rel in [
".memstead/state/findings/muehle/f.json",
".memstead/projections/muehle/f.json",
".memstead.cache/ingest/source-cursor/muehle/f/f.json",
"custom-repo/README.md",
"Allgemein/Protokoll.md",
"Allgemein/Vertrag.md",
] {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "x").unwrap();
}
std::fs::write(
root.join(".memstead/workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
std::fs::write(
root.join(".memstead/state/mounts.json"),
serde_json::json!({
"format": "memstead-mounts-3",
"mounts": [{
"mem": "muehle",
"schema": "default@1.0.0",
"storage": {
"type": "git-branch",
"gitdir": "custom-repo/.git",
"branch": "refs/heads/muehle"
},
"capability": "write",
"lifecycle": "eager",
"cross_linkable": true
}]
})
.to_string(),
)
.unwrap();
let source = primary(vec![
PatternEntry {
path: "**/*".to_string(),
mode: PatternMode::Allow,
},
PatternEntry {
path: ".memstead/**".to_string(),
mode: PatternMode::Allow,
},
PatternEntry {
path: "custom-repo/**".to_string(),
mode: PatternMode::Allow,
},
]);
let got = enumerate_facet_files(&source, &[], root);
assert_eq!(
got,
vec!["Allgemein/Protokoll.md", "Allgemein/Vertrag.md"],
"only source artifacts may enter the denominator"
);
}
#[test]
fn git_slice_excludes_engine_state() {
let repo = tempfile::tempdir().unwrap();
let root = repo.path();
git(root, &["init", "-q"]);
std::fs::write(
root.join("workspace.rs"), "x",
)
.unwrap();
std::fs::create_dir_all(root.join(".memstead/state")).unwrap();
std::fs::write(
root.join(".memstead/workspace.toml"),
"format = \"memstead-git-branch-2\"\n\n[persistence_adapter]\nname = \"file-two-layer\"\n",
)
.unwrap();
std::fs::write(
root.join(".memstead/state/mounts.json"),
serde_json::json!({
"format": "memstead-mounts-3",
"mounts": [{
"mem": "muehle",
"schema": "default@1.0.0",
"storage": {
"type": "git-branch",
"gitdir": "custom-repo/.git",
"branch": "refs/heads/muehle"
},
"capability": "write",
"lifecycle": "eager",
"cross_linkable": true
}]
})
.to_string(),
)
.unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let baseline = String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
std::fs::write(root.join("real.md"), "signal").unwrap();
std::fs::write(root.join(".memstead/state/findings.json"), "self").unwrap();
std::fs::create_dir_all(root.join("custom-repo")).unwrap();
std::fs::write(root.join("custom-repo/README.md"), "repo").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "move"]);
let source = primary(vec![PatternEntry {
path: "**/*".to_string(),
mode: PatternMode::Allow,
}]);
match compute_git_slice(&source, &[], root, Some(&baseline)) {
SliceOutcome::Changed { slice, .. } => {
assert_eq!(
slice.added,
vec!["real.md"],
"engine state leaked: {slice:?}"
);
assert!(slice.modified.is_empty(), "{slice:?}");
}
other => panic!("expected Changed, got {other:?}"),
}
}
}