use std::{
fs,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
process::{Command, Output},
};
use serde_json::Value;
use tempfile::{TempDir, tempdir};
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_autorize")
}
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("examples")
.join("pi-digits")
}
fn copy_example(dst: &Path) {
copy_dir(&fixture_root(), dst);
}
fn copy_dir(src: &Path, dst: &Path) {
fs::create_dir_all(dst).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let ty = entry.file_type().unwrap();
let from = entry.path();
let to = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir(&from, &to);
} else if ty.is_file() {
fs::copy(&from, &to).unwrap();
if from
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e == "sh")
{
let mut perms = fs::metadata(&to).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&to, perms).unwrap();
}
}
}
}
fn git(args: &[&str], cwd: &Path) {
let st = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.unwrap_or_else(|e| panic!("git {args:?} spawn failed: {e}"));
assert!(st.success(), "git {args:?} failed: {st:?}");
}
fn git_init_commit(dir: &Path) {
git(&["init", "-q", "-b", "main"], dir);
git(&["config", "user.email", "test@example.com"], dir);
git(&["config", "user.name", "Test"], dir);
git(&["add", "."], dir);
git(&["commit", "-qm", "init"], dir);
}
fn head_sha(dir: &Path) -> String {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(dir)
.output()
.unwrap();
assert!(out.status.success(), "rev-parse HEAD failed: {out:?}");
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn rev_parse(dir: &Path, refname: &str) -> Option<String> {
let out = Command::new("git")
.args(["rev-parse", "--verify", refname])
.current_dir(dir)
.output()
.unwrap();
if !out.status.success() {
return None;
}
Some(String::from_utf8(out.stdout).unwrap().trim().to_string())
}
fn run_autorize(args: &[&str], dir: &Path) -> Output {
Command::new(binary())
.args(args)
.current_dir(dir)
.output()
.unwrap_or_else(|e| panic!("spawn autorize: {e}"))
}
fn read_jsonl(path: &Path) -> Vec<Value> {
let text = fs::read_to_string(path).expect("iterations.jsonl missing");
text.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str::<Value>(l).expect("non-json line"))
.collect()
}
fn bootstrap() -> TempDir {
let tmp = tempdir().unwrap();
copy_example(tmp.path());
git_init_commit(tmp.path());
tmp
}
#[test]
fn loop_converges_with_merges_and_discards() {
let tmp = bootstrap();
let p = tmp.path();
let out = run_autorize(&["run", "pi"], p);
assert!(
out.status.success(),
"autorize run failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let log = p.join(".autorize/pi/iterations.jsonl");
let recs = read_jsonl(&log);
assert!(recs.len() >= 3, "expected >=3 records, got {}", recs.len());
let merged = recs.iter().filter(|r| r["outcome"] == "merged").count();
let discarded = recs.iter().filter(|r| r["outcome"] == "discarded").count();
assert!(merged >= 1, "expected >=1 merged record, got recs={recs:?}");
assert!(
discarded >= 1,
"expected >=1 discarded record, got recs={recs:?}"
);
for (idx, rec) in recs.iter().enumerate() {
let want = (idx as u64) + 1;
let got = rec["iter"].as_u64().unwrap();
assert_eq!(got, want, "iter numbers must be 1..=N strict; rec={rec:?}");
}
let state_text = fs::read_to_string(p.join(".autorize/pi/state.json")).unwrap();
let state: Value = serde_json::from_str(&state_text).unwrap();
let best = state["best_score"].as_f64().expect("best_score is null");
assert!(best < 0.1, "best_score {best} should be < 0.1");
let best_iter = state["best_iter"].as_u64().expect("best_iter null");
assert!(best_iter >= 1, "best_iter {best_iter} should be >= 1");
let inspect_dir = tempdir().unwrap();
let inspect = inspect_dir.path().join("wt");
git(
&["worktree", "add", inspect.to_str().unwrap(), "autorize/pi"],
p,
);
let final_value: f64 = fs::read_to_string(inspect.join("value.txt"))
.unwrap()
.trim()
.parse()
.expect("value.txt should be a float");
git(
&["worktree", "remove", "--force", inspect.to_str().unwrap()],
p,
);
let pi = std::f64::consts::PI;
let final_dist = (pi - final_value).abs();
let start_dist = (pi - 3.0_f64).abs();
assert!(
final_dist < start_dist,
"final value {final_value} (dist {final_dist}) should be closer to π than 3.0 (dist {start_dist})"
);
}
#[test]
fn dirty_tree_refused_then_allow_dirty_succeeds() {
let tmp = bootstrap();
let p = tmp.path();
fs::write(p.join("stray.txt"), "x\n").unwrap();
let out = run_autorize(&["run", "pi"], p);
assert!(
!out.status.success(),
"expected non-zero exit on dirty tree; stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("uncommitted"),
"expected 'uncommitted' in stderr, got: {stderr}"
);
let out2 = run_autorize(&["run", "pi", "--allow-dirty"], p);
assert!(
out2.status.success(),
"expected success with --allow-dirty; stdout={} stderr={}",
String::from_utf8_lossy(&out2.stdout),
String::from_utf8_lossy(&out2.stderr)
);
let recs = read_jsonl(&p.join(".autorize/pi/iterations.jsonl"));
assert!(
!recs.is_empty(),
"expected >=1 iteration record with --allow-dirty"
);
}
#[test]
fn deny_path_violation_yields_denied_outcome() {
let tmp = tempdir().unwrap();
let p = tmp.path();
copy_example(p);
let cfg_path = p.join(".autorize/pi/config.toml");
let cfg = fs::read_to_string(&cfg_path).unwrap();
let cfg = cfg.replace(
"command = \"bash mock-agent.sh {iter}\"",
"command = \"bash bad-agent.sh {iter}\"",
);
let cfg = cfg.replace("max_iterations = 6", "max_iterations = 1");
fs::write(&cfg_path, cfg).unwrap();
git_init_commit(p);
let out = run_autorize(&["run", "pi"], p);
assert!(
out.status.success(),
"autorize run failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let recs = read_jsonl(&p.join(".autorize/pi/iterations.jsonl"));
assert_eq!(recs.len(), 1, "expected exactly 1 record, got {recs:?}");
assert_eq!(
recs[0]["outcome"], "denied",
"expected denied outcome; rec={:?}",
recs[0]
);
let state_text = fs::read_to_string(p.join(".autorize/pi/state.json")).unwrap();
let state: Value = serde_json::from_str(&state_text).unwrap();
let base = state["base_commit"]
.as_str()
.expect("base_commit must be a string")
.to_string();
let branch_head = rev_parse(p, "autorize/pi").expect("autorize/pi branch missing");
assert_eq!(
branch_head, base,
"tracking branch must not advance on denied iteration"
);
}
#[test]
fn resume_records_killed_then_continues() {
let tmp = tempdir().unwrap();
let p = tmp.path();
copy_example(p);
let cfg_path = p.join(".autorize/pi/config.toml");
let cfg = fs::read_to_string(&cfg_path).unwrap();
let cfg = cfg.replace("max_iterations = 6", "max_iterations = 3");
fs::write(&cfg_path, cfg).unwrap();
git_init_commit(p);
let sha = head_sha(p);
git(&["branch", "autorize/pi", &sha], p);
let state_json = format!(
r#"{{
"experiment": "pi",
"branch": "autorize/pi",
"base_commit": "{sha}",
"iter_in_progress": 1,
"current_step": "InvokeAgent",
"best_score": null,
"best_iter": null,
"started_at": "2026-05-20T00:00:00Z",
"deadline": "2099-01-01T00:00:00Z",
"iterations_completed": 0,
"consecutive_noops": 0
}}
"#,
);
fs::write(p.join(".autorize/pi/state.json"), state_json).unwrap();
let out = run_autorize(&["resume", "pi"], p);
assert!(
out.status.success(),
"autorize resume failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let recs = read_jsonl(&p.join(".autorize/pi/iterations.jsonl"));
assert_eq!(recs.len(), 3, "expected 3 records, got {recs:?}");
assert_eq!(recs[0]["iter"].as_u64(), Some(1));
assert_eq!(recs[0]["outcome"], "killed");
assert_eq!(recs[0]["notes"], "resumed after crash");
assert_eq!(recs[1]["iter"].as_u64(), Some(2));
assert_eq!(recs[2]["iter"].as_u64(), Some(3));
assert_eq!(
recs[1]["outcome"], "merged",
"iter 2 should merge; rec={:?}",
recs[1]
);
assert_eq!(
recs[2]["outcome"], "merged",
"iter 3 should merge; rec={:?}",
recs[2]
);
}