use assert_cmd::Command;
use predicates::prelude::*;
use std::collections::HashSet;
use std::path::Path;
use tempfile::TempDir;
fn ritalin() -> Command {
let mut cmd = Command::cargo_bin("ritalin").unwrap();
cmd.env_remove("RITALIN_GATE");
cmd
}
fn init_in(dir: &Path) {
ritalin()
.args(["init", "--outcome", "test outcome"])
.current_dir(dir)
.assert()
.success();
}
fn git_init(dir: &Path) {
for args in [
vec!["init", "-q"],
vec!["config", "user.email", "t@t"],
vec!["config", "user.name", "t"],
] {
std::process::Command::new("git")
.args(&args)
.current_dir(dir)
.output()
.unwrap();
}
}
#[test]
fn concurrent_adds_mint_unique_ids() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
init_in(&dir);
const N: usize = 8;
let handles: Vec<_> = (0..N)
.map(|i| {
let dir = dir.clone();
std::thread::spawn(move || {
ritalin()
.args(["add", &format!("claim {i}"), "--proof", "true"])
.current_dir(&dir)
.assert()
.success();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let ledger = std::fs::read_to_string(dir.join(".ritalin/obligations.jsonl")).unwrap();
let mut ids = HashSet::new();
let mut count = 0;
for line in ledger.lines().filter(|l| !l.trim().is_empty()) {
let ob: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("corrupt ledger line ({e}): {line}"));
assert!(
ids.insert(ob["id"].as_str().unwrap().to_string()),
"duplicate obligation id: {}",
ob["id"]
);
count += 1;
}
assert_eq!(count, N, "all {N} adds should have landed");
}
#[test]
fn concurrent_proves_do_not_corrupt_evidence_ledger() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().to_path_buf();
init_in(&dir);
ritalin()
.args(["add", "always true", "--proof", "true"])
.current_dir(&dir)
.assert()
.success();
const N: usize = 8;
let handles: Vec<_> = (0..N)
.map(|_| {
let dir = dir.clone();
std::thread::spawn(move || {
ritalin()
.args(["prove", "O-001"])
.current_dir(&dir)
.assert()
.success();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let ledger = std::fs::read_to_string(dir.join(".ritalin/evidence.jsonl")).unwrap();
let lines: Vec<_> = ledger.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), N);
for line in &lines {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|e| panic!("corrupt evidence line ({e}): {line}"));
}
ritalin()
.args(["gate"])
.current_dir(&dir)
.assert()
.success();
}
#[test]
fn prove_runs_proof_from_contract_root() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
std::fs::write(dir.join("ok.txt"), "works").unwrap();
init_in(dir);
ritalin()
.args([
"add",
"ok.txt says works",
"--proof",
"grep -q works ok.txt",
])
.current_dir(dir)
.assert()
.success();
let sub = dir.join("deep/nested");
std::fs::create_dir_all(&sub).unwrap();
ritalin()
.args(["prove", "O-001"])
.current_dir(&sub)
.assert()
.success();
ritalin()
.args(["gate"])
.current_dir(&sub)
.assert()
.success();
}
#[test]
fn init_force_in_subdir_does_not_wipe_ancestor_contract() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
init_in(root);
ritalin()
.args(["add", "root obligation", "--proof", "true"])
.current_dir(root)
.assert()
.success();
let sub = root.join("packages/web");
std::fs::create_dir_all(&sub).unwrap();
ritalin()
.args(["init", "--outcome", "nested contract", "--force"])
.current_dir(&sub)
.assert()
.success();
assert!(root.join(".ritalin/obligations.jsonl").exists());
assert!(sub.join(".ritalin").exists());
let ledger = std::fs::read_to_string(root.join(".ritalin/obligations.jsonl")).unwrap();
assert!(ledger.contains("root obligation"));
}
#[test]
fn init_in_subdir_warns_about_shadowed_ancestor() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
init_in(root);
let sub = root.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let out = ritalin()
.args(["init", "--outcome", "nested", "--json"])
.current_dir(&sub)
.output()
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(
json["data"]["shadows"]
.as_str()
.unwrap()
.contains(".ritalin"),
"nested init should report the ancestor contract it shadows"
);
}
#[test]
fn state_discovery_does_not_cross_git_boundary() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path();
init_in(outer);
ritalin()
.args(["add", "outer obligation", "--proof", "false"])
.current_dir(outer)
.assert()
.success();
let inner = outer.join("some-other-project");
std::fs::create_dir_all(&inner).unwrap();
git_init(&inner);
ritalin()
.args(["gate", "--hook-mode"])
.write_stdin("{}")
.current_dir(&inner)
.assert()
.success()
.stdout(predicate::str::is_empty());
ritalin()
.args(["status"])
.current_dir(&inner)
.assert()
.failure();
}
#[test]
fn state_discovery_walks_up_within_one_repo() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
git_init(root);
init_in(root);
ritalin()
.args(["add", "repo obligation", "--proof", "false"])
.current_dir(root)
.assert()
.success();
let sub = root.join("src/deep");
std::fs::create_dir_all(&sub).unwrap();
ritalin()
.args(["gate", "--hook-mode"])
.write_stdin("{}")
.current_dir(&sub)
.assert()
.success()
.stdout(predicate::str::contains("\"block\""));
}
#[test]
fn seed_rejects_escaping_depends_on() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let manifest = dir.join("contract.toml");
std::fs::write(
&manifest,
r#"outcome = "escape attempt"
[[obligations]]
claim = "reads outside repo"
proof = "true"
depends_on = ["../etc/passwd"]
"#,
)
.unwrap();
ritalin()
.args(["seed", manifest.to_str().unwrap()])
.current_dir(dir)
.assert()
.failure()
.stderr(predicate::str::contains("must not contain `..`"));
assert!(!dir.join(".ritalin").exists());
}
#[test]
fn seed_rejects_empty_proof() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
let manifest = dir.join("contract.toml");
std::fs::write(
&manifest,
r#"outcome = "empty proof"
[[obligations]]
claim = "unprovable"
proof = " "
"#,
)
.unwrap();
ritalin()
.args(["seed", manifest.to_str().unwrap()])
.current_dir(dir)
.assert()
.failure()
.stderr(predicate::str::contains("empty proof command"));
}
#[test]
fn prove_all_continues_past_broken_depends_on() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
init_in(dir);
ritalin()
.args([
"add",
"dep never created",
"--proof",
"true",
"--depends-on",
"never-created.txt",
])
.current_dir(dir)
.assert()
.success();
ritalin()
.args(["add", "healthy obligation", "--proof", "true"])
.current_dir(dir)
.assert()
.success();
let out = ritalin()
.args(["prove", "--all", "--json"])
.current_dir(dir)
.output()
.unwrap();
assert!(!out.status.success());
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let summary = &json["data"]["summary"];
assert_eq!(summary["total"], 2);
assert_eq!(summary["failed"], 1);
assert_eq!(summary["discharged"], 1);
}
#[test]
fn stale_only_reproves_obligations_invalidated_within_the_run() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
git_init(dir);
std::fs::write(dir.join("seed.txt"), "v1").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-q", "-m", "init"])
.current_dir(dir)
.output()
.unwrap();
init_in(dir);
ritalin()
.args([
"add",
"mutating proof",
"--proof",
"echo mutated > seed.txt",
"--depends-on",
"seed.txt",
])
.current_dir(dir)
.assert()
.success();
ritalin()
.args([
"add",
"seed exists",
"--proof",
"test -f seed.txt",
"--depends-on",
"seed.txt",
])
.current_dir(dir)
.assert()
.success();
ritalin()
.args(["prove", "O-002"])
.current_dir(dir)
.assert()
.success();
let out = ritalin()
.args(["prove", "--all", "--stale-only", "--json"])
.current_dir(dir)
.output()
.unwrap();
assert!(out.status.success());
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let summary = &json["data"]["summary"];
assert_eq!(
summary["skipped"], 0,
"O-002 was invalidated mid-run and must not be skipped as fresh"
);
let proved = json["data"]["proved"].as_array().unwrap();
assert_eq!(proved.len(), 2);
}
#[test]
fn submodule_boundary_does_not_orphan_outer_contract() {
let tmp = TempDir::new().unwrap();
let outer = tmp.path();
git_init(outer);
init_in(outer);
ritalin()
.args(["add", "outer obligation", "--proof", "false"])
.current_dir(outer)
.assert()
.success();
let sub = outer.join("vendor/sub/src");
std::fs::create_dir_all(&sub).unwrap();
std::fs::create_dir_all(outer.join(".git/modules/sub")).unwrap();
std::fs::write(
outer.join("vendor/sub/.git"),
"gitdir: ../../.git/modules/sub\n",
)
.unwrap();
ritalin()
.args(["status"])
.current_dir(&sub)
.assert()
.success();
ritalin()
.args(["gate", "--hook-mode"])
.write_stdin("{}")
.current_dir(&sub)
.assert()
.success()
.stdout(predicate::str::contains("\"block\""));
}
#[test]
fn stale_only_detects_out_of_scope_mutation() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
git_init(dir);
std::fs::write(dir.join("a.txt"), "a").unwrap();
std::fs::write(dir.join("shared.txt"), "v1").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(dir)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-q", "-m", "init"])
.current_dir(dir)
.output()
.unwrap();
init_in(dir);
ritalin()
.args(["add", "global one", "--proof", "test -f shared.txt"])
.current_dir(dir)
.assert()
.success();
ritalin()
.args([
"add",
"scoped mutator",
"--proof",
"echo mutated >> shared.txt",
"--depends-on",
"a.txt",
])
.current_dir(dir)
.assert()
.success();
ritalin()
.args(["add", "global two", "--proof", "true"])
.current_dir(dir)
.assert()
.success();
ritalin()
.args(["prove", "O-001"])
.current_dir(dir)
.assert()
.success();
ritalin()
.args(["prove", "O-003"])
.current_dir(dir)
.assert()
.success();
let out = ritalin()
.args(["prove", "--all", "--stale-only", "--json"])
.current_dir(dir)
.output()
.unwrap();
assert!(out.status.success());
let json: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let skipped: Vec<_> = json["data"]["skipped"]
.as_array()
.unwrap()
.iter()
.map(|s| s["obligation_id"].as_str().unwrap().to_string())
.collect();
assert_eq!(skipped, vec!["O-001"], "O-003 must not be skipped as fresh");
let remaining: Vec<_> = json["data"]["remaining_open"]["ids"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(
remaining.contains(&"O-001".to_string()),
"remaining_open must surface the mid-run invalidation, got {remaining:?}"
);
}
#[test]
fn foreign_worktree_under_contract_dir_is_not_captured() {
let tmp = TempDir::new().unwrap();
let victim = tmp.path().join("elsewhere/victim-main");
std::fs::create_dir_all(&victim).unwrap();
git_init(&victim);
std::fs::create_dir_all(victim.join(".git/worktrees/wt")).unwrap();
let projects = tmp.path().join("Projects");
std::fs::create_dir_all(&projects).unwrap();
init_in(&projects);
ritalin()
.args(["add", "stray obligation", "--proof", "false"])
.current_dir(&projects)
.assert()
.success();
let wt = projects.join("victim-worktree");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(
wt.join(".git"),
format!("gitdir: {}\n", victim.join(".git/worktrees/wt").display()),
)
.unwrap();
ritalin()
.args(["status"])
.current_dir(&wt)
.assert()
.failure();
ritalin()
.args(["gate", "--hook-mode"])
.write_stdin("{}")
.current_dir(&wt)
.assert()
.success()
.stdout(predicate::str::is_empty());
}
#[test]
fn own_worktree_under_contract_repo_still_finds_contract() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git_init(&repo);
init_in(&repo);
ritalin()
.args(["add", "repo obligation", "--proof", "false"])
.current_dir(&repo)
.assert()
.success();
std::fs::create_dir_all(repo.join(".git/worktrees/x")).unwrap();
let wt = repo.join(".worktrees/x");
std::fs::create_dir_all(&wt).unwrap();
std::fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/x\n").unwrap();
ritalin()
.args(["gate", "--hook-mode"])
.write_stdin("{}")
.current_dir(&wt)
.assert()
.success()
.stdout(predicate::str::contains("\"block\""));
}