use aethershell::journal;
use aethershell::value::Value;
use std::path::PathBuf;
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
struct Session {
dir: PathBuf,
}
impl Session {
fn new(tag: &str) -> Self {
let dir = std::env::temp_dir().join(format!("ae_journal_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir");
std::env::set_var("AETHER_JOURNAL", "on");
journal::clear();
Self { dir }
}
fn path(&self, name: &str) -> String {
self.dir.join(name).to_string_lossy().into_owned()
}
}
impl Drop for Session {
fn drop(&mut self) {
journal::clear();
std::env::remove_var("AETHER_JOURNAL");
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn call(name: &str, args: Vec<Value>) -> Result<Value, String> {
let mut env = aethershell::env::Env::new();
aethershell::builtins::call(name, args, &mut env).map_err(|e| e.to_string())
}
fn field(v: &Value, key: &str) -> Value {
match v {
Value::Record(m) => m.get(key).cloned().unwrap_or(Value::Null),
other => panic!("expected a record, got {other:?}"),
}
}
#[test]
fn an_overwritten_file_is_restored_byte_for_byte() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let s = Session::new("overwrite");
let p = s.path("notes.txt");
std::fs::write(&p, "original contents").expect("seed");
call(
"file_write",
vec![Value::Str(p.clone()), Value::Str("clobbered".into())],
)
.expect("write should succeed");
assert_eq!(std::fs::read_to_string(&p).unwrap(), "clobbered");
let result = call("undo", vec![]).expect("undo");
assert_eq!(field(&result, "restored"), Value::Int(1));
assert_eq!(field(&result, "complete"), Value::Bool(true));
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"original contents",
"undo must restore the exact prior bytes"
);
}
#[test]
fn a_file_created_by_the_agent_is_removed_again() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let s = Session::new("create");
let p = s.path("new.txt");
assert!(!PathBuf::from(&p).exists());
call(
"file_write",
vec![Value::Str(p.clone()), Value::Str("fresh".into())],
)
.expect("write");
assert!(PathBuf::from(&p).exists());
let result = call("undo", vec![]).expect("undo");
assert_eq!(field(&result, "removed"), Value::Int(1));
assert!(
!PathBuf::from(&p).exists(),
"a file that did not exist before must not exist after undo"
);
}
#[test]
fn several_steps_rewind_in_reverse_order() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let s = Session::new("multi");
let p = s.path("v.txt");
std::fs::write(&p, "v0").expect("seed");
for v in ["v1", "v2", "v3"] {
call(
"file_write",
vec![Value::Str(p.clone()), Value::Str(v.into())],
)
.expect("write");
}
assert_eq!(std::fs::read_to_string(&p).unwrap(), "v3");
let result = call("undo", vec![Value::Int(2)]).expect("undo 2");
assert_eq!(field(&result, "restored"), Value::Int(2));
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"v1",
"rewinding two steps from v3 lands on v1, not v0"
);
}
#[test]
fn an_irreversible_step_is_reported_and_never_counted_as_restored() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let s = Session::new("irreversible");
let sub = s.path("subdir");
std::fs::create_dir_all(&sub).expect("subdir");
let _ = call("rm", vec![Value::Str(sub.clone())]);
let j = call("journal", vec![]).expect("journal");
let irreversible = field(&j, "irreversible");
assert_eq!(
irreversible,
Value::Int(1),
"a directory target must be journalled as irreversible, got {j:?}"
);
let result = call("undo", vec![]).expect("undo");
assert_eq!(field(&result, "restored"), Value::Int(0));
assert_eq!(field(&result, "skipped"), Value::Int(1));
assert_eq!(
field(&result, "complete"),
Value::Bool(false),
"an undo that could not reverse everything must not report completeness"
);
}
#[test]
fn the_journal_reports_what_it_can_and_cannot_reverse() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let s = Session::new("report");
let p = s.path("a.txt");
std::fs::write(&p, "x").expect("seed");
call("file_write", vec![Value::Str(p), Value::Str("y".into())]).expect("write");
let j = call("journal", vec![]).expect("journal");
assert_eq!(field(&j, "enabled"), Value::Bool(true));
assert_eq!(field(&j, "reversible"), Value::Int(1));
match field(&j, "entries") {
Value::Array(rows) => {
assert_eq!(rows.len(), 1);
assert_eq!(field(&rows[0], "reversible"), Value::Bool(true));
assert_eq!(field(&rows[0], "builtin"), Value::Str("file_write".into()));
}
other => panic!("expected entries array, got {other:?}"),
}
}
#[test]
fn a_read_only_call_is_never_journalled() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let s = Session::new("readonly");
let p = s.path("r.txt");
std::fs::write(&p, "data").expect("seed");
let _ = call("cat", vec![Value::Str(p)]);
assert_eq!(
journal::entries().len(),
0,
"a read must not enter the journal"
);
}
#[test]
fn undo_does_not_journal_itself() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let s = Session::new("selfjournal");
let p = s.path("s.txt");
std::fs::write(&p, "before").expect("seed");
call(
"file_write",
vec![Value::Str(p.clone()), Value::Str("after".into())],
)
.expect("write");
call("undo", vec![]).expect("undo");
assert_eq!(std::fs::read_to_string(&p).unwrap(), "before");
assert_eq!(
journal::entries().len(),
0,
"undo must consume the entry and add none of its own"
);
let again = call("undo", vec![]).expect("second undo");
assert_eq!(field(&again, "restored"), Value::Int(0));
assert_eq!(std::fs::read_to_string(&p).unwrap(), "before");
}
#[test]
fn journalling_is_off_for_humans_by_default() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("AETHER_JOURNAL");
std::env::remove_var("AETHER_MODE");
std::env::remove_var("AETHER_AGENT");
assert!(!journal::enabled(), "humans pay nothing for this");
std::env::set_var("AETHER_MODE", "agent");
assert!(journal::enabled(), "agents get recoverability");
std::env::remove_var("AETHER_MODE");
}
#[test]
fn undo_works_across_separate_processes() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!("ae_undo_xproc_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("session dir");
let target = dir.join("notes.txt");
std::fs::write(&target, "original").expect("seed");
let ae = env!("CARGO_BIN_EXE_ae");
let run = |code: String| -> String {
let out = std::process::Command::new(ae)
.args(["--agent", "-c", &code])
.env("AETHER_SESSION_DIR", &dir)
.env("AETHER_JOURNAL", "on")
.env("AETHER_POLICY", "permissive")
.env("AETHER_WORKSPACE", &dir)
.output()
.expect("run ae");
String::from_utf8_lossy(&out.stdout).into_owned()
};
let p = target.to_string_lossy().replace('\\', "/");
run(format!("file_write(\"{p}\", \"clobbered\")"));
assert_eq!(
std::fs::read_to_string(&target).unwrap(),
"clobbered",
"process 1 should have written"
);
let undone = run("undo()".to_string());
let restored = std::fs::read_to_string(&target).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(
restored, "original",
"a later process must be able to rewind an earlier one; undo said: {undone}"
);
}
#[test]
fn a_call_that_failed_leaves_nothing_to_undo() {
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let s = Session::new("failed_call");
let outside = std::env::temp_dir().join("ae_definitely_outside_ws.txt");
let _ = std::fs::remove_file(&outside);
std::env::set_var("AETHER_MODE", "agent");
std::env::set_var("AETHER_WORKSPACE", s.dir.to_string_lossy().into_owned());
let result = call(
"file_write",
vec![
Value::Str(outside.to_string_lossy().into_owned()),
Value::Str("nope".into()),
],
);
std::env::remove_var("AETHER_WORKSPACE");
std::env::remove_var("AETHER_MODE");
assert!(result.is_err(), "the jail should refuse this write");
assert!(
!outside.exists(),
"nothing should have been written outside the workspace"
);
assert_eq!(
journal::entries().len(),
0,
"a refused call must not leave an entry: {:?}",
journal::entries()
);
let undone = call("undo", vec![]).expect("undo");
assert_eq!(field(&undone, "skipped"), Value::Int(0));
assert_eq!(
field(&undone, "complete"),
Value::Bool(true),
"with nothing recorded, undo is trivially complete"
);
}