use std::fs;
use std::path::{Path, PathBuf};
use grex_core::manifest::append::read_all;
use grex_core::manifest::event::Event;
use grex_core::tree::{phase2_prune, QuarantineConfig, TreeError};
use tempfile::tempdir;
fn try_git_init(dir: &Path) -> bool {
let status =
std::process::Command::new("git").arg("-C").arg(dir).arg("init").arg("-q").status();
matches!(status, Ok(s) if s.success())
}
fn try_git_identity(dir: &Path) -> bool {
let a = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["config", "user.email", "test@example.com"])
.status();
let b = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["config", "user.name", "Test"])
.status();
matches!((a, b), (Ok(sa), Ok(sb)) if sa.success() && sb.success())
}
fn try_git_commit_initial(dir: &Path) -> bool {
fs::write(dir.join("README"), b"seed\n").unwrap();
let add = std::process::Command::new("git").arg("-C").arg(dir).args(["add", "README"]).status();
if !matches!(add, Ok(s) if s.success()) {
return false;
}
let commit = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["commit", "-q", "-m", "init"])
.status();
matches!(commit, Ok(s) if s.success())
}
fn build_meta_with_dirty_child(meta: &Path, name: &str) -> Option<PathBuf> {
let dest = meta.join(name);
fs::create_dir_all(&dest).unwrap();
if !try_git_init(&dest) {
return None;
}
fs::write(dest.join("scratch.txt"), b"dirty bytes").unwrap();
Some(dest)
}
fn quarantine_cfg_for(meta: &Path) -> QuarantineConfig {
QuarantineConfig {
trash_root: meta.join(".grex").join("trash"),
audit_log: meta.join(".grex").join("events.jsonl"),
}
}
#[test]
fn quarantine_force_prune_snapshots_then_unlinks() {
let tmp = tempdir().unwrap();
let meta = tmp.path().join("meta");
fs::create_dir_all(meta.join(".grex")).unwrap();
let cfg = quarantine_cfg_for(&meta);
let Some(dest) = build_meta_with_dirty_child(&meta, "victim") else { return };
assert!(dest.join("scratch.txt").exists());
let res = phase2_prune(
&dest,
true,
false,
Some(cfg.audit_log.as_path()),
Some(&cfg),
);
assert!(res.is_ok(), "quarantine-driven prune must succeed: {res:?}");
assert!(!dest.exists(), "original dest must be unlinked after success");
let events = read_all(&cfg.audit_log).expect("audit log readable");
let starts: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::QuarantineStart { src, trash, .. } => Some((src.clone(), trash.clone())),
_ => None,
})
.collect();
let completes: Vec<_> = events
.iter()
.filter_map(|e| match e {
Event::QuarantineComplete { src, trash, .. } => Some((src.clone(), trash.clone())),
_ => None,
})
.collect();
assert_eq!(starts.len(), 1, "exactly one QuarantineStart on success");
assert_eq!(completes.len(), 1, "exactly one QuarantineComplete on success");
assert_eq!(starts[0], completes[0], "Start.trash MUST equal Complete.trash");
let trash_path = PathBuf::from(&starts[0].1);
assert!(trash_path.exists(), "snapshot must exist on disk");
assert!(trash_path.starts_with(&cfg.trash_root));
assert_eq!(fs::read(trash_path.join("scratch.txt")).unwrap(), b"dirty bytes");
}
#[test]
fn force_prune_without_quarantine_uses_direct_unlink() {
let tmp = tempdir().unwrap();
let meta = tmp.path().join("meta");
fs::create_dir_all(meta.join(".grex")).unwrap();
let audit_log = meta.join(".grex").join("events.jsonl");
let Some(dest) = build_meta_with_dirty_child(&meta, "legacy-victim") else { return };
let res = phase2_prune(
&dest,
true,
false,
Some(audit_log.as_path()),
None,
);
assert!(res.is_ok(), "direct-unlink force-prune must succeed: {res:?}");
assert!(!dest.exists(), "dest must be unlinked");
assert!(
!meta.join(".grex").join("trash").exists(),
"no trash bucket may be created when --quarantine is absent"
);
let events = read_all(&audit_log).unwrap_or_default();
let any_quarantine = events.iter().any(|e| {
matches!(
e,
Event::QuarantineStart { .. }
| Event::QuarantineComplete { .. }
| Event::QuarantineFailed { .. }
)
});
assert!(!any_quarantine, "no quarantine event without --quarantine");
}
#[test]
fn quarantine_snapshot_is_recursive() {
let tmp = tempdir().unwrap();
let meta = tmp.path().join("meta");
fs::create_dir_all(meta.join(".grex")).unwrap();
let cfg = quarantine_cfg_for(&meta);
let Some(dest) = build_meta_with_dirty_child(&meta, "deep-victim") else { return };
fs::create_dir_all(dest.join("a/b/c")).unwrap();
fs::write(dest.join("a/level-1.bin"), [9u8, 8, 7, 6, 5]).unwrap();
fs::write(dest.join("a/b/level-2.txt"), b"middle").unwrap();
fs::write(dest.join("a/b/c/leaf.bin"), [255u8, 254, 253]).unwrap();
let res = phase2_prune(&dest, true, false, Some(cfg.audit_log.as_path()), Some(&cfg));
assert!(res.is_ok(), "recursive prune must succeed: {res:?}");
let events = read_all(&cfg.audit_log).expect("audit log readable");
let trash_path = events
.iter()
.find_map(|e| match e {
Event::QuarantineStart { trash, .. } => Some(PathBuf::from(trash)),
_ => None,
})
.expect("QuarantineStart present");
assert_eq!(fs::read(trash_path.join("scratch.txt")).unwrap(), b"dirty bytes");
assert_eq!(fs::read(trash_path.join("a/level-1.bin")).unwrap(), vec![9u8, 8, 7, 6, 5]);
assert_eq!(fs::read(trash_path.join("a/b/level-2.txt")).unwrap(), b"middle");
assert_eq!(fs::read(trash_path.join("a/b/c/leaf.bin")).unwrap(), vec![255u8, 254, 253]);
}
#[test]
fn quarantine_snapshot_failure_aborts_prune() {
let tmp = tempdir().unwrap();
let meta = tmp.path().join("meta");
fs::create_dir_all(meta.join(".grex")).unwrap();
let cfg = quarantine_cfg_for(&meta);
let Some(dest) = build_meta_with_dirty_child(&meta, "abort-victim") else { return };
fs::write(&cfg.trash_root, b"i am a file, not a dir").unwrap();
let res = phase2_prune(&dest, true, false, Some(cfg.audit_log.as_path()), Some(&cfg));
assert!(
matches!(res, Err(TreeError::DirtyTreeRefusal { .. })),
"snapshot failure must surface as a refusal: {res:?}",
);
assert!(dest.exists(), "dest MUST remain on snapshot failure");
assert!(dest.join("scratch.txt").exists(), "dirt must remain too");
let events = read_all(&cfg.audit_log).unwrap_or_default();
assert!(
!events.iter().any(|e| matches!(e, Event::QuarantineComplete { .. })),
"no QuarantineComplete may appear when snapshot failed: {events:?}"
);
}
#[test]
fn quarantine_audit_log_modtime_precedes_or_equals_snapshot() {
let tmp = tempdir().unwrap();
let meta = tmp.path().join("meta");
fs::create_dir_all(meta.join(".grex")).unwrap();
let cfg = quarantine_cfg_for(&meta);
let Some(dest) = build_meta_with_dirty_child(&meta, "ordered-victim") else { return };
let res = phase2_prune(&dest, true, false, Some(cfg.audit_log.as_path()), Some(&cfg));
assert!(res.is_ok(), "ordered prune must succeed: {res:?}");
let events = read_all(&cfg.audit_log).expect("audit log readable");
let trash_path = events
.iter()
.find_map(|e| match e {
Event::QuarantineStart { trash, .. } => Some(PathBuf::from(trash)),
_ => None,
})
.expect("Start entry must exist after success");
let audit_mtime = fs::metadata(&cfg.audit_log).unwrap().modified().unwrap();
let trash_mtime = fs::metadata(&trash_path).unwrap().modified().unwrap();
use std::time::Duration;
assert!(
audit_mtime <= trash_mtime + Duration::from_secs(2),
"audit log mtime {audit_mtime:?} must precede trash mtime {trash_mtime:?} (modulo slack)"
);
}
#[test]
fn quarantine_applies_to_clean_consent_too() {
let tmp = tempdir().unwrap();
let meta = tmp.path().join("meta");
fs::create_dir_all(meta.join(".grex")).unwrap();
let cfg = quarantine_cfg_for(&meta);
let dest = meta.join("clean-victim");
fs::create_dir_all(&dest).unwrap();
if !try_git_init(&dest) {
return;
}
if !try_git_identity(&dest) || !try_git_commit_initial(&dest) {
return;
}
let res = phase2_prune(
&dest,
false,
false,
Some(cfg.audit_log.as_path()),
Some(&cfg),
);
assert!(res.is_ok(), "clean prune must succeed under --quarantine: {res:?}");
assert!(!dest.exists(), "clean dest must be unlinked after pipeline");
let events = read_all(&cfg.audit_log).expect("audit log readable");
let has_start = events.iter().any(|e| matches!(e, Event::QuarantineStart { .. }));
let has_complete = events.iter().any(|e| matches!(e, Event::QuarantineComplete { .. }));
assert!(has_start && has_complete, "clean prune still emits Start+Complete: {events:?}");
assert!(cfg.trash_root.exists(), "trash root materialised even on clean consent");
}