1mod 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 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 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 u.track(&dir).unwrap();
228
229 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 assert_eq!(fs::read(dir.join("src/main.rs")).unwrap(), b"ORIGINAL");
238 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 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 u.rollback(None).unwrap();
268 assert_eq!(fs::read(dir.join("b.txt")).unwrap(), b"B1");
269
270 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}