Skip to main content

undo_core/
lib.rs

1//! `undo-core` — the reversible side-effect engine behind `undo`,
2//! "Ctrl-Z for AI agents".
3//!
4//! The model is small on purpose:
5//!   - [`Effect`] is a change paired with how to reverse it.
6//!   - [`Undo`] is an append-only journal of effects, grouped by checkpoint,
7//!     plus a rollback executor that replays their inverses — crash-safely,
8//!     under a cross-process lock, with directory/permission/symlink fidelity
9//!     and a redo stack.
10//!
11//! Filesystem effects are fully reversible today. The same journal is built to
12//! carry network, email, and database effects (which know their own inverse)
13//! without changing the rollback path — that uniform reversibility is the point.
14
15mod diff;
16mod effect;
17mod journal;
18mod meta;
19mod store;
20
21pub use diff::DiffEntry;
22pub use effect::{Effect, HttpCompensator};
23pub use journal::{path_is_ignored, RedoReport, RollbackReport, Row, Status, Undo};
24pub use store::Store;
25
26#[cfg(test)]
27mod tests {
28    use super::*;
29    use std::fs;
30    use std::path::PathBuf;
31
32    fn tmp() -> PathBuf {
33        use std::sync::atomic::{AtomicU64, Ordering};
34        static COUNTER: AtomicU64 = AtomicU64::new(0);
35        // A monotonic counter guarantees uniqueness even when parallel tests
36        // hit the same coarse-resolution timestamp.
37        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
38        let base = std::env::temp_dir().join(format!("undo-test-{}-{}", std::process::id(), n));
39        fs::create_dir_all(&base).unwrap();
40        base
41    }
42
43    #[test]
44    fn rolls_back_modify_create_delete() {
45        let dir = tmp();
46        let u = Undo::init(&dir).unwrap();
47        let kept = dir.join("keep.txt");
48        fs::write(&kept, b"ORIGINAL").unwrap();
49        u.checkpoint("before agent").unwrap();
50
51        u.track(&kept).unwrap();
52        fs::write(&kept, b"MUTATED").unwrap();
53
54        let created = dir.join("nested/new.txt");
55        u.track(&created).unwrap();
56        fs::create_dir_all(created.parent().unwrap()).unwrap();
57        fs::write(&created, b"junk").unwrap();
58
59        u.rollback(None).unwrap();
60        assert_eq!(fs::read(&kept).unwrap(), b"ORIGINAL");
61        assert!(!created.exists());
62        fs::remove_dir_all(&dir).ok();
63    }
64
65    #[test]
66    fn double_track_keeps_earliest_snapshot() {
67        let dir = tmp();
68        let u = Undo::init(&dir).unwrap();
69        let f = dir.join("a.txt");
70        fs::write(&f, b"v1").unwrap();
71        u.checkpoint("c").unwrap();
72        u.track(&f).unwrap();
73        fs::write(&f, b"v2").unwrap();
74        u.track(&f).unwrap();
75        fs::write(&f, b"v3").unwrap();
76        u.rollback(None).unwrap();
77        assert_eq!(fs::read(&f).unwrap(), b"v1");
78        fs::remove_dir_all(&dir).ok();
79    }
80
81    #[test]
82    fn restores_a_deleted_directory_tree() {
83        let dir = tmp();
84        let u = Undo::init(&dir).unwrap();
85        fs::create_dir_all(dir.join("src/util")).unwrap();
86        fs::write(dir.join("src/main.rs"), b"fn main(){}").unwrap();
87        fs::write(dir.join("src/util/log.rs"), b"// log").unwrap();
88        u.checkpoint("before").unwrap();
89
90        u.track(&dir.join("src")).unwrap();
91        fs::remove_dir_all(dir.join("src")).unwrap();
92        assert!(!dir.join("src").exists());
93
94        u.rollback(None).unwrap();
95        assert_eq!(fs::read(dir.join("src/main.rs")).unwrap(), b"fn main(){}");
96        assert_eq!(fs::read(dir.join("src/util/log.rs")).unwrap(), b"// log");
97        fs::remove_dir_all(&dir).ok();
98    }
99
100    #[test]
101    fn prunes_files_the_agent_added_to_a_tracked_dir() {
102        let dir = tmp();
103        let u = Undo::init(&dir).unwrap();
104        fs::create_dir_all(dir.join("conf")).unwrap();
105        fs::write(dir.join("conf/a.txt"), b"a").unwrap();
106        u.checkpoint("before").unwrap();
107
108        u.track(&dir.join("conf")).unwrap();
109        fs::write(dir.join("conf/sneaky.txt"), b"added by agent").unwrap();
110
111        u.rollback(None).unwrap();
112        assert!(dir.join("conf/a.txt").exists());
113        assert!(
114            !dir.join("conf/sneaky.txt").exists(),
115            "agent-added file should be pruned"
116        );
117        fs::remove_dir_all(&dir).ok();
118    }
119
120    #[cfg(unix)]
121    #[test]
122    fn restores_executable_bit() {
123        use std::os::unix::fs::PermissionsExt;
124        let dir = tmp();
125        let u = Undo::init(&dir).unwrap();
126        let script = dir.join("run.sh");
127        fs::write(&script, b"#!/bin/sh\necho hi\n").unwrap();
128        fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
129        u.checkpoint("c").unwrap();
130
131        u.track(&script).unwrap();
132        fs::set_permissions(&script, fs::Permissions::from_mode(0o644)).unwrap();
133        fs::write(&script, b"tampered").unwrap();
134
135        u.rollback(None).unwrap();
136        let mode = fs::metadata(&script).unwrap().permissions().mode() & 0o777;
137        assert_eq!(mode, 0o755, "executable bit must be restored");
138        assert_eq!(fs::read(&script).unwrap(), b"#!/bin/sh\necho hi\n");
139        fs::remove_dir_all(&dir).ok();
140    }
141
142    #[cfg(unix)]
143    #[test]
144    fn restores_a_symlink_not_its_target() {
145        let dir = tmp();
146        let u = Undo::init(&dir).unwrap();
147        fs::write(dir.join("real.txt"), b"real").unwrap();
148        std::os::unix::fs::symlink("real.txt", dir.join("link")).unwrap();
149        u.checkpoint("c").unwrap();
150
151        u.track(&dir.join("link")).unwrap();
152        fs::remove_file(dir.join("link")).unwrap();
153        fs::write(dir.join("link"), b"now a regular file").unwrap();
154
155        u.rollback(None).unwrap();
156        let meta = fs::symlink_metadata(dir.join("link")).unwrap();
157        assert!(meta.file_type().is_symlink(), "should be a symlink again");
158        assert_eq!(
159            fs::read_link(dir.join("link")).unwrap(),
160            PathBuf::from("real.txt")
161        );
162        fs::remove_dir_all(&dir).ok();
163    }
164
165    #[test]
166    fn redo_reapplies_the_rollback() {
167        let dir = tmp();
168        let u = Undo::init(&dir).unwrap();
169        let f = dir.join("doc.txt");
170        fs::write(&f, b"original").unwrap();
171        u.checkpoint("c").unwrap();
172        u.track(&f).unwrap();
173        fs::write(&f, b"agent edit").unwrap();
174
175        u.rollback(None).unwrap();
176        assert_eq!(fs::read(&f).unwrap(), b"original");
177
178        let report = u.redo().unwrap();
179        assert!(report.failed.is_empty());
180        assert_eq!(
181            fs::read(&f).unwrap(),
182            b"agent edit",
183            "redo restores the agent's change"
184        );
185
186        // And we can roll back again after redo (journal was re-extended).
187        u.rollback(None).unwrap();
188        assert_eq!(fs::read(&f).unwrap(), b"original");
189        fs::remove_dir_all(&dir).ok();
190    }
191
192    #[test]
193    fn refuses_paths_outside_the_project() {
194        let dir = tmp();
195        let u = Undo::init(&dir).unwrap();
196        u.checkpoint("c").unwrap();
197        let outside = u.track(std::path::Path::new("/etc/hosts"));
198        assert!(
199            outside.is_err(),
200            "tracking outside the project must be refused"
201        );
202        let traversal = u.track(std::path::Path::new("../../../etc/hosts"));
203        assert!(traversal.is_err(), "path traversal must be refused");
204        fs::remove_dir_all(&dir).ok();
205    }
206
207    #[test]
208    fn writes_gitignore_on_init() {
209        let dir = tmp();
210        Undo::init(&dir).unwrap();
211        let gi = fs::read_to_string(dir.join(".gitignore")).unwrap();
212        assert!(gi.contains(".undo/"), "init should gitignore .undo");
213        fs::remove_dir_all(&dir).ok();
214    }
215
216    #[test]
217    fn ignores_noise_directories() {
218        let dir = tmp();
219        let u = Undo::init(&dir).unwrap();
220        fs::create_dir_all(dir.join("node_modules/pkg")).unwrap();
221        fs::write(dir.join("node_modules/pkg/index.js"), b"dep").unwrap();
222        fs::create_dir_all(dir.join("src")).unwrap();
223        fs::write(dir.join("src/main.rs"), b"ORIGINAL").unwrap();
224        u.checkpoint("c").unwrap();
225
226        // Track the whole project root.
227        u.track(&dir).unwrap();
228
229        // Agent touches both a real source file and node_modules.
230        fs::write(dir.join("src/main.rs"), b"EDITED").unwrap();
231        fs::write(dir.join("node_modules/pkg/index.js"), b"changed dep").unwrap();
232        fs::write(dir.join("node_modules/added.js"), b"new dep file").unwrap();
233
234        u.rollback(None).unwrap();
235
236        // Real source is restored...
237        assert_eq!(fs::read(dir.join("src/main.rs")).unwrap(), b"ORIGINAL");
238        // ...but node_modules is left entirely alone (never captured, never pruned).
239        assert_eq!(
240            fs::read(dir.join("node_modules/pkg/index.js")).unwrap(),
241            b"changed dep"
242        );
243        assert!(dir.join("node_modules/added.js").exists());
244        fs::remove_dir_all(&dir).ok();
245    }
246
247    #[test]
248    fn selective_undo_reverts_one_file_keeping_the_rest() {
249        let dir = tmp();
250        let u = Undo::init(&dir).unwrap();
251        fs::write(dir.join("a.txt"), b"A1").unwrap();
252        fs::write(dir.join("b.txt"), b"B1").unwrap();
253        u.checkpoint("c").unwrap();
254
255        u.track(&dir.join("a.txt")).unwrap();
256        u.track(&dir.join("b.txt")).unwrap();
257        fs::write(dir.join("a.txt"), b"A2").unwrap();
258        fs::write(dir.join("b.txt"), b"B2").unwrap();
259
260        // Revert ONLY a.txt.
261        let msg = u.revert(&dir.join("a.txt")).unwrap();
262        assert!(msg.is_some());
263        assert_eq!(fs::read(dir.join("a.txt")).unwrap(), b"A1", "a reverted");
264        assert_eq!(fs::read(dir.join("b.txt")).unwrap(), b"B2", "b untouched");
265
266        // b is still tracked, so a full rollback restores it.
267        u.rollback(None).unwrap();
268        assert_eq!(fs::read(dir.join("b.txt")).unwrap(), b"B1");
269
270        // Reverting an untracked path is a no-op.
271        assert!(u.revert(&dir.join("nope.txt")).unwrap().is_none());
272        fs::remove_dir_all(&dir).ok();
273    }
274
275    #[test]
276    fn diff_shows_what_the_agent_changed() {
277        let dir = tmp();
278        let u = Undo::init(&dir).unwrap();
279        fs::write(dir.join("keep.txt"), b"line1\nline2\n").unwrap();
280        u.checkpoint("c").unwrap();
281
282        u.track(&dir.join("keep.txt")).unwrap();
283        u.track(&dir.join("new.txt")).unwrap();
284        fs::write(dir.join("keep.txt"), b"line1\nCHANGED\n").unwrap();
285        fs::write(dir.join("new.txt"), b"brand new\n").unwrap();
286
287        let entries = u.diff().unwrap();
288        let modified = entries
289            .iter()
290            .find(|e| e.path.ends_with("keep.txt"))
291            .unwrap();
292        assert_eq!(modified.status, "modified");
293        assert_eq!(modified.added, 1);
294        assert_eq!(modified.removed, 1);
295        assert!(modified.hunk.contains("+line1\nCHANGED") || modified.hunk.contains("CHANGED"));
296
297        let created = entries
298            .iter()
299            .find(|e| e.path.ends_with("new.txt"))
300            .unwrap();
301        assert_eq!(created.status, "created");
302        assert_eq!(created.added, 1);
303        fs::remove_dir_all(&dir).ok();
304    }
305
306    #[test]
307    fn re_tracking_a_path_is_a_cheap_noop() {
308        let dir = tmp();
309        let u = Undo::init(&dir).unwrap();
310        fs::write(dir.join("a.txt"), b"v1").unwrap();
311        u.checkpoint("c").unwrap();
312
313        let first = u.track(&dir.join("a.txt")).unwrap();
314        assert_eq!(first.len(), 1, "first track records the file");
315        let second = u.track(&dir.join("a.txt")).unwrap();
316        assert!(second.is_empty(), "re-tracking is a no-op");
317        fs::remove_dir_all(&dir).ok();
318    }
319}