Skip to main content

doing_ops/
undo.rs

1use std::{
2  fs,
3  path::{Path, PathBuf},
4};
5
6use chrono::Local;
7use doing_error::{Error, Result};
8
9use crate::backup::{self, backup_prefix};
10
11/// Restore from the Nth most recent consumed (`.undone`) backup (1-indexed),
12/// reversing the last N undo operations.
13///
14/// After restoration all consumed backups are converted back to `.bak`,
15/// fully resetting the undo state.
16pub fn redo(source: &Path, backup_dir: &Path, count: usize) -> Result<()> {
17  let undone = backup::list_undone(source, backup_dir)?;
18  let count = if count == 0 { 1 } else { count };
19
20  if count > undone.len() {
21    return Err(Error::HistoryLimit("end of redo history".into()));
22  }
23
24  fs::copy(&undone[count - 1], source)?;
25  unconsume_all(source, backup_dir)?;
26  Ok(())
27}
28
29/// Restore the doing file from the Nth most recent unconsumed backup (1-indexed).
30///
31/// Before restoring, a consumed snapshot of the current `source` is created so
32/// that [`redo`] can reverse the undo. The restored backup and all newer backups
33/// are also marked as consumed (renamed from `.bak` to `.undone`) so that
34/// subsequent calls walk backwards through history. Returns an error if fewer
35/// than `count` unconsumed backups exist.
36pub fn undo(source: &Path, backup_dir: &Path, count: usize) -> Result<()> {
37  let backups = backup::list_backups(source, backup_dir)?;
38  if count == 0 || count > backups.len() {
39    return Err(Error::HistoryLimit("end of undo history".into()));
40  }
41
42  create_undone(source, backup_dir)?;
43
44  // Atomic restore: write to a temp file, then rename into place
45  let tmp = source.with_extension("tmp");
46  fs::copy(&backups[count - 1], &tmp)?;
47  fs::rename(&tmp, source)?;
48
49  for backup in &backups[..count] {
50    consume(backup)?;
51  }
52
53  Ok(())
54}
55
56/// Rename a `.bak` file to `.undone`, marking it as consumed by undo.
57fn consume(path: &Path) -> Result<()> {
58  let undone = path.with_extension("undone");
59  fs::rename(path, undone)?;
60  Ok(())
61}
62
63/// Create an `.undone` snapshot of `source` in `backup_dir`.
64///
65/// Uses microsecond-precision timestamps to avoid filename collisions with
66/// consumed `.bak` files that share the same second.
67fn create_undone(source: &Path, backup_dir: &Path) -> Result<PathBuf> {
68  fs::create_dir_all(backup_dir)?;
69
70  let prefix = backup_prefix(source);
71  let timestamp = Local::now().format("%Y%m%d_%H%M%S_%6f");
72  let name = format!("{prefix}{timestamp}.undone");
73  let path = backup_dir.join(name);
74
75  fs::copy(source, &path)?;
76  Ok(path)
77}
78
79/// Rename all `.undone` files back to `.bak`, restoring them as available backups.
80fn unconsume_all(source: &Path, backup_dir: &Path) -> Result<()> {
81  for undone in backup::list_undone(source, backup_dir)? {
82    let bak = undone.with_extension("bak");
83    fs::rename(undone, bak)?;
84  }
85  Ok(())
86}
87
88#[cfg(test)]
89mod test {
90  use std::fs;
91
92  use super::*;
93
94  mod redo {
95    use pretty_assertions::assert_eq;
96
97    use super::*;
98
99    #[test]
100    fn it_converts_all_undone_files_back_to_bak() {
101      let dir = tempfile::tempdir().unwrap();
102      let source = dir.path().join("doing.md");
103      let backup_dir = dir.path().join("backups");
104      fs::create_dir_all(&backup_dir).unwrap();
105      fs::write(&source, "current").unwrap();
106
107      let prefix = backup_prefix(&source);
108      fs::write(backup_dir.join(format!("{prefix}20240101_000002.undone")), "newer").unwrap();
109      fs::write(backup_dir.join(format!("{prefix}20240101_000001.undone")), "older").unwrap();
110
111      redo(&source, &backup_dir, 1).unwrap();
112
113      let undone = backup::list_undone(&source, &backup_dir).unwrap();
114      assert!(undone.is_empty());
115
116      let bak = backup::list_backups(&source, &backup_dir).unwrap();
117      assert_eq!(bak.len(), 2);
118    }
119
120    #[test]
121    fn it_restores_from_newest_undone_file() {
122      let dir = tempfile::tempdir().unwrap();
123      let source = dir.path().join("doing.md");
124      let backup_dir = dir.path().join("backups");
125      fs::create_dir_all(&backup_dir).unwrap();
126      fs::write(&source, "current").unwrap();
127
128      let prefix = backup_prefix(&source);
129      fs::write(
130        backup_dir.join(format!("{prefix}20240101_000001.undone")),
131        "older undone",
132      )
133      .unwrap();
134      fs::write(
135        backup_dir.join(format!("{prefix}20240101_000002.undone")),
136        "newest undone",
137      )
138      .unwrap();
139
140      redo(&source, &backup_dir, 1).unwrap();
141
142      assert_eq!(fs::read_to_string(&source).unwrap(), "newest undone");
143    }
144
145    #[test]
146    fn it_returns_error_when_no_undone_files() {
147      let dir = tempfile::tempdir().unwrap();
148      let source = dir.path().join("doing.md");
149      let backup_dir = dir.path().join("backups");
150      fs::create_dir_all(&backup_dir).unwrap();
151
152      let result = redo(&source, &backup_dir, 1);
153
154      assert!(result.is_err());
155      assert!(result.unwrap_err().to_string().contains("redo history"));
156    }
157  }
158
159  mod undo {
160    use pretty_assertions::assert_eq;
161
162    use super::*;
163
164    #[test]
165    fn it_consumes_backup_after_restoring() {
166      let dir = tempfile::tempdir().unwrap();
167      let source = dir.path().join("doing.md");
168      let backup_dir = dir.path().join("backups");
169      fs::create_dir_all(&backup_dir).unwrap();
170      fs::write(&source, "current state").unwrap();
171
172      let prefix = backup_prefix(&source);
173      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup1").unwrap();
174
175      undo(&source, &backup_dir, 1).unwrap();
176
177      let remaining_bak = backup::list_backups(&source, &backup_dir).unwrap();
178      assert!(remaining_bak.is_empty());
179
180      let undone = backup::list_undone(&source, &backup_dir).unwrap();
181      assert_eq!(undone.len(), 2);
182    }
183
184    #[test]
185    fn it_creates_undone_snapshot_of_current_state() {
186      let dir = tempfile::tempdir().unwrap();
187      let source = dir.path().join("doing.md");
188      let backup_dir = dir.path().join("backups");
189      fs::create_dir_all(&backup_dir).unwrap();
190      fs::write(&source, "current state").unwrap();
191
192      let prefix = backup_prefix(&source);
193      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup1").unwrap();
194
195      undo(&source, &backup_dir, 1).unwrap();
196
197      let undone = backup::list_undone(&source, &backup_dir).unwrap();
198      let newest_undone = &undone[0];
199      assert_eq!(fs::read_to_string(newest_undone).unwrap(), "current state");
200    }
201
202    #[test]
203    fn it_restores_atomically_without_temp_file_residue() {
204      let dir = tempfile::tempdir().unwrap();
205      let source = dir.path().join("doing.md");
206      let backup_dir = dir.path().join("backups");
207      fs::create_dir_all(&backup_dir).unwrap();
208      fs::write(&source, "current").unwrap();
209
210      let prefix = backup_prefix(&source);
211      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup").unwrap();
212
213      undo(&source, &backup_dir, 1).unwrap();
214
215      // Source should be restored and no temp file should remain
216      assert_eq!(fs::read_to_string(&source).unwrap(), "backup");
217      assert!(!source.with_extension("tmp").exists());
218    }
219
220    #[test]
221    fn it_restores_from_most_recent_by_default() {
222      let dir = tempfile::tempdir().unwrap();
223      let source = dir.path().join("doing.md");
224      let backup_dir = dir.path().join("backups");
225      fs::create_dir_all(&backup_dir).unwrap();
226      fs::write(&source, "current").unwrap();
227
228      let prefix = backup_prefix(&source);
229      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "oldest").unwrap();
230      fs::write(backup_dir.join(format!("{prefix}20240101_000002.bak")), "newest").unwrap();
231
232      undo(&source, &backup_dir, 1).unwrap();
233
234      assert_eq!(fs::read_to_string(&source).unwrap(), "newest");
235    }
236
237    #[test]
238    fn it_restores_from_nth_backup() {
239      let dir = tempfile::tempdir().unwrap();
240      let source = dir.path().join("doing.md");
241      let backup_dir = dir.path().join("backups");
242      fs::create_dir_all(&backup_dir).unwrap();
243      fs::write(&source, "current").unwrap();
244
245      let prefix = backup_prefix(&source);
246      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "oldest").unwrap();
247      fs::write(backup_dir.join(format!("{prefix}20240101_000002.bak")), "middle").unwrap();
248      fs::write(backup_dir.join(format!("{prefix}20240101_000003.bak")), "newest").unwrap();
249
250      undo(&source, &backup_dir, 2).unwrap();
251
252      assert_eq!(fs::read_to_string(&source).unwrap(), "middle");
253    }
254
255    #[test]
256    fn it_returns_error_when_count_exceeds_history() {
257      let dir = tempfile::tempdir().unwrap();
258      let source = dir.path().join("doing.md");
259      let backup_dir = dir.path().join("backups");
260      fs::create_dir_all(&backup_dir).unwrap();
261      fs::write(&source, "current").unwrap();
262
263      let prefix = backup_prefix(&source);
264      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup").unwrap();
265
266      let result = undo(&source, &backup_dir, 5);
267
268      assert!(result.is_err());
269      assert!(result.unwrap_err().to_string().contains("undo history"));
270    }
271
272    #[test]
273    fn it_returns_error_when_count_is_zero() {
274      let dir = tempfile::tempdir().unwrap();
275      let source = dir.path().join("doing.md");
276      let backup_dir = dir.path().join("backups");
277      fs::create_dir_all(&backup_dir).unwrap();
278      fs::write(&source, "current").unwrap();
279
280      let prefix = backup_prefix(&source);
281      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "backup").unwrap();
282
283      let result = undo(&source, &backup_dir, 0);
284
285      assert!(result.is_err());
286    }
287
288    #[test]
289    fn it_walks_backwards_on_sequential_calls() {
290      let dir = tempfile::tempdir().unwrap();
291      let source = dir.path().join("doing.md");
292      let backup_dir = dir.path().join("backups");
293      fs::create_dir_all(&backup_dir).unwrap();
294      fs::write(&source, "current").unwrap();
295
296      let prefix = backup_prefix(&source);
297      fs::write(backup_dir.join(format!("{prefix}20240101_000001.bak")), "oldest").unwrap();
298      fs::write(backup_dir.join(format!("{prefix}20240101_000002.bak")), "middle").unwrap();
299      fs::write(backup_dir.join(format!("{prefix}20240101_000003.bak")), "newest").unwrap();
300
301      undo(&source, &backup_dir, 1).unwrap();
302      assert_eq!(fs::read_to_string(&source).unwrap(), "newest");
303
304      undo(&source, &backup_dir, 1).unwrap();
305      assert_eq!(fs::read_to_string(&source).unwrap(), "middle");
306
307      undo(&source, &backup_dir, 1).unwrap();
308      assert_eq!(fs::read_to_string(&source).unwrap(), "oldest");
309
310      let result = undo(&source, &backup_dir, 1);
311      assert!(result.is_err());
312    }
313  }
314}