1use std::collections::BTreeMap;
70use std::path::{Component, Path, PathBuf};
71use std::process::Command;
72
73use globset::{Glob, GlobSet, GlobSetBuilder};
74
75use crate::Engine;
76use crate::pipeline::{MediumType, PatternMode};
77
78use super::brief::{NoSignalNote, SourceCursor, SyncCommand};
79use super::change_detection::{
80 StatMap, compute_stat_map, digest_stat_map, parse_digest_token, serialize_digest_token,
81};
82use super::resolve::{
83 ChangeStrategy, ResolvedIngest, ResolvedPrimarySource, ResolvedSource, find_git_root,
84 resolve_change_strategy,
85};
86use super::slice::{
87 NoSignalReason, Slice, SliceOutcome, graph_slice_outcome, is_git_token, mtime_slice_outcome,
88};
89
90fn normalize_lexical(path: &Path) -> PathBuf {
94 let mut out: Vec<Component> = Vec::new();
95 for comp in path.components() {
96 match comp {
97 Component::CurDir => {}
98 Component::ParentDir => match out.last() {
99 Some(Component::Normal(_)) => {
100 out.pop();
101 }
102 Some(Component::RootDir | Component::Prefix(_)) => {}
103 _ => out.push(comp),
104 },
105 other => out.push(other),
106 }
107 }
108 out.iter().collect()
109}
110
111fn relative_path(from: &Path, to: &Path) -> PathBuf {
114 let from = normalize_lexical(from);
115 let to = normalize_lexical(to);
116 let from_comps: Vec<Component> = from.components().collect();
117 let to_comps: Vec<Component> = to.components().collect();
118 let mut common = 0;
119 while common < from_comps.len()
120 && common < to_comps.len()
121 && from_comps[common] == to_comps[common]
122 {
123 common += 1;
124 }
125 let mut result = PathBuf::new();
126 for _ in common..from_comps.len() {
127 result.push("..");
128 }
129 for comp in &to_comps[common..] {
130 result.push(comp.as_os_str());
131 }
132 result
133}
134
135fn medium_base(pointer: &str, workspace_root: &Path) -> PathBuf {
137 if pointer.is_empty() {
138 workspace_root.to_path_buf()
139 } else {
140 normalize_lexical(&workspace_root.join(pointer))
141 }
142}
143
144fn git_head(git_root: &Path) -> Option<String> {
146 let out = Command::new("git")
147 .args(["rev-parse", "HEAD"])
148 .current_dir(git_root)
149 .output()
150 .ok()?;
151 if !out.status.success() {
152 return None;
153 }
154 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
155 (!sha.is_empty()).then_some(sha)
156}
157
158fn to_git_pathspec(pattern: &str, git_root: &Path, workspace_root: &Path, exclude: bool) -> String {
161 let resolved = normalize_lexical(&workspace_root.join(pattern));
162 let git_rel = relative_path(git_root, &resolved);
163 let magic = if exclude {
164 ":(glob,exclude)"
165 } else {
166 ":(glob)"
167 };
168 format!("{magic}{}", git_rel.to_string_lossy())
169}
170
171fn in_repo_pathspec(
176 pattern: &str,
177 git_root: &Path,
178 workspace_root: &Path,
179 exclude: bool,
180) -> Option<String> {
181 let resolved = normalize_lexical(&workspace_root.join(pattern));
182 let git_rel = relative_path(git_root, &resolved);
183 if git_rel
184 .components()
185 .next()
186 .is_some_and(|c| c == Component::ParentDir)
187 {
188 return None;
189 }
190 let magic = if exclude {
191 ":(glob,exclude)"
192 } else {
193 ":(glob)"
194 };
195 Some(format!("{magic}{}", git_rel.to_string_lossy()))
196}
197
198fn build_glob_set(patterns: &[&str]) -> Option<GlobSet> {
201 let mut builder = GlobSetBuilder::new();
202 for pattern in patterns {
203 builder.add(Glob::new(pattern).ok()?);
204 }
205 builder.build().ok()
206}
207
208fn facet_unscoped(source: &ResolvedPrimarySource) -> bool {
215 !source.scope.iter().any(|r| r.mode == PatternMode::Allow)
216}
217
218pub fn enumerate_facet_files(
235 source: &ResolvedPrimarySource,
236 deny_paths: &[String],
237 workspace_root: &Path,
238) -> Vec<String> {
239 if !matches!(
240 source.medium_type,
241 MediumType::Codebase | MediumType::Filesystem
242 ) {
243 return Vec::new();
244 }
245 let mut allows: Vec<&str> = Vec::new();
246 let mut denies: Vec<&str> = Vec::new();
247 for rule in &source.scope {
248 match rule.mode {
249 PatternMode::Allow => allows.push(&rule.path),
250 PatternMode::Deny => denies.push(&rule.path),
251 }
252 }
253 for dp in deny_paths {
258 denies.push(dp);
259 }
260 if allows.is_empty() {
261 return Vec::new();
262 }
263 let Some(allow_set) = build_glob_set(&allows) else {
264 return Vec::new();
265 };
266 let deny_set = if denies.is_empty() {
267 None
268 } else {
269 build_glob_set(&denies)
270 };
271
272 let base = medium_base(&source.medium_pointer, workspace_root);
276 let mut out: Vec<String> = Vec::new();
277 let mut stack = vec![base];
278 while let Some(dir) = stack.pop() {
279 let Ok(entries) = std::fs::read_dir(&dir) else {
280 continue;
281 };
282 for entry in entries.flatten() {
283 let Ok(file_type) = entry.file_type() else {
284 continue;
285 };
286 let path = entry.path();
287 if file_type.is_dir() {
288 stack.push(path);
289 } else if file_type.is_file() {
290 let rel = relative_path(workspace_root, &normalize_lexical(&path))
291 .to_string_lossy()
292 .to_string();
293 let denied = deny_set.as_ref().is_some_and(|d| d.is_match(&rel));
294 if allow_set.is_match(&rel) && !denied {
295 out.push(rel);
296 }
297 }
298 }
299 }
300 out.sort();
301 out.dedup();
302 out
303}
304
305fn compute_git_slice(
308 source: &ResolvedPrimarySource,
309 deny_paths: &[String],
310 workspace_root: &Path,
311 baseline: Option<&str>,
312) -> SliceOutcome {
313 let base = medium_base(&source.medium_pointer, workspace_root);
314 let Some(git_root) = find_git_root(&base) else {
315 return SliceOutcome::NoSignal {
316 reason: NoSignalReason::GitUnavailable,
317 };
318 };
319 let Some(head) = git_head(&git_root) else {
320 return SliceOutcome::NoSignal {
321 reason: NoSignalReason::GitUnavailable,
322 };
323 };
324
325 let baseline = match baseline {
326 Some(b) if is_git_token(b) => b,
327 _ => return SliceOutcome::Reseed { token: head },
329 };
330 if baseline == head {
331 return SliceOutcome::Unchanged { token: head };
332 }
333
334 let mut allows: Vec<&str> = Vec::new();
336 let mut denies: Vec<&str> = Vec::new();
337 for rule in &source.scope {
338 match rule.mode {
339 PatternMode::Allow => allows.push(&rule.path),
340 PatternMode::Deny => denies.push(&rule.path),
341 }
342 }
343 if allows.is_empty() {
344 return SliceOutcome::NoSignal {
347 reason: NoSignalReason::Unscoped,
348 };
349 }
350 for dp in deny_paths {
351 denies.push(dp);
352 }
353 let mut specs: Vec<String> = Vec::with_capacity(allows.len() + denies.len());
354 for a in &allows {
355 specs.push(to_git_pathspec(a, &git_root, workspace_root, false));
356 }
357 for d in &denies {
358 if let Some(spec) = in_repo_pathspec(d, &git_root, workspace_root, true) {
367 specs.push(spec);
368 }
369 }
370
371 let mut cmd = Command::new("git");
372 cmd.args([
373 "diff",
374 "--no-renames",
375 "--name-status",
376 baseline,
377 &head,
378 "--",
379 ]);
380 cmd.args(&specs);
381 cmd.current_dir(&git_root);
382 let out = match cmd.output() {
383 Ok(o) if o.status.success() => o,
384 _ => {
387 return SliceOutcome::NoSignal {
388 reason: NoSignalReason::GitUnavailable,
389 };
390 }
391 };
392 let text = String::from_utf8_lossy(&out.stdout);
393
394 let mut slice = Slice::default();
395 for line in text.lines() {
396 if line.trim().is_empty() {
397 continue;
398 }
399 let Some(tab) = line.find('\t') else { continue };
400 let status = line[..tab].trim();
401 let git_path = line[tab + 1..].trim();
402 let ws_path = relative_path(workspace_root, &normalize_lexical(&git_root.join(git_path)))
403 .to_string_lossy()
404 .to_string();
405 match status.chars().next() {
406 Some('A') => slice.added.push(ws_path),
407 Some('D') => slice.deleted.push(ws_path),
408 _ => slice.modified.push(ws_path),
410 }
411 }
412 slice.added.sort();
413 slice.modified.sort();
414 slice.deleted.sort();
415 SliceOutcome::Changed {
416 token: head,
417 slice,
418 degraded: false,
419 }
420}
421
422fn compute_graph_slice(engine: &Engine, source_mem: &str, baseline: Option<&str>) -> SliceOutcome {
426 let current = match engine.mem_head_sha(source_mem) {
427 Ok(Some(sha)) => sha,
428 _ => {
430 return SliceOutcome::NoSignal {
431 reason: NoSignalReason::GraphSnapshotMissing,
432 };
433 }
434 };
435 let changed = matches!(baseline, Some(b) if is_git_token(b) && b != current);
437 if changed {
438 let baseline = baseline.expect("changed implies a baseline");
439 match engine.changes_since(source_mem, baseline, None) {
440 Ok(report) => graph_slice_outcome(Some(baseline), ¤t, &report.changes),
441 Err(_) => SliceOutcome::NoSignal {
443 reason: NoSignalReason::GraphSnapshotMissing,
444 },
445 }
446 } else {
447 graph_slice_outcome(baseline, ¤t, &[])
448 }
449}
450
451fn cursor_memo_path(cache_root: &Path, ingest_name: &str, facet_ref: &str) -> PathBuf {
467 let safe: String = facet_ref
468 .chars()
469 .map(|c| {
470 if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
471 c
472 } else {
473 '_'
474 }
475 })
476 .collect();
477 cache_root
478 .join("source-cursor")
479 .join(ingest_name)
480 .join(format!("{safe}.json"))
481}
482
483fn read_cursor_memo(
485 cache_root: &Path,
486 ingest: &str,
487 facet: &str,
488 aggregate: &str,
489) -> Option<StatMap> {
490 let bytes = std::fs::read(cursor_memo_path(cache_root, ingest, facet)).ok()?;
491 let memo: BTreeMap<String, StatMap> = serde_json::from_slice(&bytes).ok()?;
492 memo.get(aggregate).cloned()
493}
494
495fn write_cursor_memo(cache_root: &Path, ingest: &str, facet: &str, aggregate: &str, map: &StatMap) {
498 let path = cursor_memo_path(cache_root, ingest, facet);
499 let mut memo: BTreeMap<String, StatMap> = std::fs::read(&path)
500 .ok()
501 .and_then(|b| serde_json::from_slice(&b).ok())
502 .unwrap_or_default();
503 memo.insert(aggregate.to_string(), map.clone());
504 if memo.len() > 3 {
505 let drop: Vec<String> = memo
507 .keys()
508 .filter(|k| k.as_str() != aggregate)
509 .skip(2)
510 .cloned()
511 .collect();
512 for key in drop {
513 memo.remove(&key);
514 }
515 }
516 if let Some(parent) = path.parent() {
517 let _ = std::fs::create_dir_all(parent);
518 }
519 if let Ok(bytes) = serde_json::to_vec(&memo) {
520 let _ = std::fs::write(&path, bytes);
521 }
522}
523
524fn active_deny_path(workspace_root: &Path) -> PathBuf {
536 workspace_root
537 .join(".memstead.cache")
538 .join("projection")
539 .join("active-deny-paths.json")
540}
541
542pub fn write_active_deny_file(workspace_root: &Path, ingest_name: &str, deny_paths: &[String]) {
555 let path = active_deny_path(workspace_root);
556 let _ = std::fs::remove_file(&path);
559 if let Some(parent) = path.parent() {
560 let _ = std::fs::create_dir_all(parent);
561 }
562 let payload = serde_json::json!({
563 "ingest": ingest_name,
564 "deny_paths": deny_paths,
565 });
566 if let Ok(bytes) = serde_json::to_vec(&payload) {
567 let _ = std::fs::write(&path, bytes);
568 }
569}
570
571const DEAD_DENY_SKIP_DIRS: &[&str] = &[
574 ".git",
575 "node_modules",
576 "target",
577 "dist",
578 ".memstead.cache",
579 ".sqlx",
580 ".svn",
581 ".hg",
582];
583
584fn walk_tree_bounded(base: &Path, workspace_root: &Path, cap: usize) -> Option<Vec<String>> {
590 let mut out: Vec<String> = Vec::new();
591 let mut stack = vec![base.to_path_buf()];
592 while let Some(dir) = stack.pop() {
593 let Ok(entries) = std::fs::read_dir(&dir) else {
594 continue;
595 };
596 for entry in entries.flatten() {
597 let Ok(file_type) = entry.file_type() else {
598 continue;
599 };
600 let path = entry.path();
601 if file_type.is_dir() {
602 let skip = path
603 .file_name()
604 .and_then(|n| n.to_str())
605 .is_some_and(|n| DEAD_DENY_SKIP_DIRS.contains(&n));
606 if !skip {
607 stack.push(path);
608 }
609 } else if file_type.is_file() {
610 if out.len() >= cap {
611 return None;
612 }
613 out.push(
614 relative_path(workspace_root, &normalize_lexical(&path))
615 .to_string_lossy()
616 .to_string(),
617 );
618 }
619 }
620 }
621 Some(out)
622}
623
624fn dead_deny_entries(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
635 if resolved.deny_paths.is_empty() {
636 return Vec::new();
637 }
638 let base = find_git_root(workspace_root).unwrap_or_else(|| workspace_root.to_path_buf());
639 let Some(files) = walk_tree_bounded(&base, workspace_root, 100_000) else {
640 return Vec::new();
641 };
642 let mut dead: Vec<String> = Vec::new();
643 for entry in &resolved.deny_paths {
644 let Some(set) = build_glob_set(&[entry.as_str()]) else {
645 continue;
648 };
649 if !files.iter().any(|f| set.is_match(f)) {
650 dead.push(entry.clone());
651 }
652 }
653 dead
654}
655
656fn compute_mtime_slice(
661 source: &ResolvedPrimarySource,
662 ingest_name: &str,
663 deny_paths: &[String],
664 workspace_root: &Path,
665 cache_root: &Path,
666 baseline: Option<&str>,
667) -> SliceOutcome {
668 if facet_unscoped(source) {
669 return SliceOutcome::NoSignal {
672 reason: NoSignalReason::Unscoped,
673 };
674 }
675 let files = enumerate_facet_files(source, deny_paths, workspace_root);
676 let now_map = compute_stat_map(&files, workspace_root);
677 let now_digest = digest_stat_map(&now_map);
678 write_cursor_memo(
679 cache_root,
680 ingest_name,
681 &source.facet_ref,
682 &now_digest.aggregate,
683 &now_map,
684 );
685 let prev_map = baseline.and_then(parse_digest_token).and_then(|base| {
686 read_cursor_memo(cache_root, ingest_name, &source.facet_ref, &base.aggregate)
687 });
688 mtime_slice_outcome(baseline, prev_map.as_ref(), &now_map)
689}
690
691fn current_primary_token(
695 engine: &Engine,
696 source: &ResolvedPrimarySource,
697 deny_paths: &[String],
698 workspace_root: &Path,
699) -> Option<String> {
700 match resolve_change_strategy(source, workspace_root) {
701 ChangeStrategy::Git => git_head(&find_git_root(&medium_base(
702 &source.medium_pointer,
703 workspace_root,
704 ))?),
705 ChangeStrategy::Graph => engine.mem_head_sha(&source.medium_pointer).ok().flatten(),
706 ChangeStrategy::Mtime => {
707 if facet_unscoped(source) {
708 None
711 } else {
712 let files = enumerate_facet_files(source, deny_paths, workspace_root);
713 Some(serialize_digest_token(&digest_stat_map(&compute_stat_map(
714 &files,
715 workspace_root,
716 ))))
717 }
718 }
719 ChangeStrategy::None => None,
720 }
721}
722
723pub fn source_moved(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> bool {
730 let dest = &resolved.destination_mem;
731 let baseline_map = engine
732 .mem_config_for(dest)
733 .map(|c| c.sync_state.clone())
734 .unwrap_or_default();
735
736 for source in &resolved.sources {
737 let (facet_ref, current) = match source {
738 ResolvedSource::Primary(p) => (
739 p.facet_ref.clone(),
740 current_primary_token(engine, p, &resolved.deny_paths, workspace_root),
741 ),
742 ResolvedSource::Reference { mem } => {
743 (mem.clone(), engine.mem_head_sha(mem).ok().flatten())
744 }
745 };
746 let key = format!("{}/{}#synced", resolved.name, facet_ref);
747 let Some(baseline) = baseline_map.get(&key) else {
748 continue; };
750 if let Some(current) = current
751 && !current.is_empty()
752 && current != *baseline
753 {
754 return true;
755 }
756 }
757 false
758}
759
760pub fn compute_source_cursor(
763 engine: &Engine,
764 resolved: &ResolvedIngest,
765 workspace_root: &Path,
766) -> SourceCursor {
767 let dest = &resolved.destination_mem;
768 let baseline_map = engine
769 .mem_config_for(dest)
770 .map(|c| c.sync_state.clone())
771 .unwrap_or_default();
772
773 let cache_root = workspace_root.join(".memstead.cache").join("ingest");
774 let mut union = Slice::default();
775 let mut write_commands: Vec<SyncCommand> = Vec::new();
776 let mut reseed: Vec<SyncCommand> = Vec::new();
777 let mut no_signal: Vec<NoSignalNote> = Vec::new();
778 let mut degraded = false;
779
780 for source in &resolved.sources {
781 let (facet_ref, outcome) = match source {
784 ResolvedSource::Primary(p) => {
785 let key = format!("{}/{}#synced", resolved.name, p.facet_ref);
786 let baseline = baseline_map.get(&key).map(String::as_str);
787 let outcome = match resolve_change_strategy(p, workspace_root) {
788 ChangeStrategy::Git => {
789 compute_git_slice(p, &resolved.deny_paths, workspace_root, baseline)
790 }
791 ChangeStrategy::Graph => {
793 compute_graph_slice(engine, &p.medium_pointer, baseline)
794 }
795 ChangeStrategy::Mtime => compute_mtime_slice(
796 p,
797 &resolved.name,
798 &resolved.deny_paths,
799 workspace_root,
800 &cache_root,
801 baseline,
802 ),
803 ChangeStrategy::None => SliceOutcome::NoSignal {
805 reason: NoSignalReason::DetectionNone,
806 },
807 };
808 (p.facet_ref.clone(), outcome)
809 }
810 ResolvedSource::Reference { mem } => {
811 let key = format!("{}/{}#synced", resolved.name, mem);
812 let baseline = baseline_map.get(&key).map(String::as_str);
813 (mem.clone(), compute_graph_slice(engine, mem, baseline))
814 }
815 };
816
817 let key = format!("{}/{}#synced", resolved.name, facet_ref);
818 match outcome {
819 SliceOutcome::Unchanged { .. } => {}
823 SliceOutcome::NoSignal { reason } => no_signal.push(NoSignalNote {
825 source: facet_ref.clone(),
826 reason,
827 }),
828 SliceOutcome::Reseed { token } => reseed.push(SyncCommand { key, token }),
829 SliceOutcome::Changed {
830 token,
831 slice,
832 degraded: d,
833 } => {
834 union.added.extend(slice.added);
835 union.modified.extend(slice.modified);
836 union.deleted.extend(slice.deleted);
837 degraded |= d;
838 write_commands.push(SyncCommand { key, token });
839 }
840 }
841 }
842
843 dedupe_sort(&mut union.added);
844 dedupe_sort(&mut union.modified);
845 dedupe_sort(&mut union.deleted);
846 let any_changes =
847 !union.added.is_empty() || !union.modified.is_empty() || !union.deleted.is_empty();
848
849 SourceCursor {
850 union,
851 write_commands,
852 reseed,
853 no_signal,
854 any_changes,
855 degraded,
856 dead_denies: dead_deny_entries(resolved, workspace_root),
857 dest_mem: dest.clone(),
858 binding_id: resolved.name.clone(),
862 }
863}
864
865fn dedupe_sort(v: &mut Vec<String>) {
866 v.sort();
867 v.dedup();
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873
874 #[test]
875 fn normalize_resolves_dot_and_dotdot() {
876 assert_eq!(
877 normalize_lexical(Path::new("/a/b/../c/./d")),
878 PathBuf::from("/a/c/d")
879 );
880 assert_eq!(
881 normalize_lexical(Path::new("/a/../../b")),
882 PathBuf::from("/b"),
883 "dotdot past root is clamped"
884 );
885 }
886
887 #[test]
888 fn relative_computes_updowns() {
889 assert_eq!(
890 relative_path(Path::new("/a/b"), Path::new("/a/b/c/d")),
891 PathBuf::from("c/d")
892 );
893 assert_eq!(
894 relative_path(Path::new("/a/b/c"), Path::new("/a/x")),
895 PathBuf::from("../../x")
896 );
897 assert_eq!(
899 relative_path(Path::new("/m/public"), Path::new("/m/public/crates/x.rs")),
900 PathBuf::from("crates/x.rs")
901 );
902 assert_eq!(
903 relative_path(Path::new("/m/graph"), Path::new("/m/public/crates/x.rs")),
904 PathBuf::from("../public/crates/x.rs")
905 );
906 }
907
908 #[test]
909 fn pathspec_builds_glob_magic_relative_to_git_root() {
910 let ws = Path::new("/m/graph");
911 let git_root = Path::new("/m/public");
912 assert_eq!(
913 to_git_pathspec("../public/**/*.rs", git_root, ws, false),
914 ":(glob)**/*.rs"
915 );
916 assert_eq!(
917 to_git_pathspec("../public/target/**", git_root, ws, true),
918 ":(glob,exclude)target/**"
919 );
920 }
921
922 use crate::ingest::resolve::ResolvedPrimarySource;
923 use crate::pipeline::{MediumType, PatternEntry};
924
925 fn git(repo: &Path, args: &[&str]) {
926 let status = std::process::Command::new("git")
927 .args(args)
928 .current_dir(repo)
929 .env("GIT_AUTHOR_NAME", "t")
930 .env("GIT_AUTHOR_EMAIL", "t@t")
931 .env("GIT_COMMITTER_NAME", "t")
932 .env("GIT_COMMITTER_EMAIL", "t@t")
933 .output()
934 .unwrap();
935 assert!(
936 status.status.success(),
937 "git {args:?}: {}",
938 String::from_utf8_lossy(&status.stderr)
939 );
940 }
941
942 fn primary(scope: Vec<PatternEntry>) -> ResolvedPrimarySource {
943 ResolvedPrimarySource {
944 facet_ref: "src".to_string(),
945 medium: "m".to_string(),
946 medium_type: MediumType::Codebase,
947 medium_pointer: String::new(),
948 declared_change_detection: Some("git".to_string()),
949 scope,
950 preparation: None,
951 }
952 }
953
954 #[test]
962 fn deny_dialect_fixture_matches_engine_slice() {
963 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
964 .join("../../plugins/claude-code/hooks/deny-dialect-fixture.json");
965 let raw = std::fs::read(&fixture_path)
966 .unwrap_or_else(|e| panic!("read fixture {}: {e}", fixture_path.display()));
967 let fixture: serde_json::Value = serde_json::from_slice(&raw).unwrap();
968 let strs = |key: &str| -> Vec<String> {
969 fixture[key]
970 .as_array()
971 .unwrap()
972 .iter()
973 .map(|v| v.as_str().unwrap().to_string())
974 .collect()
975 };
976 let entries = strs("entries");
977 let blocked = strs("blocked");
978 let allowed = strs("allowed");
979
980 let ws = tempfile::tempdir().unwrap();
981 for rel in blocked.iter().chain(allowed.iter()) {
982 let path = ws.path().join(rel);
983 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
984 std::fs::write(&path, "x").unwrap();
985 }
986
987 let source = primary(vec![PatternEntry {
989 path: "**".to_string(),
990 mode: PatternMode::Allow,
991 }]);
992 let mut got = enumerate_facet_files(&source, &entries, ws.path());
993 got.sort();
994 let mut want = allowed.clone();
995 want.sort();
996 assert_eq!(
997 got, want,
998 "engine slice must equal the fixture `allowed` set"
999 );
1000
1001 for b in &blocked {
1002 assert!(
1003 !got.contains(b),
1004 "denied `{b}` leaked into the engine slice"
1005 );
1006 }
1007 for a in &allowed {
1008 assert!(
1009 got.contains(a),
1010 "allowed `{a}` missing from the engine slice"
1011 );
1012 }
1013 }
1014
1015 #[test]
1019 fn active_deny_file_overwrites_and_writes_empty() {
1020 let ws = tempfile::tempdir().unwrap();
1021 let path = active_deny_path(ws.path());
1022
1023 write_active_deny_file(ws.path(), "x-graph", &["dev/**".to_string()]);
1024 let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1025 assert_eq!(v["ingest"], "x-graph");
1026 assert_eq!(v["deny_paths"], serde_json::json!(["dev/**"]));
1027
1028 write_active_deny_file(ws.path(), "y-graph", &["**/VISION.md".to_string()]);
1030 let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1031 assert_eq!(v["ingest"], "y-graph");
1032 assert_eq!(v["deny_paths"], serde_json::json!(["**/VISION.md"]));
1033
1034 write_active_deny_file(ws.path(), "z-graph", &[]);
1036 let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
1037 assert_eq!(v["ingest"], "z-graph");
1038 assert_eq!(v["deny_paths"], serde_json::json!([]));
1039 }
1040
1041 #[test]
1045 fn out_of_repo_deny_pathspec_is_dropped() {
1046 let ws = Path::new("/m/graph");
1047 let git_root = Path::new("/m/public");
1048 assert_eq!(in_repo_pathspec("../dev/**", git_root, ws, true), None);
1050 assert_eq!(in_repo_pathspec("../CLAUDE.md", git_root, ws, true), None);
1051 assert_eq!(
1053 in_repo_pathspec("../public/target/**", git_root, ws, true),
1054 Some(":(glob,exclude)target/**".to_string())
1055 );
1056 }
1057
1058 #[test]
1063 fn git_slice_survives_cross_repo_deny() {
1064 let repo = tempfile::tempdir().unwrap();
1065 let root = repo.path();
1066 std::fs::write(root.join("keep.rs"), "one").unwrap();
1067 git(root, &["init", "-q"]);
1068 git(root, &["add", "-A"]);
1069 git(root, &["commit", "-qm", "seed"]);
1070 let baseline = String::from_utf8(
1071 std::process::Command::new("git")
1072 .args(["rev-parse", "HEAD"])
1073 .current_dir(root)
1074 .output()
1075 .unwrap()
1076 .stdout,
1077 )
1078 .unwrap()
1079 .trim()
1080 .to_string();
1081 std::fs::write(root.join("keep.rs"), "two").unwrap();
1082 git(root, &["add", "-A"]);
1083 git(root, &["commit", "-qm", "move"]);
1084
1085 let source = primary(vec![PatternEntry {
1086 path: "**/*.rs".to_string(),
1087 mode: PatternMode::Allow,
1088 }]);
1089 let outcome = compute_git_slice(&source, &["../dev/**".to_string()], root, Some(&baseline));
1091 match outcome {
1092 SliceOutcome::Changed { slice, .. } => {
1093 assert_eq!(slice.modified, vec!["keep.rs"]);
1094 }
1095 other => panic!("expected Changed (deny dropped), got {other:?}"),
1096 }
1097 }
1098
1099 #[test]
1102 fn git_slice_diffs_baseline_to_head() {
1103 let repo = tempfile::tempdir().unwrap();
1104 let root = repo.path();
1105 git(root, &["init", "-q"]);
1106 std::fs::write(root.join("keep.rs"), "one").unwrap();
1107 std::fs::write(root.join("gone.rs"), "bye").unwrap();
1108 std::fs::write(root.join("note.md"), "ignored-by-scope").unwrap();
1109 git(root, &["add", "-A"]);
1110 git(root, &["commit", "-qm", "base"]);
1111 let baseline = String::from_utf8(
1112 std::process::Command::new("git")
1113 .args(["rev-parse", "HEAD"])
1114 .current_dir(root)
1115 .output()
1116 .unwrap()
1117 .stdout,
1118 )
1119 .unwrap()
1120 .trim()
1121 .to_string();
1122
1123 std::fs::write(root.join("keep.rs"), "two").unwrap();
1125 std::fs::remove_file(root.join("gone.rs")).unwrap();
1126 std::fs::write(root.join("new.rs"), "hi").unwrap();
1127 std::fs::write(root.join("note.md"), "still ignored").unwrap();
1128 git(root, &["add", "-A"]);
1129 git(root, &["commit", "-qm", "move"]);
1130
1131 let source = primary(vec![PatternEntry {
1133 path: "**/*.rs".to_string(),
1134 mode: PatternMode::Allow,
1135 }]);
1136 let outcome = compute_git_slice(&source, &[], root, Some(&baseline));
1137 match outcome {
1138 SliceOutcome::Changed {
1139 slice, degraded, ..
1140 } => {
1141 assert!(!degraded);
1142 assert_eq!(slice.added, vec!["new.rs"]);
1143 assert_eq!(slice.modified, vec!["keep.rs"]);
1144 assert_eq!(slice.deleted, vec!["gone.rs"]);
1145 }
1146 other => panic!("expected Changed, got {other:?}"),
1147 }
1148
1149 let head = String::from_utf8(
1151 std::process::Command::new("git")
1152 .args(["rev-parse", "HEAD"])
1153 .current_dir(root)
1154 .output()
1155 .unwrap()
1156 .stdout,
1157 )
1158 .unwrap()
1159 .trim()
1160 .to_string();
1161 assert!(matches!(
1162 compute_git_slice(&source, &[], root, Some(&head)),
1163 SliceOutcome::Unchanged { .. }
1164 ));
1165
1166 assert!(matches!(
1168 compute_git_slice(&source, &[], root, None),
1169 SliceOutcome::Reseed { .. }
1170 ));
1171 }
1172
1173 #[test]
1176 fn enumerate_honours_allow_and_deny() {
1177 let ws = tempfile::tempdir().unwrap();
1178 let root = ws.path();
1179 std::fs::create_dir_all(root.join("sub")).unwrap();
1180 std::fs::write(root.join("a.rs"), "").unwrap();
1181 std::fs::write(root.join("sub/b.rs"), "").unwrap();
1182 std::fs::write(root.join("c.md"), "").unwrap();
1183
1184 let source = primary(vec![
1187 PatternEntry {
1188 path: "**/*.rs".to_string(),
1189 mode: PatternMode::Allow,
1190 },
1191 PatternEntry {
1192 path: "sub/**".to_string(),
1193 mode: PatternMode::Deny,
1194 },
1195 ]);
1196 assert_eq!(enumerate_facet_files(&source, &[], root), vec!["a.rs"]);
1197
1198 let mut graph_source = source.clone();
1200 graph_source.medium_type = MediumType::Graph;
1201 assert!(enumerate_facet_files(&graph_source, &[], root).is_empty());
1202 }
1203
1204 #[test]
1207 fn mtime_driver_reseeds_then_diffs_precisely() {
1208 let ws = tempfile::tempdir().unwrap();
1209 let root = ws.path();
1210 let cache = root.join(".memstead.cache").join("ingest");
1211 std::fs::write(root.join("a.rs"), "one").unwrap();
1212 std::fs::write(root.join("gone.rs"), "bye").unwrap();
1213 let source = primary(vec![PatternEntry {
1214 path: "**/*.rs".to_string(),
1215 mode: PatternMode::Allow,
1216 }]);
1217
1218 let token = match compute_mtime_slice(&source, "ing", &[], root, &cache, None) {
1220 SliceOutcome::Reseed { token } => token,
1221 other => panic!("expected Reseed, got {other:?}"),
1222 };
1223
1224 std::fs::write(root.join("a.rs"), "one-longer").unwrap();
1226 std::fs::remove_file(root.join("gone.rs")).unwrap();
1227 std::fs::write(root.join("new.rs"), "x").unwrap();
1228
1229 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&token)) {
1231 SliceOutcome::Changed {
1232 slice, degraded, ..
1233 } => {
1234 assert!(
1235 !degraded,
1236 "memo present → precise, not a degraded full scan"
1237 );
1238 assert_eq!(slice.added, vec!["new.rs"]);
1239 assert_eq!(slice.modified, vec!["a.rs"]);
1240 assert_eq!(
1241 slice.deleted,
1242 vec!["gone.rs"],
1243 "deletions come from the memo"
1244 );
1245 }
1246 other => panic!("expected Changed, got {other:?}"),
1247 }
1248
1249 let stale = super::super::change_detection::serialize_digest_token(
1252 &super::super::change_detection::digest_stat_map(&stat_map_for(&["absent.rs"])),
1253 );
1254 match compute_mtime_slice(&source, "ing", &[], root, &cache, Some(&stale)) {
1255 SliceOutcome::Changed { degraded, .. } => assert!(degraded, "memo miss → degraded"),
1256 other => panic!("expected degraded Changed, got {other:?}"),
1257 }
1258 }
1259
1260 fn head_sha(repo: &Path) -> String {
1261 String::from_utf8(
1262 std::process::Command::new("git")
1263 .args(["rev-parse", "HEAD"])
1264 .current_dir(repo)
1265 .output()
1266 .unwrap()
1267 .stdout,
1268 )
1269 .unwrap()
1270 .trim()
1271 .to_string()
1272 }
1273
1274 fn slice_contains(slice: &Slice, path: &str) -> bool {
1275 let p = path.to_string();
1276 slice.added.contains(&p) || slice.modified.contains(&p) || slice.deleted.contains(&p)
1277 }
1278
1279 fn mtime_token(source: &ResolvedPrimarySource, deny: &[String], root: &Path) -> String {
1283 let files = enumerate_facet_files(source, deny, root);
1284 serialize_digest_token(&digest_stat_map(&compute_stat_map(&files, root)))
1285 }
1286
1287 #[test]
1293 fn deny_paths_excluded_from_every_strategy_and_token() {
1294 use crate::binding::BuildMode;
1295 use crate::ingest::refinement::next_batch;
1296 use crate::pipeline::IngestTrigger;
1297
1298 let repo = tempfile::tempdir().unwrap();
1299 let root = repo.path();
1300 let cache = root.join(".memstead.cache").join("ingest");
1301
1302 git(root, &["init", "-q"]);
1305 std::fs::write(root.join("keep.rs"), "one").unwrap();
1306 std::fs::write(root.join("denied.rs"), "secret-one").unwrap();
1307 git(root, &["add", "-A"]);
1308 git(root, &["commit", "-qm", "base"]);
1309 let baseline = head_sha(root);
1310
1311 std::fs::write(root.join("keep.rs"), "two").unwrap();
1313 std::fs::write(root.join("denied.rs"), "secret-two").unwrap();
1314 git(root, &["add", "-A"]);
1315 git(root, &["commit", "-qm", "move"]);
1316
1317 let source = primary(vec![PatternEntry {
1320 path: "**/*.rs".to_string(),
1321 mode: PatternMode::Allow,
1322 }]);
1323 let deny = vec!["denied.rs".to_string()];
1324
1325 match compute_git_slice(&source, &deny, root, Some(&baseline)) {
1327 SliceOutcome::Changed { slice, .. } => {
1328 assert_eq!(slice.modified, vec!["keep.rs"]);
1329 assert!(!slice_contains(&slice, "denied.rs"), "git deny leak");
1330 }
1331 other => panic!("git: expected Changed, got {other:?}"),
1332 }
1333 match compute_git_slice(&source, &[], root, Some(&baseline)) {
1336 SliceOutcome::Changed { slice, .. } => {
1337 assert!(
1338 slice_contains(&slice, "denied.rs"),
1339 "un-denied, denied.rs is a genuine git change"
1340 );
1341 }
1342 other => panic!("git(no-deny): expected Changed, got {other:?}"),
1343 }
1344
1345 assert_eq!(enumerate_facet_files(&source, &deny, root), vec!["keep.rs"]);
1347 assert!(
1348 enumerate_facet_files(&source, &[], root).contains(&"denied.rs".to_string()),
1349 "un-denied, denied.rs is enumerated"
1350 );
1351
1352 let token = match compute_mtime_slice(&source, "ing", &deny, root, &cache, None) {
1354 SliceOutcome::Reseed { token } => token,
1355 other => panic!("mtime reseed expected, got {other:?}"),
1356 };
1357 std::fs::write(root.join("keep.rs"), "three-longer").unwrap();
1358 std::fs::write(root.join("denied.rs"), "secret-three-longer").unwrap();
1359 match compute_mtime_slice(&source, "ing", &deny, root, &cache, Some(&token)) {
1360 SliceOutcome::Changed { slice, .. } => {
1361 assert_eq!(slice.modified, vec!["keep.rs"]);
1362 assert!(!slice_contains(&slice, "denied.rs"), "mtime deny leak");
1363 }
1364 other => panic!("mtime: expected Changed, got {other:?}"),
1365 }
1366
1367 let token_present = mtime_token(&source, &deny, root);
1372 std::fs::remove_file(root.join("denied.rs")).unwrap();
1373 let token_absent = mtime_token(&source, &deny, root);
1374 assert_eq!(
1375 token_present, token_absent,
1376 "denied.rs must not influence the mtime digest / source_moved token"
1377 );
1378 std::fs::write(root.join("denied.rs"), "secret-restored").unwrap();
1379
1380 let resolved = ResolvedIngest {
1382 name: "ing".to_string(),
1383 mode: BuildMode::Discovery,
1384 trigger: IngestTrigger::Loop,
1385 batch_size: 50,
1386 deny_paths: deny.clone(),
1387 projection_ref: "m/p".to_string(),
1388 projection_mem: "m".to_string(),
1389 projection_name: "p".to_string(),
1390 intent: None,
1391 sources: vec![ResolvedSource::Primary(source.clone())],
1392 destination_mem: "m".to_string(),
1393 rules: None,
1394 post_actions: None,
1395 };
1396 let batch = next_batch(&resolved, root, &cache, 20).unwrap();
1397 assert!(
1398 batch.files.contains(&"keep.rs".to_string()),
1399 "keep.rs batched"
1400 );
1401 assert!(
1402 !batch.files.contains(&"denied.rs".to_string()),
1403 "denied.rs must never enter a refinement batch"
1404 );
1405 }
1406
1407 #[test]
1414 fn unscoped_facet_refuses_uniformly_and_empty_deny_is_distinct() {
1415 let repo = tempfile::tempdir().unwrap();
1416 let root = repo.path();
1417 let cache = root.join(".memstead.cache").join("ingest");
1418 git(root, &["init", "-q"]);
1419 std::fs::write(root.join("a.rs"), "one").unwrap();
1420 git(root, &["add", "-A"]);
1421 git(root, &["commit", "-qm", "base"]);
1422 let baseline = head_sha(root);
1423 std::fs::write(root.join("a.rs"), "two").unwrap();
1424 git(root, &["add", "-A"]);
1425 git(root, &["commit", "-qm", "move"]);
1426
1427 let unscoped = primary(vec![PatternEntry {
1430 path: "target/**".to_string(),
1431 mode: PatternMode::Deny,
1432 }]);
1433 assert_eq!(
1434 compute_git_slice(&unscoped, &[], root, Some(&baseline)),
1435 SliceOutcome::NoSignal {
1436 reason: NoSignalReason::Unscoped
1437 },
1438 "git refuses an unscoped facet"
1439 );
1440 assert_eq!(
1441 compute_mtime_slice(&unscoped, "ing", &[], root, &cache, None),
1442 SliceOutcome::NoSignal {
1443 reason: NoSignalReason::Unscoped
1444 },
1445 "mtime refuses an unscoped facet identically"
1446 );
1447 let empty_scope = primary(vec![]);
1449 assert_eq!(
1450 compute_git_slice(&empty_scope, &[], root, Some(&baseline)),
1451 SliceOutcome::NoSignal {
1452 reason: NoSignalReason::Unscoped
1453 }
1454 );
1455
1456 let scoped = primary(vec![PatternEntry {
1459 path: "**/*.rs".to_string(),
1460 mode: PatternMode::Allow,
1461 }]);
1462 assert!(
1463 matches!(
1464 compute_git_slice(&scoped, &[], root, Some(&baseline)),
1465 SliceOutcome::Changed { .. }
1466 ),
1467 "scoped facet + empty deny_paths → normal git slice, not a refusal"
1468 );
1469 assert!(
1470 matches!(
1471 compute_mtime_slice(&scoped, "ing", &[], root, &cache, None),
1472 SliceOutcome::Reseed { .. }
1473 ),
1474 "scoped facet + empty deny_paths → normal mtime reseed, not a refusal"
1475 );
1476 }
1477
1478 #[test]
1481 fn unscoped_facet_emits_no_refinement_batch() {
1482 use crate::binding::BuildMode;
1483 use crate::ingest::refinement::next_batch;
1484 use crate::pipeline::IngestTrigger;
1485
1486 let ws = tempfile::tempdir().unwrap();
1487 let root = ws.path();
1488 let cache = root.join(".memstead.cache").join("ingest");
1489 std::fs::write(root.join("a.rs"), "x").unwrap();
1490
1491 let resolved = ResolvedIngest {
1492 name: "ing".to_string(),
1493 mode: BuildMode::Discovery,
1494 trigger: IngestTrigger::Loop,
1495 batch_size: 50,
1496 deny_paths: vec![],
1497 projection_ref: "m/p".to_string(),
1498 projection_mem: "m".to_string(),
1499 projection_name: "p".to_string(),
1500 intent: None,
1501 sources: vec![ResolvedSource::Primary(primary(vec![]))],
1503 destination_mem: "m".to_string(),
1504 rules: None,
1505 post_actions: None,
1506 };
1507 assert!(
1508 next_batch(&resolved, root, &cache, 20).is_none(),
1509 "an all-unscoped ingest emits no refinement batch"
1510 );
1511 }
1512
1513 #[test]
1519 fn compute_source_cursor_notes_no_signal_reasons() {
1520 use crate::binding::BuildMode;
1521 use crate::pipeline::IngestTrigger;
1522
1523 let engine = crate::Engine::from_mounts(Vec::new()).unwrap();
1524 let ws = tempfile::tempdir().unwrap();
1526 let root = ws.path();
1527 std::fs::write(root.join("a.rs"), "x").unwrap();
1528
1529 let allow_rs = || {
1530 vec![PatternEntry {
1531 path: "**/*.rs".to_string(),
1532 mode: PatternMode::Allow,
1533 }]
1534 };
1535 let src = |facet: &str, declared: &str, scope: Vec<PatternEntry>| {
1536 ResolvedSource::Primary(ResolvedPrimarySource {
1537 facet_ref: facet.to_string(),
1538 medium: "m".to_string(),
1539 medium_type: MediumType::Filesystem,
1540 medium_pointer: String::new(),
1541 declared_change_detection: Some(declared.to_string()),
1542 scope,
1543 preparation: None,
1544 })
1545 };
1546
1547 let resolved = ResolvedIngest {
1548 name: "ing".to_string(),
1549 mode: BuildMode::Discovery,
1550 trigger: IngestTrigger::Loop,
1551 batch_size: 20,
1552 deny_paths: vec![],
1553 projection_ref: "m/p".to_string(),
1554 projection_mem: "m".to_string(),
1555 projection_name: "p".to_string(),
1556 intent: None,
1557 sources: vec![
1558 src("plan", "none", allow_rs()),
1560 src("blind", "mtime", vec![]),
1562 src("watched", "mtime", allow_rs()),
1564 ],
1565 destination_mem: "m".to_string(),
1566 rules: None,
1567 post_actions: None,
1568 };
1569
1570 let cursor = compute_source_cursor(&engine, &resolved, root);
1571 let reasons: BTreeMap<&str, NoSignalReason> = cursor
1572 .no_signal
1573 .iter()
1574 .map(|n| (n.source.as_str(), n.reason))
1575 .collect();
1576 assert_eq!(reasons.get("plan"), Some(&NoSignalReason::DetectionNone));
1577 assert_eq!(reasons.get("blind"), Some(&NoSignalReason::Unscoped));
1578 assert!(
1579 !reasons.contains_key("watched"),
1580 "a first-seen (reseed) source is not a no-signal note"
1581 );
1582 assert_eq!(cursor.no_signal.len(), 2);
1583 assert!(cursor.reseed.iter().any(|c| c.key == "ing/watched#synced"));
1585
1586 let out = crate::ingest::brief::render_changed_slice(&cursor);
1588 assert!(out.contains("- `plan`: `signal:none`"));
1589 assert!(out.contains("- `blind`: unscoped facet"));
1590 }
1591
1592 fn stat_map_for(paths: &[&str]) -> super::super::change_detection::StatMap {
1593 paths
1594 .iter()
1595 .map(|p| {
1596 (
1597 (*p).to_string(),
1598 super::super::change_detection::StatEntry { mtime: 1, size: 1 },
1599 )
1600 })
1601 .collect()
1602 }
1603}