mod diff;
mod effect;
mod journal;
mod meta;
mod store;
pub use diff::DiffEntry;
pub use effect::{Effect, HttpCompensator};
pub use journal::{path_is_ignored, RedoReport, RollbackReport, Row, Status, Undo};
pub use store::Store;
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn tmp() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let base = std::env::temp_dir().join(format!("undo-test-{}-{}", std::process::id(), n));
fs::create_dir_all(&base).unwrap();
base
}
#[test]
fn rolls_back_modify_create_delete() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
let kept = dir.join("keep.txt");
fs::write(&kept, b"ORIGINAL").unwrap();
u.checkpoint("before agent").unwrap();
u.track(&kept).unwrap();
fs::write(&kept, b"MUTATED").unwrap();
let created = dir.join("nested/new.txt");
u.track(&created).unwrap();
fs::create_dir_all(created.parent().unwrap()).unwrap();
fs::write(&created, b"junk").unwrap();
u.rollback(None).unwrap();
assert_eq!(fs::read(&kept).unwrap(), b"ORIGINAL");
assert!(!created.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn double_track_keeps_earliest_snapshot() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
let f = dir.join("a.txt");
fs::write(&f, b"v1").unwrap();
u.checkpoint("c").unwrap();
u.track(&f).unwrap();
fs::write(&f, b"v2").unwrap();
u.track(&f).unwrap();
fs::write(&f, b"v3").unwrap();
u.rollback(None).unwrap();
assert_eq!(fs::read(&f).unwrap(), b"v1");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn restores_a_deleted_directory_tree() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
fs::create_dir_all(dir.join("src/util")).unwrap();
fs::write(dir.join("src/main.rs"), b"fn main(){}").unwrap();
fs::write(dir.join("src/util/log.rs"), b"// log").unwrap();
u.checkpoint("before").unwrap();
u.track(&dir.join("src")).unwrap();
fs::remove_dir_all(dir.join("src")).unwrap();
assert!(!dir.join("src").exists());
u.rollback(None).unwrap();
assert_eq!(fs::read(dir.join("src/main.rs")).unwrap(), b"fn main(){}");
assert_eq!(fs::read(dir.join("src/util/log.rs")).unwrap(), b"// log");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn prunes_files_the_agent_added_to_a_tracked_dir() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
fs::create_dir_all(dir.join("conf")).unwrap();
fs::write(dir.join("conf/a.txt"), b"a").unwrap();
u.checkpoint("before").unwrap();
u.track(&dir.join("conf")).unwrap();
fs::write(dir.join("conf/sneaky.txt"), b"added by agent").unwrap();
u.rollback(None).unwrap();
assert!(dir.join("conf/a.txt").exists());
assert!(
!dir.join("conf/sneaky.txt").exists(),
"agent-added file should be pruned"
);
fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn restores_executable_bit() {
use std::os::unix::fs::PermissionsExt;
let dir = tmp();
let u = Undo::init(&dir).unwrap();
let script = dir.join("run.sh");
fs::write(&script, b"#!/bin/sh\necho hi\n").unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
u.checkpoint("c").unwrap();
u.track(&script).unwrap();
fs::set_permissions(&script, fs::Permissions::from_mode(0o644)).unwrap();
fs::write(&script, b"tampered").unwrap();
u.rollback(None).unwrap();
let mode = fs::metadata(&script).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755, "executable bit must be restored");
assert_eq!(fs::read(&script).unwrap(), b"#!/bin/sh\necho hi\n");
fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn restores_a_symlink_not_its_target() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
fs::write(dir.join("real.txt"), b"real").unwrap();
std::os::unix::fs::symlink("real.txt", dir.join("link")).unwrap();
u.checkpoint("c").unwrap();
u.track(&dir.join("link")).unwrap();
fs::remove_file(dir.join("link")).unwrap();
fs::write(dir.join("link"), b"now a regular file").unwrap();
u.rollback(None).unwrap();
let meta = fs::symlink_metadata(dir.join("link")).unwrap();
assert!(meta.file_type().is_symlink(), "should be a symlink again");
assert_eq!(
fs::read_link(dir.join("link")).unwrap(),
PathBuf::from("real.txt")
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn redo_reapplies_the_rollback() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
let f = dir.join("doc.txt");
fs::write(&f, b"original").unwrap();
u.checkpoint("c").unwrap();
u.track(&f).unwrap();
fs::write(&f, b"agent edit").unwrap();
u.rollback(None).unwrap();
assert_eq!(fs::read(&f).unwrap(), b"original");
let report = u.redo().unwrap();
assert!(report.failed.is_empty());
assert_eq!(
fs::read(&f).unwrap(),
b"agent edit",
"redo restores the agent's change"
);
u.rollback(None).unwrap();
assert_eq!(fs::read(&f).unwrap(), b"original");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn refuses_paths_outside_the_project() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
u.checkpoint("c").unwrap();
let outside = u.track(std::path::Path::new("/etc/hosts"));
assert!(
outside.is_err(),
"tracking outside the project must be refused"
);
let traversal = u.track(std::path::Path::new("../../../etc/hosts"));
assert!(traversal.is_err(), "path traversal must be refused");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn writes_gitignore_on_init() {
let dir = tmp();
Undo::init(&dir).unwrap();
let gi = fs::read_to_string(dir.join(".gitignore")).unwrap();
assert!(gi.contains(".undo/"), "init should gitignore .undo");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn ignores_noise_directories() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
fs::write(dir.join("node_modules/pkg/index.js"), b"dep").unwrap();
fs::create_dir_all(dir.join("src")).unwrap();
fs::write(dir.join("src/main.rs"), b"ORIGINAL").unwrap();
u.checkpoint("c").unwrap();
u.track(&dir).unwrap();
fs::write(dir.join("src/main.rs"), b"EDITED").unwrap();
fs::write(dir.join("node_modules/pkg/index.js"), b"changed dep").unwrap();
fs::write(dir.join("node_modules/added.js"), b"new dep file").unwrap();
u.rollback(None).unwrap();
assert_eq!(fs::read(dir.join("src/main.rs")).unwrap(), b"ORIGINAL");
assert_eq!(
fs::read(dir.join("node_modules/pkg/index.js")).unwrap(),
b"changed dep"
);
assert!(dir.join("node_modules/added.js").exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn selective_undo_reverts_one_file_keeping_the_rest() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
fs::write(dir.join("a.txt"), b"A1").unwrap();
fs::write(dir.join("b.txt"), b"B1").unwrap();
u.checkpoint("c").unwrap();
u.track(&dir.join("a.txt")).unwrap();
u.track(&dir.join("b.txt")).unwrap();
fs::write(dir.join("a.txt"), b"A2").unwrap();
fs::write(dir.join("b.txt"), b"B2").unwrap();
let msg = u.revert(&dir.join("a.txt")).unwrap();
assert!(msg.is_some());
assert_eq!(fs::read(dir.join("a.txt")).unwrap(), b"A1", "a reverted");
assert_eq!(fs::read(dir.join("b.txt")).unwrap(), b"B2", "b untouched");
u.rollback(None).unwrap();
assert_eq!(fs::read(dir.join("b.txt")).unwrap(), b"B1");
assert!(u.revert(&dir.join("nope.txt")).unwrap().is_none());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn diff_shows_what_the_agent_changed() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
fs::write(dir.join("keep.txt"), b"line1\nline2\n").unwrap();
u.checkpoint("c").unwrap();
u.track(&dir.join("keep.txt")).unwrap();
u.track(&dir.join("new.txt")).unwrap();
fs::write(dir.join("keep.txt"), b"line1\nCHANGED\n").unwrap();
fs::write(dir.join("new.txt"), b"brand new\n").unwrap();
let entries = u.diff().unwrap();
let modified = entries
.iter()
.find(|e| e.path.ends_with("keep.txt"))
.unwrap();
assert_eq!(modified.status, "modified");
assert_eq!(modified.added, 1);
assert_eq!(modified.removed, 1);
assert!(modified.hunk.contains("+line1\nCHANGED") || modified.hunk.contains("CHANGED"));
let created = entries
.iter()
.find(|e| e.path.ends_with("new.txt"))
.unwrap();
assert_eq!(created.status, "created");
assert_eq!(created.added, 1);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn re_tracking_a_path_is_a_cheap_noop() {
let dir = tmp();
let u = Undo::init(&dir).unwrap();
fs::write(dir.join("a.txt"), b"v1").unwrap();
u.checkpoint("c").unwrap();
let first = u.track(&dir.join("a.txt")).unwrap();
assert_eq!(first.len(), 1, "first track records the file");
let second = u.track(&dir.join("a.txt")).unwrap();
assert!(second.is_empty(), "re-tracking is a no-op");
fs::remove_dir_all(&dir).ok();
}
}