use anyhow::{bail, Context, Result};
use chrono::Utc;
use clap::{Parser, Subcommand};
use colored::Colorize;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use walkdir::WalkDir;
const MANIFEST_DIR: &str = ".testwall";
const MANIFEST_FILE: &str = ".testwall/manifest.json";
const SNAPSHOT_DIR: &str = ".testwall/snapshot";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FileRecord {
relative_path: String,
sha256: String,
size: u64,
}
#[derive(Debug, Serialize, Deserialize)]
struct Manifest {
version: String,
created_at: String,
patterns: Vec<String>,
test_command: Option<String>,
files: BTreeMap<String, FileRecord>,
}
#[derive(Parser)]
#[command(
name = "testwall",
version,
about = "Enforce test immutability for agentic TDD workflows",
long_about = "testwall prevents implementing agents from cheating test gates by \
snapshotting test files, locking them read-only, and verifying \
integrity before accepting implementation results."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(short, long)]
pattern: Vec<String>,
#[arg(short = 'c', long = "cmd")]
test_command: Option<String>,
},
Lock,
Unlock,
Run {
#[arg(short = 'c', long = "cmd")]
test_command: Option<String>,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Verify {
#[arg(long)]
report_only: bool,
},
Accept,
Status,
}
fn project_root() -> Result<PathBuf> {
let mut dir = std::env::current_dir()?;
loop {
if dir.join(MANIFEST_DIR).exists() || dir.join(".git").exists() {
return Ok(dir);
}
if !dir.pop() {
bail!("Could not find project root (no .git or .testwall directory found)");
}
}
}
fn sha256_file(path: &Path) -> Result<String> {
let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
Ok(format!("{:x}", hasher.finalize()))
}
fn load_manifest(root: &Path) -> Result<Manifest> {
let path = root.join(MANIFEST_FILE);
let data = fs::read_to_string(&path)
.with_context(|| format!("Could not read manifest at {}", path.display()))?;
let manifest: Manifest =
serde_json::from_str(&data).context("Failed to parse manifest JSON")?;
Ok(manifest)
}
fn save_manifest(root: &Path, manifest: &Manifest) -> Result<()> {
let path = root.join(MANIFEST_FILE);
let json = serde_json::to_string_pretty(manifest)?;
fs::write(&path, json)?;
Ok(())
}
fn default_patterns() -> Vec<String> {
vec![
"test_*.py".into(),
"*_test.py".into(),
"tests/**/*.py".into(),
"conftest.py".into(),
"tests/**/*.rs".into(),
"**/*.test.js".into(),
"**/*.test.ts".into(),
"**/*.test.tsx".into(),
"**/*.spec.js".into(),
"**/*.spec.ts".into(),
"**/*.spec.tsx".into(),
"**/*_test.go".into(),
"src/test/**/*.java".into(),
"src/test/**/*.kt".into(),
"pytest.ini".into(),
"setup.cfg".into(),
"jest.config.*".into(),
"vitest.config.*".into(),
".cargo/config.toml".into(),
]
}
fn collect_test_files(root: &Path, patterns: &[String]) -> Result<BTreeMap<String, FileRecord>> {
let mut files = BTreeMap::new();
for entry in WalkDir::new(root)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
if e.file_type().is_dir() {
return !name.starts_with('.')
&& name != "node_modules"
&& name != "target"
&& name != "__pycache__"
&& name != ".testwall";
}
true
})
.filter_map(|e| e.ok())
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let rel = path
.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
for pattern in patterns {
if glob_match(&rel, pattern) {
let hash = sha256_file(path)?;
let meta = fs::metadata(path)?;
files.insert(
rel.clone(),
FileRecord {
relative_path: rel.clone(),
sha256: hash,
size: meta.len(),
},
);
break;
}
}
}
Ok(files)
}
fn glob_match(path: &str, pattern: &str) -> bool {
if let Some(rest) = pattern.strip_prefix("**/") {
let parts: Vec<&str> = path.split('/').collect();
for i in 0..parts.len() {
let suffix = parts[i..].join("/");
if simple_glob(&suffix, rest) {
return true;
}
}
return false;
}
if pattern.contains("/**/") {
let parts: Vec<&str> = pattern.splitn(2, "/**/").collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1];
if let Some(rest) = path.strip_prefix(prefix) {
let rest = rest.strip_prefix('/').unwrap_or(rest);
let path_parts: Vec<&str> = rest.split('/').collect();
for i in 0..path_parts.len() {
let candidate = path_parts[i..].join("/");
if simple_glob(&candidate, suffix) {
return true;
}
}
}
return false;
}
}
simple_glob(path, pattern)
}
fn simple_glob(text: &str, pattern: &str) -> bool {
let text = text.as_bytes();
let pattern = pattern.as_bytes();
let (mut ti, mut pi) = (0, 0);
let (mut star_p, mut star_t) = (usize::MAX, 0);
while ti < text.len() {
if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == text[ti]) {
ti += 1;
pi += 1;
} else if pi < pattern.len() && pattern[pi] == b'*' {
star_p = pi;
star_t = ti;
pi += 1;
} else if star_p != usize::MAX {
pi = star_p + 1;
star_t += 1;
ti = star_t;
} else {
return false;
}
}
while pi < pattern.len() && pattern[pi] == b'*' {
pi += 1;
}
pi == pattern.len()
}
fn set_readonly(path: &Path, readonly: bool) -> Result<()> {
let meta = fs::metadata(path)?;
let mut perms = meta.permissions();
if readonly {
let mode = perms.mode() & !0o222;
perms.set_mode(mode);
} else {
let mode = perms.mode() | 0o200;
perms.set_mode(mode);
}
fs::set_permissions(path, perms)?;
Ok(())
}
fn cmd_init(patterns: Vec<String>, test_command: Option<String>) -> Result<()> {
let root = project_root()?;
let patterns = if patterns.is_empty() {
default_patterns()
} else {
patterns
};
println!(
"{} Scanning for test files...",
"testwall".bold().cyan()
);
let files = collect_test_files(&root, &patterns)?;
if files.is_empty() {
println!(
"{} No test files found matching patterns:",
"warning:".bold().yellow()
);
for p in &patterns {
println!(" - {}", p);
}
println!("\nUse {} to specify custom patterns.", "--pattern".bold());
return Ok(());
}
let _manifest_dir = root.join(MANIFEST_DIR);
let snapshot_dir = root.join(SNAPSHOT_DIR);
fs::create_dir_all(&snapshot_dir)?;
for (rel_path, _record) in &files {
let src = root.join(rel_path);
let dst = snapshot_dir.join(rel_path);
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&src, &dst)?;
}
let manifest = Manifest {
version: env!("CARGO_PKG_VERSION").to_string(),
created_at: Utc::now().to_rfc3339(),
patterns,
test_command,
files,
};
save_manifest(&root, &manifest)?;
let gitignore_path = root.join(".gitignore");
let gitignore = fs::read_to_string(&gitignore_path).unwrap_or_default();
if !gitignore.contains(".testwall/") {
let mut content = gitignore;
if !content.ends_with('\n') && !content.is_empty() {
content.push('\n');
}
content.push_str("\n# testwall snapshot (do not commit)\n.testwall/snapshot/\n");
fs::write(&gitignore_path, content)?;
}
let file_count = manifest.files.len();
println!(
"\n{} Initialized testwall with {} test file{}.",
"done:".bold().green(),
file_count.to_string().bold(),
if file_count == 1 { "" } else { "s" }
);
println!(
" Manifest: {}",
root.join(MANIFEST_FILE).display().to_string().dimmed()
);
println!(
" Snapshot: {}",
root.join(SNAPSHOT_DIR).display().to_string().dimmed()
);
println!(
"\nRun {} to make test files read-only.",
"testwall lock".bold()
);
Ok(())
}
fn cmd_lock() -> Result<()> {
let root = project_root()?;
let manifest = load_manifest(&root)?;
let mut locked = 0;
for (rel_path, _) in &manifest.files {
let path = root.join(rel_path);
if path.exists() {
set_readonly(&path, true)?;
locked += 1;
}
}
println!(
"{} Locked {} test file{} (read-only).",
"testwall".bold().cyan(),
locked.to_string().bold(),
if locked == 1 { "" } else { "s" }
);
println!(
" The implementing agent can {} but not {} these files.",
"read".green().bold(),
"modify".red().bold()
);
Ok(())
}
fn cmd_unlock() -> Result<()> {
let root = project_root()?;
let manifest = load_manifest(&root)?;
let mut unlocked = 0;
for (rel_path, _) in &manifest.files {
let path = root.join(rel_path);
if path.exists() {
set_readonly(&path, false)?;
unlocked += 1;
}
}
println!(
"{} Unlocked {} test file{} (write restored).",
"testwall".bold().cyan(),
unlocked.to_string().bold(),
if unlocked == 1 { "" } else { "s" }
);
Ok(())
}
fn cmd_run(test_command: Option<String>, extra_args: Vec<String>) -> Result<()> {
let root = project_root()?;
let manifest = load_manifest(&root)?;
let snapshot_dir = root.join(SNAPSHOT_DIR);
let cmd_str = test_command
.or(manifest.test_command.clone())
.unwrap_or_else(|| {
if root.join("Cargo.toml").exists() {
"cargo test".into()
} else if root.join("pyproject.toml").exists() || root.join("pytest.ini").exists() {
"pytest".into()
} else if root.join("package.json").exists() {
"npm test".into()
} else if root.join("go.mod").exists() {
"go test ./...".into()
} else {
"make test".into()
}
});
println!(
"{} Verifying snapshot integrity...",
"testwall".bold().cyan()
);
for (rel_path, record) in &manifest.files {
let snap_path = snapshot_dir.join(rel_path);
if !snap_path.exists() {
bail!(
"Snapshot file missing: {}. Run `testwall init` again.",
rel_path
);
}
let current_hash = sha256_file(&snap_path)?;
if current_hash != record.sha256 {
bail!(
"Snapshot tampered: {} has been modified. Run `testwall init` again.",
rel_path
);
}
}
println!(
"{} Restoring test files from snapshot...",
"testwall".bold().cyan()
);
for (rel_path, _) in &manifest.files {
let snap_path = snapshot_dir.join(rel_path);
let work_path = root.join(rel_path);
if let Some(parent) = work_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&snap_path, &work_path)?;
set_readonly(&work_path, true)?;
}
println!(
"{} Running: {} {}",
"testwall".bold().cyan(),
cmd_str.bold(),
extra_args.join(" ")
);
println!();
let parts: Vec<&str> = cmd_str.split_whitespace().collect();
let (program, cmd_args) = parts.split_first().context("Empty test command")?;
let mut all_args: Vec<&str> = cmd_args.to_vec();
let extra_refs: Vec<&str> = extra_args.iter().map(|s| s.as_str()).collect();
all_args.extend(extra_refs);
let status = Command::new(program)
.args(&all_args)
.current_dir(&root)
.status()
.with_context(|| format!("Failed to execute: {}", cmd_str))?;
println!();
if status.success() {
println!("{} Tests passed.", "PASS".bold().green());
} else {
println!(
"{} Tests failed (exit code: {}).",
"FAIL".bold().red(),
status.code().unwrap_or(-1)
);
}
std::process::exit(status.code().unwrap_or(1));
}
fn cmd_verify(report_only: bool) -> Result<()> {
let root = project_root()?;
let manifest = load_manifest(&root)?;
println!(
"{} Verifying test file integrity...\n",
"testwall".bold().cyan()
);
let mut clean = 0;
let mut modified = Vec::new();
let mut missing = Vec::new();
for (rel_path, record) in &manifest.files {
let path = root.join(rel_path);
if !path.exists() {
missing.push(rel_path.clone());
continue;
}
let current_hash = sha256_file(&path)?;
if current_hash == record.sha256 {
clean += 1;
println!(" {} {}", "ok".green(), rel_path);
} else {
modified.push(rel_path.clone());
println!(
" {} {} (checksum mismatch)",
"MODIFIED".red().bold(),
rel_path
);
}
}
for path in &missing {
println!(" {} {} (file missing)", "MISSING".red().bold(), path);
}
println!();
let tampered = !modified.is_empty() || !missing.is_empty();
if tampered {
println!(
"{} Test integrity check FAILED.",
"FAIL".bold().red()
);
println!(
" {} modified, {} missing, {} clean",
modified.len().to_string().red().bold(),
missing.len().to_string().red().bold(),
clean.to_string().green()
);
println!(
"\n The implementing agent appears to have tampered with test files."
);
println!(
" Run {} to restore from snapshot.",
"testwall run".bold()
);
if !report_only {
std::process::exit(1);
}
} else {
println!(
"{} All {} test file{} verified.",
"PASS".bold().green(),
clean.to_string().bold(),
if clean == 1 { "" } else { "s" }
);
}
Ok(())
}
fn cmd_accept() -> Result<()> {
let root = project_root()?;
println!(
"{} Running verification before accepting...\n",
"testwall".bold().cyan()
);
let manifest = load_manifest(&root)?;
let mut tampered = false;
for (rel_path, record) in &manifest.files {
let path = root.join(rel_path);
if !path.exists() {
println!(" {} {} (missing)", "FAIL".red().bold(), rel_path);
tampered = true;
continue;
}
let current_hash = sha256_file(&path)?;
if current_hash != record.sha256 {
println!(" {} {} (modified)", "FAIL".red().bold(), rel_path);
tampered = true;
} else {
println!(" {} {}", "ok".green(), rel_path);
}
}
if tampered {
println!(
"\n{} Cannot accept: test files were tampered with.",
"REJECTED".bold().red()
);
println!(
" Restore originals with {} and re-run the implementation.",
"testwall run".bold()
);
std::process::exit(1);
}
for (rel_path, _) in &manifest.files {
let path = root.join(rel_path);
if path.exists() {
set_readonly(&path, false)?;
}
}
let snapshot_dir = root.join(SNAPSHOT_DIR);
if snapshot_dir.exists() {
fs::remove_dir_all(&snapshot_dir)?;
}
println!(
"\n{} Implementation accepted. Test files unlocked, snapshot cleaned up.",
"ACCEPTED".bold().green()
);
println!(
" Manifest retained at {} for audit trail.",
root.join(MANIFEST_FILE).display().to_string().dimmed()
);
Ok(())
}
fn cmd_status() -> Result<()> {
let root = project_root()?;
let manifest_path = root.join(MANIFEST_FILE);
if !manifest_path.exists() {
println!(
"{} No testwall session found. Run {} to start.",
"testwall".bold().cyan(),
"testwall init".bold()
);
return Ok(());
}
let manifest = load_manifest(&root)?;
println!("{}", "testwall status".bold().cyan());
println!(" Version: {}", manifest.version);
println!(" Created: {}", manifest.created_at);
println!(
" Test cmd: {}",
manifest
.test_command
.as_deref()
.unwrap_or("(auto-detect)")
);
println!(" Patterns: {}", manifest.patterns.join(", "));
println!(" Files: {}", manifest.files.len());
let mut locked = 0;
let mut unlocked = 0;
let mut missing = 0;
for (rel_path, _) in &manifest.files {
let path = root.join(rel_path);
if !path.exists() {
missing += 1;
} else {
let meta = fs::metadata(&path)?;
if meta.permissions().mode() & 0o222 == 0 {
locked += 1;
} else {
unlocked += 1;
}
}
}
println!(
" Lock state: {} locked, {} unlocked, {} missing",
locked.to_string().green(),
unlocked.to_string().yellow(),
missing.to_string().red()
);
let snapshot_dir = root.join(SNAPSHOT_DIR);
if snapshot_dir.exists() {
println!(" Snapshot: {}", "present".green());
} else {
println!(" Snapshot: {}", "missing (already accepted?)".yellow());
}
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Init {
pattern,
test_command,
} => cmd_init(pattern, test_command),
Commands::Lock => cmd_lock(),
Commands::Unlock => cmd_unlock(),
Commands::Run {
test_command,
args,
} => cmd_run(test_command, args),
Commands::Verify { report_only } => cmd_verify(report_only),
Commands::Accept => cmd_accept(),
Commands::Status => cmd_status(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sha256_consistency() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "hello world").unwrap();
let hash1 = sha256_file(&file).unwrap();
let hash2 = sha256_file(&file).unwrap();
assert_eq!(hash1, hash2);
assert_eq!(
hash1,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn test_glob_simple() {
assert!(glob_match("test_foo.py", "test_*.py"));
assert!(glob_match("bar_test.py", "*_test.py"));
assert!(!glob_match("foo.py", "test_*.py"));
assert!(!glob_match("test_foo.rs", "test_*.py"));
}
#[test]
fn test_glob_double_star_prefix() {
assert!(glob_match("src/components/Button.test.tsx", "**/*.test.tsx"));
assert!(glob_match("Button.test.tsx", "**/*.test.tsx"));
assert!(!glob_match("Button.tsx", "**/*.test.tsx"));
}
#[test]
fn test_glob_double_star_middle() {
assert!(glob_match("tests/unit/test_core.rs", "tests/**/*.rs"));
assert!(glob_match("tests/test_core.rs", "tests/**/*.rs"));
assert!(!glob_match("src/core.rs", "tests/**/*.rs"));
}
#[test]
fn test_set_readonly() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "content").unwrap();
set_readonly(&file, true).unwrap();
let meta = fs::metadata(&file).unwrap();
assert_eq!(meta.permissions().mode() & 0o222, 0);
set_readonly(&file, false).unwrap();
let meta = fs::metadata(&file).unwrap();
assert_ne!(meta.permissions().mode() & 0o200, 0);
}
}